simbiot-harness/venv/lib/python3.12/site-packages/strands/models/sagemaker.py
Simbiot Harness b60439152b Simbiot Harness v0.1: тактовая лестница на Strands + CQRS/ES-спина
- 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 первым)
2026-09-29 15:28:50 +00:00

709 lines
30 KiB
Python

"""Amazon SageMaker model provider."""
import json
import logging
import os
from collections.abc import AsyncGenerator, Iterable, Iterator
from dataclasses import dataclass
from typing import Any, Literal, TypedDict, TypeVar
import boto3
from botocore.config import Config as BotocoreConfig
from mypy_boto3_sagemaker_runtime import SageMakerRuntimeClient
from pydantic import BaseModel
from typing_extensions import Unpack, override
from ..types.content import ContentBlock, Messages
from ..types.streaming import StreamEvent
from ..types.tools import ToolChoice, ToolResult, ToolSpec
from ._validation import validate_config_keys, warn_on_cache_config_not_supported, warn_on_tool_choice_not_supported
from .model import BaseModelConfig, CacheConfig
from .openai import OpenAIModel
T = TypeVar("T", bound=BaseModel)
logger = logging.getLogger(__name__)
@dataclass
class UsageMetadata:
"""Usage metadata for the model.
Attributes:
total_tokens: Total number of tokens used in the request
completion_tokens: Number of tokens used in the completion
prompt_tokens: Number of tokens used in the prompt
prompt_tokens_details: Additional information about the prompt tokens (optional)
"""
total_tokens: int
completion_tokens: int
prompt_tokens: int
prompt_tokens_details: int | None = 0
@dataclass
class FunctionCall:
"""Function call for the model.
Attributes:
name: Name of the function to call
arguments: Arguments to pass to the function
"""
name: str | dict[Any, Any]
arguments: str | dict[Any, Any]
def __init__(self, **kwargs: dict[str, str]):
"""Initialize function call.
Args:
**kwargs: Keyword arguments for the function call.
"""
self.name = kwargs.get("name", "")
self.arguments = kwargs.get("arguments", "")
@dataclass
class ToolCall:
"""Tool call for the model object.
Attributes:
id: Tool call ID
type: Tool call type
function: Tool call function
"""
id: str
type: Literal["function"]
function: FunctionCall
def __init__(self, **kwargs: dict):
"""Initialize tool call object.
Args:
**kwargs: Keyword arguments for the tool call.
"""
self.id = str(kwargs.get("id", ""))
self.type = "function"
self.function = FunctionCall(**kwargs.get("function", {"name": "", "arguments": ""}))
def _strip_sse_framing(line: str) -> str | None:
"""Return the JSON payload of one SSE line, or None if the line carries no event.
Strips an optional ``data:`` field prefix and skips blank lines, comment lines
(``:`` prefix), non-data SSE fields (``event:``, ``id:``, ``retry:``), and the
``[DONE]`` sentinel.
Args:
line: A single line from the event stream, without its terminating newline.
Returns:
The payload to decode as JSON, or None if the line is not a data event.
"""
line = line.strip()
if line.startswith("data:"):
line = line[len("data:") :].strip()
elif line.startswith(("event:", "id:", "retry:")):
return None
if not line or line.startswith(":") or line == "[DONE]":
return None
return line
def _parse_event_stream(body: Iterable[Any]) -> Iterator[dict[str, Any]]:
"""Yield decoded JSON events from a SageMaker response stream.
A ``PayloadPart`` is an arbitrary byte chunk that is not aligned to event
boundaries: a container such as vLLM or TGI may flush several ``data:`` SSE
events in one part, or split a single event across parts. Bytes are buffered;
each complete newline-delimited line is decoded as an independent event and any
trailing partial line is retained until the next part completes it. A malformed
``data:``-framed line is dropped with a warning, while a bare line that does not
decode on its own defers to the whole-buffer decode, so a payload that is not
newline-framed (multi-line bare JSON) is decoded once the buffer parses whole.
Args:
body: The ``response["Body"]`` event stream of ``PayloadPart`` chunks.
Yields:
One decoded JSON object per event.
"""
buffer = ""
for part in body:
buffer += part["PayloadPart"]["Bytes"].decode("utf-8")
logger.debug("buffer=<%s> | accumulated payload part", buffer)
while "\n" in buffer:
line, rest = buffer.split("\n", 1)
payload = _strip_sse_framing(line)
if payload is None:
buffer = rest
continue
try:
event = json.loads(payload)
except json.JSONDecodeError:
if not line.lstrip().startswith("data:"):
# Not SSE-framed: likely one line of a bare JSON document that only
# decodes as a whole. Stop line-splitting and let the whole-buffer
# decode below retry once more bytes arrive.
break
logger.warning("line=<%s> | skipping malformed event", line)
buffer = rest
continue
buffer = rest
yield event
payload = _strip_sse_framing(buffer)
if payload is None:
continue
try:
parsed = json.loads(payload)
except json.JSONDecodeError:
continue
buffer = ""
yield parsed
if _strip_sse_framing(buffer) is not None:
logger.warning("buffer=<%s> | stream ended with an undecoded partial event", buffer)
class SageMakerAIModel(OpenAIModel):
"""Amazon SageMaker model provider implementation."""
client: SageMakerRuntimeClient # type: ignore[assignment]
class SageMakerAIPayloadSchema(TypedDict, total=False):
"""Payload schema for the Amazon SageMaker AI model.
Attributes:
max_tokens: Maximum number of tokens to generate in the completion
stream: Whether to stream the response
temperature: Sampling temperature to use for the model (optional)
top_p: Nucleus sampling parameter (optional)
top_k: Top-k sampling parameter (optional)
stop: List of stop sequences to use for the model (optional)
tool_results_as_user_messages: Convert tool result to user messages (optional)
additional_args: Additional request parameters, as supported by https://bit.ly/djl-lmi-request-schema
"""
max_tokens: int
stream: bool
temperature: float | None
top_p: float | None
top_k: int | None
stop: list[str] | None
tool_results_as_user_messages: bool | None
additional_args: dict[str, Any] | None
class SageMakerAIEndpointConfig(BaseModelConfig, total=False):
"""Configuration options for SageMaker models.
Attributes:
endpoint_name: The name of the SageMaker endpoint to invoke
inference_component_name: The name of the inference component to use
cache_config: Prompt caching settings. SageMaker honors no cache fields, so any set field is ignored.
additional_args: Other request parameters, as supported by https://bit.ly/sagemaker-invoke-endpoint-params
"""
endpoint_name: str
region_name: str
inference_component_name: str | None
target_model: str | None | None
target_variant: str | None | None
cache_config: CacheConfig | None
additional_args: dict[str, Any] | None
def __init__(
self,
endpoint_config: SageMakerAIEndpointConfig,
payload_config: SageMakerAIPayloadSchema,
boto_session: boto3.Session | None = None,
boto_client_config: BotocoreConfig | None = None,
):
"""Initialize provider instance.
Args:
endpoint_config: Endpoint configuration for SageMaker.
payload_config: Payload configuration for the model.
boto_session: Boto Session to use when calling the SageMaker Runtime.
boto_client_config: Configuration to use when creating the SageMaker-Runtime Boto Client.
"""
validate_config_keys(endpoint_config, self.SageMakerAIEndpointConfig)
validate_config_keys(payload_config, self.SageMakerAIPayloadSchema)
payload_config.setdefault("stream", True)
payload_config.setdefault("tool_results_as_user_messages", False)
self.endpoint_config = self.SageMakerAIEndpointConfig(**endpoint_config)
self.payload_config = self.SageMakerAIPayloadSchema(**payload_config)
logger.debug(
"endpoint_config=<%s> payload_config=<%s> | initializing", self.endpoint_config, self.payload_config
)
region = self.endpoint_config.get("region_name") or os.getenv("AWS_REGION") or "us-west-2"
session = boto_session or boto3.Session(region_name=str(region))
# Add strands-agents to the request user agent
if boto_client_config:
existing_user_agent = getattr(boto_client_config, "user_agent_extra", None)
# Append 'strands-agents' to existing user_agent_extra or set it if not present
new_user_agent = f"{existing_user_agent} strands-agents" if existing_user_agent else "strands-agents"
client_config = boto_client_config.merge(BotocoreConfig(user_agent_extra=new_user_agent))
else:
client_config = BotocoreConfig(user_agent_extra="strands-agents")
self.client = session.client(
service_name="sagemaker-runtime",
config=client_config,
)
@override
def update_config(self, **endpoint_config: Unpack[SageMakerAIEndpointConfig]) -> None: # type: ignore[override]
"""Update the Amazon SageMaker model configuration with the provided arguments.
Args:
**endpoint_config: Configuration overrides.
"""
validate_config_keys(endpoint_config, self.SageMakerAIEndpointConfig)
self.endpoint_config.update(endpoint_config)
@override
def get_config(self) -> "SageMakerAIModel.SageMakerAIEndpointConfig": # type: ignore[override]
"""Get the Amazon SageMaker model configuration.
Returns:
The Amazon SageMaker model configuration.
"""
return self.endpoint_config
@override
def format_request(
self,
messages: Messages,
tool_specs: list[ToolSpec] | None = None,
system_prompt: str | None = None,
tool_choice: ToolChoice | None = None,
**kwargs: Any,
) -> dict[str, Any]:
"""Format an Amazon SageMaker chat streaming request.
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.
Returns:
An Amazon SageMaker chat streaming request.
"""
formatted_messages = self.format_request_messages(messages, system_prompt)
payload = {
"messages": formatted_messages,
"tools": [
{
"type": "function",
"function": {
"name": tool_spec["name"],
"description": tool_spec["description"],
"parameters": tool_spec["inputSchema"]["json"],
},
}
for tool_spec in tool_specs or []
],
# Add payload configuration parameters
**{
k: v
for k, v in self.payload_config.items()
if k not in ["additional_args", "tool_results_as_user_messages"]
},
}
payload_additional_args = self.payload_config.get("additional_args")
if payload_additional_args:
payload.update(payload_additional_args)
# Remove tools and tool_choice if tools = []
if not payload["tools"]:
payload.pop("tools")
payload.pop("tool_choice", None)
else:
# Ensure the model can use tools when available
payload["tool_choice"] = "auto"
for message in payload["messages"]: # type: ignore
# Assistant message must have either content or tool_calls, but not both
if message.get("role", "") == "assistant" and message.get("tool_calls", []) != []:
message.pop("content", None)
if message.get("role") == "tool" and self.payload_config.get("tool_results_as_user_messages", False):
# Convert tool message to user message
tool_call_id = message.get("tool_call_id", "ABCDEF")
content = message.get("content", "")
message = {"role": "user", "content": f"Tool call ID '{tool_call_id}' returned: {content}"}
# Cannot have both reasoning_text and text - if "text", content becomes an array of content["text"]
for c in message.get("content", []):
if "text" in c:
message["content"] = [c]
break
# Cast message content to string for TGI compatibility
# message["content"] = str(message.get("content", ""))
logger.info("payload=<%s>", json.dumps(payload, indent=2))
# Format the request according to the SageMaker Runtime API requirements
request = {
"EndpointName": self.endpoint_config["endpoint_name"],
"Body": json.dumps(payload),
"ContentType": "application/json",
"Accept": "application/json",
}
# Add optional SageMaker parameters if provided
inf_component_name = self.endpoint_config.get("inference_component_name")
if inf_component_name:
request["InferenceComponentName"] = inf_component_name
target_model = self.endpoint_config.get("target_model")
if target_model:
request["TargetModel"] = target_model
target_variant = self.endpoint_config.get("target_variant")
if target_variant:
request["TargetVariant"] = target_variant
# Add additional request args if provided
additional_args = self.endpoint_config.get("additional_args")
if additional_args:
request.update(additional_args)
# SageMaker applies no CacheConfig fields; warn that any set field is ignored.
warn_on_cache_config_not_supported(
self.endpoint_config.get("cache_config"), "SageMaker", supported=set(), stacklevel=3
)
return request
@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 SageMaker 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.
"""
warn_on_tool_choice_not_supported(tool_choice)
logger.debug("formatting request")
request = self.format_request(messages, tool_specs, system_prompt)
logger.debug("formatted request=<%s>", request)
logger.debug("invoking model")
try:
if self.payload_config.get("stream", True):
response = self.client.invoke_endpoint_with_response_stream(**request)
# Message start
yield self.format_chunk({"chunk_type": "message_start"})
# Parse the content
finish_reason = ""
tool_calls: dict[int, list[Any]] = {}
has_text_content = False
text_content_started = False
reasoning_content_started = False
for content in _parse_event_stream(response["Body"]):
choices = content.get("choices")
if not choices:
if usage := content.get("usage"):
yield self.format_chunk({"chunk_type": "metadata", "data": UsageMetadata(**usage)})
continue
if finish_reason:
continue
choice = choices[0]
logger.debug("choice=<%s>", json.dumps(choice, indent=2))
# Handle text content
if choice["delta"].get("content"):
if not text_content_started:
yield self.format_chunk({"chunk_type": "content_start", "data_type": "text"})
text_content_started = True
has_text_content = True
yield self.format_chunk(
{
"chunk_type": "content_delta",
"data_type": "text",
"data": choice["delta"]["content"],
}
)
# Handle reasoning content
# vLLM v0.16.0+ uses "reasoning" instead of "reasoning_content"
reasoning_text = choice["delta"].get("reasoning_content") or choice["delta"].get("reasoning")
if reasoning_text:
if not reasoning_content_started:
yield self.format_chunk({"chunk_type": "content_start", "data_type": "reasoning_content"})
reasoning_content_started = True
yield self.format_chunk(
{
"chunk_type": "content_delta",
"data_type": "reasoning_content",
"data": reasoning_text,
}
)
# Handle tool calls
generated_tool_calls = choice["delta"].get("tool_calls", [])
if not isinstance(generated_tool_calls, list):
generated_tool_calls = [generated_tool_calls]
for tool_call in generated_tool_calls:
tool_calls.setdefault(tool_call["index"], []).append(tool_call)
usage = content.get("usage") or choice.get("usage")
if usage:
yield self.format_chunk({"chunk_type": "metadata", "data": UsageMetadata(**usage)})
if choice["finish_reason"] is not None:
finish_reason = choice["finish_reason"]
# Close reasoning content if it was started
if reasoning_content_started:
yield self.format_chunk({"chunk_type": "content_stop", "data_type": "reasoning_content"})
# Close text content if it was started
if text_content_started:
yield self.format_chunk({"chunk_type": "content_stop", "data_type": "text"})
# Handle tool calling
logger.info("tool_calls=<%s>", json.dumps(tool_calls, indent=2))
for tool_deltas in tool_calls.values():
if not tool_deltas[0]["function"].get("name"):
raise Exception("The model did not provide a tool name.")
yield self.format_chunk(
{"chunk_type": "content_start", "data_type": "tool", "data": ToolCall(**tool_deltas[0])}
)
for tool_delta in tool_deltas:
yield self.format_chunk(
{"chunk_type": "content_delta", "data_type": "tool", "data": ToolCall(**tool_delta)}
)
yield self.format_chunk({"chunk_type": "content_stop", "data_type": "tool"})
# If no content was generated at all, ensure we have empty text content
if not has_text_content and not tool_calls:
yield self.format_chunk({"chunk_type": "content_start", "data_type": "text"})
yield self.format_chunk({"chunk_type": "content_stop", "data_type": "text"})
# Message close
yield self.format_chunk({"chunk_type": "message_stop", "data": finish_reason})
else:
# Not all SageMaker AI models support streaming!
response = self.client.invoke_endpoint(**request) # type: ignore[assignment]
final_response_json = json.loads(response["Body"].read().decode("utf-8")) # type: ignore[attr-defined]
logger.info("response=<%s>", json.dumps(final_response_json, indent=2))
# Obtain the key elements from the response
message = final_response_json["choices"][0]["message"]
message_stop_reason = final_response_json["choices"][0]["finish_reason"]
# Message start
yield self.format_chunk({"chunk_type": "message_start"})
# Handle text
if message.get("content", ""):
yield self.format_chunk({"chunk_type": "content_start", "data_type": "text"})
yield self.format_chunk(
{"chunk_type": "content_delta", "data_type": "text", "data": message["content"]}
)
yield self.format_chunk({"chunk_type": "content_stop", "data_type": "text"})
# Handle reasoning content
# vLLM v0.16.0+ uses "reasoning" instead of "reasoning_content"
reasoning_text = message.get("reasoning_content") or message.get("reasoning")
if reasoning_text:
yield self.format_chunk({"chunk_type": "content_start", "data_type": "reasoning_content"})
yield self.format_chunk(
{
"chunk_type": "content_delta",
"data_type": "reasoning_content",
"data": reasoning_text,
}
)
yield self.format_chunk({"chunk_type": "content_stop", "data_type": "reasoning_content"})
# Handle the tool calling, if any
if message.get("tool_calls") or message_stop_reason == "tool_calls":
if not isinstance(message["tool_calls"], list):
message["tool_calls"] = [message["tool_calls"]]
for tool_call in message["tool_calls"]:
# if arguments of tool_call is not str, cast it
if not isinstance(tool_call["function"]["arguments"], str):
tool_call["function"]["arguments"] = json.dumps(tool_call["function"]["arguments"])
yield self.format_chunk(
{"chunk_type": "content_start", "data_type": "tool", "data": ToolCall(**tool_call)}
)
yield self.format_chunk(
{"chunk_type": "content_delta", "data_type": "tool", "data": ToolCall(**tool_call)}
)
yield self.format_chunk({"chunk_type": "content_stop", "data_type": "tool"})
message_stop_reason = "tool_calls"
# Message close
yield self.format_chunk({"chunk_type": "message_stop", "data": message_stop_reason})
# Handle usage metadata
if final_response_json.get("usage"):
yield self.format_chunk(
{"chunk_type": "metadata", "data": UsageMetadata(**final_response_json.get("usage"))}
)
except (
self.client.exceptions.InternalFailure,
self.client.exceptions.ServiceUnavailable,
self.client.exceptions.ValidationError,
self.client.exceptions.ModelError,
self.client.exceptions.InternalDependencyException,
self.client.exceptions.ModelNotReadyException,
) as e:
logger.error("SageMaker error: %s", str(e))
raise e
logger.debug("finished streaming response from model")
@override
@classmethod
def format_request_tool_message(cls, tool_result: ToolResult, **kwargs: Any) -> dict[str, Any]:
"""Format a SageMaker compatible tool message.
Args:
tool_result: Tool result collected from a tool execution.
**kwargs: Additional keyword arguments for future extensibility.
Returns:
SageMaker compatible tool message with content as a string.
"""
# Convert content blocks to a simple string for SageMaker compatibility
content_parts = []
for content in tool_result["content"]:
if "json" in content:
content_parts.append(json.dumps(content["json"], ensure_ascii=False))
elif "text" in content:
content_parts.append(content["text"])
else:
# Handle other content types by converting to string
content_parts.append(str(content))
content_string = " ".join(content_parts)
return {
"role": "tool",
"tool_call_id": tool_result["toolUseId"],
"content": content_string, # String instead of list
}
@override
@classmethod
def format_request_message_content(cls, content: ContentBlock, **kwargs: Any) -> dict[str, Any]:
"""Format a content block.
Args:
content: Message content.
**kwargs: Additional keyword arguments for future extensibility.
Returns:
Formatted content block.
Raises:
TypeError: If the content block type cannot be converted to a SageMaker-compatible format.
"""
# if "text" in content and not isinstance(content["text"], str):
# return {"type": "text", "text": str(content["text"])}
if "reasoningContent" in content and content["reasoningContent"]:
return {
"signature": content["reasoningContent"].get("reasoningText", {}).get("signature", ""),
"thinking": content["reasoningContent"].get("reasoningText", {}).get("text", ""),
"type": "thinking",
}
elif not content.get("reasoningContent"):
content.pop("reasoningContent", None)
if "video" in content:
return {
"type": "video_url",
"video_url": {
"detail": "auto",
"url": content["video"]["source"]["bytes"],
},
}
return super().format_request_message_content(content)
@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.
Yields:
Model events with the last being the structured output.
"""
# Format the request for structured output
request = self.format_request(prompt, system_prompt=system_prompt)
# Parse the payload to add response format
payload = json.loads(request["Body"])
payload["response_format"] = {
"type": "json_schema",
"json_schema": {"name": output_model.__name__, "schema": output_model.model_json_schema(), "strict": True},
}
request["Body"] = json.dumps(payload)
try:
# Use non-streaming mode for structured output
response = self.client.invoke_endpoint(**request)
final_response_json = json.loads(response["Body"].read().decode("utf-8"))
# Extract the structured content
message = final_response_json["choices"][0]["message"]
if message.get("content"):
try:
# Parse the JSON content and create the output model instance
content_data = json.loads(message["content"])
parsed_output = output_model(**content_data)
yield {"output": parsed_output}
except (json.JSONDecodeError, TypeError, ValueError) as e:
raise ValueError(f"Failed to parse structured output: {e}") from e
else:
raise ValueError("No content found in SageMaker response")
except (
self.client.exceptions.InternalFailure,
self.client.exceptions.ServiceUnavailable,
self.client.exceptions.ValidationError,
self.client.exceptions.ModelError,
self.client.exceptions.InternalDependencyException,
self.client.exceptions.ModelNotReadyException,
) as e:
logger.error("SageMaker structured output error: %s", str(e))
raise ValueError(f"SageMaker structured output error: {str(e)}") from e