- harness/es.py: append-only event store + проекции (образец — кооп-портал) - harness/levels.py: уровни 1с/1м/1ч/1д, контекст ∝ масштабу - harness/scheduler.py: уровневые потоки, идемпотентные тики, absorbed-стабы - harness/isolated_runner.py + _llm_worker.py: LLM-тик в сабпроцессе (глобальный asyncio-рантайм Strands не переживает 2+ create_harness) - cli.py: init/task/tasks/status/events + requeue (написан самим минутным агентом) - 9router: отдельный ключ sk-simbiot, модель auto (glm-5.3 первым)
501 lines
19 KiB
Python
501 lines
19 KiB
Python
"""Writer model provider.
|
|
|
|
- Docs: https://dev.writer.com/home/introduction
|
|
"""
|
|
|
|
import base64
|
|
import json
|
|
import logging
|
|
import mimetypes
|
|
from collections.abc import AsyncGenerator
|
|
from typing import Any, TypeVar, cast
|
|
|
|
import writerai
|
|
from pydantic import BaseModel
|
|
from typing_extensions import Unpack, override
|
|
|
|
from ..types.content import ContentBlock, Messages
|
|
from ..types.exceptions import ContextWindowOverflowException, ModelThrottledException
|
|
from ..types.streaming import StreamEvent
|
|
from ..types.tools import ToolChoice, ToolResult, ToolSpec, ToolUse
|
|
from ._validation import (
|
|
_has_location_source,
|
|
validate_config_keys,
|
|
warn_on_cache_config_not_supported,
|
|
warn_on_tool_choice_not_supported,
|
|
)
|
|
from .model import BaseModelConfig, CacheConfig, Model
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
T = TypeVar("T", bound=BaseModel)
|
|
|
|
|
|
class WriterModel(Model):
|
|
"""Writer API model provider implementation."""
|
|
|
|
OVERFLOW_MESSAGES = {
|
|
"this model's maximum context length is",
|
|
"exceed context limit",
|
|
"model's maximum context limit",
|
|
"is longer than the model's context length",
|
|
"prompt is too long",
|
|
"too many tokens",
|
|
}
|
|
|
|
class WriterConfig(BaseModelConfig, total=False):
|
|
"""Configuration options for Writer API.
|
|
|
|
Attributes:
|
|
model_id: Model name to use (e.g. palmyra-x5, palmyra-x4, etc.).
|
|
max_tokens: Maximum number of tokens to generate.
|
|
cache_config: Prompt caching settings. Writer honors no cache fields, so any set field is ignored.
|
|
stop: Default stop sequences.
|
|
stream_options: Additional options for streaming.
|
|
temperature: What sampling temperature to use.
|
|
top_p: Threshold for 'nucleus sampling'
|
|
"""
|
|
|
|
model_id: str
|
|
max_tokens: int | None
|
|
cache_config: CacheConfig | None
|
|
stop: str | list[str] | None
|
|
stream_options: dict[str, Any]
|
|
temperature: float | None
|
|
top_p: float | None
|
|
|
|
def __init__(self, client_args: dict[str, Any] | None = None, **model_config: Unpack[WriterConfig]):
|
|
"""Initialize provider instance.
|
|
|
|
Args:
|
|
client_args: Arguments for the Writer client (e.g., api_key, base_url, timeout, etc.).
|
|
**model_config: Configuration options for the Writer model.
|
|
"""
|
|
validate_config_keys(model_config, self.WriterConfig)
|
|
self.config = WriterModel.WriterConfig(**model_config)
|
|
|
|
logger.debug("config=<%s> | initializing", self.config)
|
|
|
|
client_args = client_args or {}
|
|
self.client = writerai.AsyncClient(**client_args)
|
|
|
|
@override
|
|
def update_config(self, **model_config: Unpack[WriterConfig]) -> None: # type: ignore[override]
|
|
"""Update the Writer Model configuration with the provided arguments.
|
|
|
|
Args:
|
|
**model_config: Configuration overrides.
|
|
"""
|
|
validate_config_keys(model_config, self.WriterConfig)
|
|
self.config.update(model_config)
|
|
|
|
@override
|
|
def get_config(self) -> WriterConfig:
|
|
"""Get the Writer model configuration.
|
|
|
|
Returns:
|
|
The Writer model configuration.
|
|
"""
|
|
return self.config
|
|
|
|
def _format_request_message_contents_vision(self, contents: list[ContentBlock]) -> list[dict[str, Any]]:
|
|
def _format_content_vision(content: ContentBlock) -> dict[str, Any]:
|
|
"""Format a Writer content block for Palmyra V5 request.
|
|
|
|
- NOTE: "reasoningContent", "document" and "video" are not supported currently.
|
|
|
|
Args:
|
|
content: Message content.
|
|
|
|
Returns:
|
|
Writer formatted content block for models, which support vision content format.
|
|
|
|
Raises:
|
|
TypeError: If the content block type cannot be converted to a Writer-compatible format.
|
|
"""
|
|
if "text" in content:
|
|
return {"text": content["text"], "type": "text"}
|
|
|
|
if "image" in content:
|
|
mime_type = mimetypes.types_map.get(f".{content['image']['format']}", "application/octet-stream")
|
|
image_data = base64.b64encode(content["image"]["source"]["bytes"]).decode("utf-8")
|
|
|
|
return {
|
|
"image_url": {
|
|
"url": f"data:{mime_type};base64,{image_data}",
|
|
},
|
|
"type": "image_url",
|
|
}
|
|
|
|
raise TypeError(f"content_type=<{next(iter(content))}> | unsupported type")
|
|
|
|
return [
|
|
_format_content_vision(content)
|
|
for content in contents
|
|
if not any(block_type in content for block_type in ["toolResult", "toolUse"])
|
|
]
|
|
|
|
def _format_request_message_contents(self, contents: list[ContentBlock]) -> str:
|
|
def _format_content(content: ContentBlock) -> str:
|
|
"""Format a Writer content block for Palmyra models (except V5) request.
|
|
|
|
- NOTE: "reasoningContent", "document", "video" and "image" are not supported currently.
|
|
|
|
Args:
|
|
content: Message content.
|
|
|
|
Returns:
|
|
Writer formatted content block.
|
|
|
|
Raises:
|
|
TypeError: If the content block type cannot be converted to a Writer-compatible format.
|
|
"""
|
|
if "text" in content:
|
|
return content["text"]
|
|
|
|
raise TypeError(f"content_type=<{next(iter(content))}> | unsupported type")
|
|
|
|
content_blocks = list(
|
|
filter(
|
|
lambda content: (
|
|
content.get("text") and not any(block_type in content for block_type in ["toolResult", "toolUse"])
|
|
),
|
|
contents,
|
|
)
|
|
)
|
|
|
|
if len(content_blocks) > 1:
|
|
raise ValueError(
|
|
f"Model with name {self.get_config().get('model_id', 'N/A')} doesn't support multiple contents"
|
|
)
|
|
elif len(content_blocks) == 1:
|
|
return _format_content(content_blocks[0])
|
|
else:
|
|
return ""
|
|
|
|
def _format_request_message_tool_call(self, tool_use: ToolUse) -> dict[str, Any]:
|
|
"""Format a Writer tool call.
|
|
|
|
Args:
|
|
tool_use: Tool use requested by the model.
|
|
|
|
Returns:
|
|
Writer formatted tool call.
|
|
"""
|
|
return {
|
|
"function": {
|
|
"arguments": json.dumps(tool_use["input"], ensure_ascii=False),
|
|
"name": tool_use["name"],
|
|
},
|
|
"id": tool_use["toolUseId"],
|
|
"type": "function",
|
|
}
|
|
|
|
def _format_request_tool_message(self, tool_result: ToolResult) -> dict[str, Any]:
|
|
"""Format a Writer tool message.
|
|
|
|
Args:
|
|
tool_result: Tool result collected from a tool execution.
|
|
|
|
Returns:
|
|
Writer formatted tool message.
|
|
"""
|
|
contents = cast(
|
|
list[ContentBlock],
|
|
[
|
|
{"text": json.dumps(content["json"], ensure_ascii=False)} if "json" in content else content
|
|
for content in tool_result["content"]
|
|
],
|
|
)
|
|
|
|
if self.get_config().get("model_id", "") == "palmyra-x5":
|
|
formatted_contents = self._format_request_message_contents_vision(contents)
|
|
else:
|
|
formatted_contents = self._format_request_message_contents(contents) # type: ignore [assignment]
|
|
|
|
return {
|
|
"role": "tool",
|
|
"tool_call_id": tool_result["toolUseId"],
|
|
"content": formatted_contents,
|
|
}
|
|
|
|
def _format_request_messages(self, messages: Messages, system_prompt: str | None = None) -> list[dict[str, Any]]:
|
|
"""Format a Writer compatible messages array.
|
|
|
|
Args:
|
|
messages: List of message objects to be processed by the model.
|
|
system_prompt: System prompt to provide context to the model.
|
|
|
|
Returns:
|
|
Writer compatible messages array.
|
|
"""
|
|
formatted_messages: list[dict[str, Any]]
|
|
formatted_messages = [{"role": "system", "content": system_prompt}] if system_prompt else []
|
|
|
|
for message in messages:
|
|
contents = message["content"]
|
|
|
|
# Filter out location sources
|
|
filtered_contents = []
|
|
for content in contents:
|
|
if _has_location_source(content):
|
|
logger.warning("Location sources are not supported by Writer | skipping content block")
|
|
continue
|
|
filtered_contents.append(content)
|
|
|
|
# Only palmyra V5 support multiple content. Other models support only '{"content": "text_content"}'
|
|
if self.get_config().get("model_id", "") == "palmyra-x5":
|
|
formatted_contents: str | list[dict[str, Any]] = self._format_request_message_contents_vision(
|
|
filtered_contents
|
|
)
|
|
else:
|
|
formatted_contents = self._format_request_message_contents(filtered_contents)
|
|
|
|
formatted_tool_calls = [
|
|
self._format_request_message_tool_call(content["toolUse"])
|
|
for content in contents
|
|
if "toolUse" in content
|
|
]
|
|
formatted_tool_messages = [
|
|
self._format_request_tool_message(content["toolResult"])
|
|
for content in contents
|
|
if "toolResult" in content
|
|
]
|
|
|
|
formatted_message = {
|
|
"role": message["role"],
|
|
"content": formatted_contents if len(formatted_contents) > 0 else "",
|
|
**({"tool_calls": formatted_tool_calls} if formatted_tool_calls else {}),
|
|
}
|
|
formatted_messages.append(formatted_message)
|
|
formatted_messages.extend(formatted_tool_messages)
|
|
|
|
return [message for message in formatted_messages if message["content"] or "tool_calls" in message]
|
|
|
|
def format_request(
|
|
self, messages: Messages, tool_specs: list[ToolSpec] | None = None, system_prompt: str | None = None
|
|
) -> Any:
|
|
"""Format a streaming request to the underlying model.
|
|
|
|
Args:
|
|
messages: List of message objects to be processed by the model.
|
|
tool_specs: List of tool specifications to make available to the model.
|
|
system_prompt: System prompt to provide context to the model.
|
|
|
|
Returns:
|
|
The formatted request.
|
|
"""
|
|
request = {
|
|
**{k: v for k, v in self.config.items() if k != "cache_config"},
|
|
"messages": self._format_request_messages(messages, system_prompt),
|
|
"stream": True,
|
|
}
|
|
try:
|
|
request["model"] = request.pop(
|
|
"model_id"
|
|
) # To be consisted with other models WriterConfig use 'model_id' arg, but Writer API wait for 'model' arg
|
|
except KeyError as e:
|
|
raise KeyError("Please specify a model ID. Use 'model_id' keyword argument.") from e
|
|
|
|
# Writer don't support empty tools attribute
|
|
if tool_specs:
|
|
request["tools"] = [
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": tool_spec["name"],
|
|
"description": tool_spec["description"],
|
|
"parameters": tool_spec["inputSchema"]["json"],
|
|
},
|
|
}
|
|
for tool_spec in tool_specs
|
|
]
|
|
|
|
# Writer applies no CacheConfig fields; warn that any set field is ignored.
|
|
warn_on_cache_config_not_supported(self.config.get("cache_config"), "Writer", supported=set(), stacklevel=3)
|
|
return request
|
|
|
|
def format_chunk(self, event: Any) -> StreamEvent:
|
|
"""Format the model response events into standardized message chunks.
|
|
|
|
Args:
|
|
event: A response event from the model.
|
|
|
|
Returns:
|
|
The formatted chunk.
|
|
"""
|
|
match event.get("chunk_type", ""):
|
|
case "message_start":
|
|
return {"messageStart": {"role": "assistant"}}
|
|
|
|
case "content_block_start":
|
|
if event["data_type"] == "text":
|
|
return {"contentBlockStart": {"start": {}}}
|
|
|
|
return {
|
|
"contentBlockStart": {
|
|
"start": {
|
|
"toolUse": {
|
|
"name": event["data"].function.name,
|
|
"toolUseId": event["data"].id,
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
case "content_block_delta":
|
|
if event["data_type"] == "text":
|
|
return {"contentBlockDelta": {"delta": {"text": event["data"]}}}
|
|
|
|
return {"contentBlockDelta": {"delta": {"toolUse": {"input": event["data"].function.arguments}}}}
|
|
|
|
case "content_block_stop":
|
|
return {"contentBlockStop": {}}
|
|
|
|
case "message_stop":
|
|
match event["data"]:
|
|
case "tool_calls":
|
|
return {"messageStop": {"stopReason": "tool_use"}}
|
|
case "length":
|
|
return {"messageStop": {"stopReason": "max_tokens"}}
|
|
case _:
|
|
return {"messageStop": {"stopReason": "end_turn"}}
|
|
|
|
case "metadata":
|
|
return {
|
|
"metadata": {
|
|
"usage": {
|
|
"inputTokens": event["data"].prompt_tokens if event["data"] else 0,
|
|
"outputTokens": event["data"].completion_tokens if event["data"] else 0,
|
|
"totalTokens": event["data"].total_tokens if event["data"] else 0,
|
|
}, # If 'stream_options' param is unset, empty metadata will be provided.
|
|
# To avoid errors replacing expected fields with default zero value
|
|
"metrics": {
|
|
"latencyMs": 0, # All palmyra models don't provide 'latency' metadata
|
|
},
|
|
},
|
|
}
|
|
|
|
case _:
|
|
raise RuntimeError(f"chunk_type=<{event['chunk_type']} | unknown type")
|
|
|
|
@override
|
|
async def stream(
|
|
self,
|
|
messages: Messages,
|
|
tool_specs: list[ToolSpec] | None = None,
|
|
system_prompt: str | None = None,
|
|
*,
|
|
tool_choice: ToolChoice | None = None,
|
|
**kwargs: Any,
|
|
) -> AsyncGenerator[StreamEvent, None]:
|
|
"""Stream conversation with the Writer model.
|
|
|
|
Args:
|
|
messages: List of message objects to be processed by the model.
|
|
tool_specs: List of tool specifications to make available to the model.
|
|
system_prompt: System prompt to provide context to the model.
|
|
tool_choice: Selection strategy for tool invocation. **Note: This parameter is accepted for
|
|
interface consistency but is currently ignored for this model provider.**
|
|
**kwargs: Additional keyword arguments for future extensibility.
|
|
|
|
Yields:
|
|
Formatted message chunks from the model.
|
|
|
|
Raises:
|
|
ContextWindowOverflowException: If the input exceeds the model's context window.
|
|
ModelThrottledException: When the model service is throttling requests from the client.
|
|
"""
|
|
warn_on_tool_choice_not_supported(tool_choice)
|
|
|
|
logger.debug("formatting request")
|
|
request = self.format_request(messages, tool_specs, system_prompt)
|
|
logger.debug("request=<%s>", request)
|
|
|
|
logger.debug("invoking model")
|
|
try:
|
|
response = await self.client.chat.chat(**request)
|
|
except writerai.RateLimitError as e:
|
|
raise ModelThrottledException(str(e)) from e
|
|
except writerai.BadRequestError as e:
|
|
if any(message in str(e).lower() for message in self.OVERFLOW_MESSAGES):
|
|
raise ContextWindowOverflowException(str(e)) from e
|
|
raise
|
|
|
|
yield self.format_chunk({"chunk_type": "message_start"})
|
|
yield self.format_chunk({"chunk_type": "content_block_start", "data_type": "text"})
|
|
|
|
tool_calls: dict[int, list[Any]] = {}
|
|
|
|
async for chunk in response:
|
|
if not getattr(chunk, "choices", None):
|
|
continue
|
|
choice = chunk.choices[0]
|
|
|
|
if choice.delta.content:
|
|
yield self.format_chunk(
|
|
{"chunk_type": "content_block_delta", "data_type": "text", "data": choice.delta.content}
|
|
)
|
|
|
|
for tool_call in choice.delta.tool_calls or []:
|
|
tool_calls.setdefault(tool_call.index, []).append(tool_call)
|
|
|
|
if choice.finish_reason:
|
|
break
|
|
|
|
yield self.format_chunk({"chunk_type": "content_block_stop", "data_type": "text"})
|
|
|
|
for tool_deltas in tool_calls.values():
|
|
tool_start, tool_deltas = tool_deltas[0], tool_deltas[1:]
|
|
yield self.format_chunk({"chunk_type": "content_block_start", "data_type": "tool", "data": tool_start})
|
|
|
|
for tool_delta in tool_deltas:
|
|
yield self.format_chunk({"chunk_type": "content_block_delta", "data_type": "tool", "data": tool_delta})
|
|
|
|
yield self.format_chunk({"chunk_type": "content_block_stop", "data_type": "tool"})
|
|
|
|
yield self.format_chunk({"chunk_type": "message_stop", "data": choice.finish_reason})
|
|
|
|
# Iterating until the end to fetch metadata chunk
|
|
async for chunk in response:
|
|
_ = chunk
|
|
|
|
yield self.format_chunk({"chunk_type": "metadata", "data": chunk.usage})
|
|
|
|
logger.debug("finished streaming response from model")
|
|
|
|
@override
|
|
async def structured_output(
|
|
self, output_model: type[T], prompt: Messages, system_prompt: str | None = None, **kwargs: Any
|
|
) -> AsyncGenerator[dict[str, T | Any], None]:
|
|
"""Get structured output from the model.
|
|
|
|
Args:
|
|
output_model: The output model to use for the agent.
|
|
prompt: The prompt messages to use for the agent.
|
|
system_prompt: System prompt to provide context to the model.
|
|
**kwargs: Additional keyword arguments for future extensibility.
|
|
|
|
Raises:
|
|
ContextWindowOverflowException: If the input exceeds the model's context window.
|
|
"""
|
|
formatted_request = self.format_request(messages=prompt, tool_specs=None, system_prompt=system_prompt)
|
|
formatted_request["response_format"] = {
|
|
"type": "json_schema",
|
|
"json_schema": {"schema": output_model.model_json_schema()},
|
|
}
|
|
formatted_request["stream"] = False
|
|
formatted_request.pop("stream_options", None)
|
|
|
|
try:
|
|
response = await self.client.chat.chat(**formatted_request)
|
|
except writerai.BadRequestError as e:
|
|
if any(message in str(e).lower() for message in self.OVERFLOW_MESSAGES):
|
|
raise ContextWindowOverflowException(str(e)) from e
|
|
raise
|
|
|
|
try:
|
|
content = response.choices[0].message.content.strip()
|
|
yield {"output": output_model.model_validate_json(content)}
|
|
except Exception as e:
|
|
raise ValueError(f"Failed to parse or load content into model: {e}") from e
|