- 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 первым)
936 lines
42 KiB
Python
936 lines
42 KiB
Python
"""OpenAI model provider using the Responses API.
|
|
|
|
Built-in tools (e.g. web_search, file_search, code_interpreter) can be passed via the
|
|
``params`` configuration and will be merged with any agent function tools in the request.
|
|
|
|
All built-in tools produce text responses that stream correctly. Limitations on tool-specific
|
|
metadata:
|
|
|
|
- web_search (supported): Full support including URL citations.
|
|
- file_search (partial): File citation annotations not emitted (no matching CitationLocation variant).
|
|
- code_interpreter (partial): Executed code and stdout/stderr not surfaced.
|
|
- mcp (partial): Approval flow and ``mcp_list_tools``/``mcp_call`` events not surfaced.
|
|
- shell (partial): Local (client-executed) mode not supported.
|
|
- tool_search (not supported): Requires ``defer_loading`` on function tools, which is not supported.
|
|
- image_generation (not supported): Requires image content block delta support in the event loop.
|
|
- computer_use_preview (not supported): Requires a developer-managed screenshot/action loop.
|
|
|
|
Docs: https://platform.openai.com/docs/api-reference/responses
|
|
"""
|
|
|
|
import base64
|
|
import json
|
|
import logging
|
|
import mimetypes
|
|
from collections.abc import AsyncGenerator
|
|
from importlib.metadata import version as get_package_version
|
|
from types import SimpleNamespace
|
|
from typing import Any, Protocol, TypedDict, TypeVar, cast
|
|
|
|
from packaging.version import Version
|
|
from pydantic import BaseModel
|
|
from typing_extensions import Unpack, override
|
|
|
|
# Validate OpenAI SDK version at import time - Responses API requires v2.0.0+
|
|
# A major version bump is proposed in https://github.com/strands-agents/harness-sdk/pull/1370
|
|
_MIN_OPENAI_VERSION = Version("2.0.0")
|
|
|
|
try:
|
|
_openai_version = Version(get_package_version("openai"))
|
|
if _openai_version < _MIN_OPENAI_VERSION:
|
|
raise ImportError(
|
|
f"OpenAIResponsesModel requires openai>={_MIN_OPENAI_VERSION} (found {_openai_version}). "
|
|
"Install/upgrade with: pip install -U openai. "
|
|
"For older SDKs, use OpenAIModel (Chat Completions)."
|
|
)
|
|
except ImportError:
|
|
# Re-raise ImportError as-is (covers both our explicit raise above and missing openai package)
|
|
raise
|
|
except Exception as e:
|
|
raise ImportError(
|
|
f"OpenAIResponsesModel requires openai>={_MIN_OPENAI_VERSION}. Install with: pip install -U openai"
|
|
) from e
|
|
|
|
import openai # noqa: E402 - must import after version check
|
|
|
|
from ..agent.agent_metadata import AgentMetadata # noqa: E402
|
|
from ..types.citations import WebLocationDict # noqa: E402
|
|
from ..types.content import ContentBlock, Messages, Role, SystemContentBlock # noqa: E402
|
|
from ..types.event_loop import Usage # noqa: E402
|
|
from ..types.exceptions import ContextWindowOverflowException, ModelThrottledException # noqa: E402
|
|
from ..types.streaming import StreamEvent # noqa: E402
|
|
from ..types.tools import ToolChoice, ToolResult, ToolSpec, ToolUse # noqa: E402
|
|
from ._defaults import resolve_config_metadata # noqa: E402
|
|
from ._openai_bedrock import BedrockMantleConfig, resolve_bedrock_client_args # noqa: E402
|
|
from ._openai_cache import apply_cache_config # noqa: E402
|
|
from ._openai_errors import classify_openai_error # noqa: E402
|
|
from ._validation import validate_config_keys # noqa: E402
|
|
from .model import BaseModelConfig, CacheConfig, Model # noqa: E402
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
T = TypeVar("T", bound=BaseModel)
|
|
|
|
# Maximum file size for media content in tool results (20MB)
|
|
_MAX_MEDIA_SIZE_BYTES = 20 * 1024 * 1024
|
|
_MAX_MEDIA_SIZE_LABEL = "20MB"
|
|
_DEFAULT_MIME_TYPE = "application/octet-stream"
|
|
_CONTEXT_WINDOW_OVERFLOW_MSG = "OpenAI Responses API threw context window overflow error"
|
|
_RATE_LIMIT_MSG = "OpenAI Responses API threw rate limit error"
|
|
|
|
|
|
class _OpenAIResponsesStreamError(RuntimeError):
|
|
"""Error reported by a terminal OpenAI Responses API stream event."""
|
|
|
|
def __init__(self, message: str | None, code: str | None) -> None:
|
|
super().__init__(message or "OpenAI Responses API response failed")
|
|
self.code = code
|
|
|
|
|
|
def _encode_media_to_data_url(data: bytes, format_ext: str, media_type: str = "image") -> str:
|
|
"""Encode media bytes to a base64 data URL with size validation.
|
|
|
|
Args:
|
|
data: Raw bytes of the media content.
|
|
format_ext: File format extension (e.g., "png", "pdf").
|
|
media_type: Type of media for error messages ("image" or "document").
|
|
|
|
Returns:
|
|
Base64-encoded data URL string.
|
|
|
|
Raises:
|
|
ValueError: If the media size exceeds the maximum allowed size.
|
|
"""
|
|
if len(data) > _MAX_MEDIA_SIZE_BYTES:
|
|
raise ValueError(
|
|
f"{media_type.capitalize()} size {len(data)} bytes exceeds maximum of"
|
|
f" {_MAX_MEDIA_SIZE_BYTES} bytes ({_MAX_MEDIA_SIZE_LABEL})"
|
|
)
|
|
mime_type = mimetypes.types_map.get(f".{format_ext}", _DEFAULT_MIME_TYPE)
|
|
encoded_data = base64.b64encode(data).decode("utf-8")
|
|
return f"data:{mime_type};base64,{encoded_data}"
|
|
|
|
|
|
class _ToolCallInfo(TypedDict):
|
|
"""Internal type for tracking tool call information during streaming."""
|
|
|
|
name: str
|
|
arguments: str
|
|
call_id: str
|
|
item_id: str
|
|
|
|
|
|
class Client(Protocol):
|
|
"""Protocol defining the OpenAI Responses API interface for the underlying provider client."""
|
|
|
|
@property
|
|
# pragma: no cover
|
|
def responses(self) -> Any:
|
|
"""Responses interface."""
|
|
...
|
|
|
|
|
|
class OpenAIResponsesModel(Model):
|
|
"""OpenAI Responses API model provider implementation."""
|
|
|
|
client: Client
|
|
client_args: dict[str, Any]
|
|
|
|
class OpenAIResponsesConfig(BaseModelConfig, total=False):
|
|
"""Configuration options for OpenAI Responses API models.
|
|
|
|
Attributes:
|
|
model_id: Model ID (e.g., "gpt-4o").
|
|
For a complete list of supported models, see https://platform.openai.com/docs/models.
|
|
params: Model parameters (e.g., max_output_tokens, temperature, etc.).
|
|
For a complete list of supported parameters, see
|
|
https://platform.openai.com/docs/api-reference/responses/create.
|
|
stateful: Whether to enable server-side conversation state management.
|
|
When True, the server stores conversation history and the client does not need to
|
|
send the full message history with each request. Defaults to False.
|
|
use_native_token_count: Whether to use the native OpenAI input_tokens.count API.
|
|
When True, count_tokens() calls the OpenAI API for accurate counts.
|
|
When False (default), skips the API call and uses the local estimator.
|
|
cache_config: Prompt-caching configuration. OpenAI routes cache reads on
|
|
``cache_key`` (mapped to ``prompt_cache_key``) and honors ``ttl`` only when it names
|
|
an OpenAI retention value; other fields have no effect. An explicit
|
|
``prompt_cache_key``/``prompt_cache_retention`` in ``params`` takes precedence.
|
|
"""
|
|
|
|
model_id: str
|
|
params: dict[str, Any] | None
|
|
stateful: bool
|
|
use_native_token_count: bool
|
|
cache_config: CacheConfig | None
|
|
|
|
def __init__(
|
|
self,
|
|
client_args: dict[str, Any] | None = None,
|
|
bedrock_mantle_config: BedrockMantleConfig | None = None,
|
|
**model_config: Unpack[OpenAIResponsesConfig],
|
|
) -> None:
|
|
"""Initialize provider instance.
|
|
|
|
Args:
|
|
client_args: Arguments for the OpenAI client.
|
|
For a complete list of supported arguments, see https://pypi.org/project/openai/.
|
|
May be combined with ``bedrock_mantle_config``; when both are set, the config
|
|
derives ``base_url`` and ``api_key`` (which must not appear in ``client_args``).
|
|
bedrock_mantle_config: Route requests through one of Amazon Bedrock's
|
|
OpenAI-compatible endpoints, ``bedrock-mantle`` (the default) or
|
|
``bedrock-runtime`` via the config's ``endpoint`` key. See
|
|
:class:`BedrockMantleConfig` for accepted keys. When set, a fresh bearer
|
|
token is minted on every request.
|
|
**model_config: Configuration options for the OpenAI Responses API model.
|
|
"""
|
|
validate_config_keys(model_config, self.OpenAIResponsesConfig)
|
|
self.config = dict(model_config)
|
|
|
|
self.client_args = client_args or {}
|
|
self._bedrock_mantle_config = bedrock_mantle_config
|
|
|
|
if bedrock_mantle_config is not None and client_args:
|
|
conflicting = [k for k in ("api_key", "base_url") if k in client_args]
|
|
if conflicting:
|
|
raise ValueError(
|
|
f"client_args must not contain {conflicting} when bedrock_mantle_config is set; "
|
|
"these are derived from the Mantle config automatically."
|
|
)
|
|
|
|
logger.debug("config=<%s> | initializing", self.config)
|
|
|
|
def _resolve_client_args(self) -> dict[str, Any]:
|
|
"""Return the kwargs to pass to ``openai.AsyncOpenAI`` for the current request.
|
|
|
|
Delegates to :func:`resolve_bedrock_client_args` when ``bedrock_mantle_config`` is set.
|
|
"""
|
|
if self._bedrock_mantle_config is not None:
|
|
return resolve_bedrock_client_args(
|
|
self._bedrock_mantle_config, self.client_args, model_id=str(self.config.get("model_id", ""))
|
|
)
|
|
return self.client_args
|
|
|
|
@property
|
|
@override
|
|
def stateful(self) -> bool:
|
|
"""Whether server-side conversation storage is enabled.
|
|
|
|
Derived from the ``stateful`` configuration option.
|
|
"""
|
|
return bool(self.config.get("stateful"))
|
|
|
|
@override
|
|
def update_config(self, **model_config: Unpack[OpenAIResponsesConfig]) -> None: # type: ignore[override]
|
|
"""Update the OpenAI Responses API model configuration with the provided arguments.
|
|
|
|
Args:
|
|
**model_config: Configuration overrides.
|
|
"""
|
|
validate_config_keys(model_config, self.OpenAIResponsesConfig)
|
|
self.config.update(model_config)
|
|
|
|
@override
|
|
def get_config(self) -> OpenAIResponsesConfig:
|
|
"""Get the OpenAI Responses API model configuration.
|
|
|
|
Returns:
|
|
The OpenAI Responses API model configuration.
|
|
"""
|
|
return cast(
|
|
OpenAIResponsesModel.OpenAIResponsesConfig,
|
|
resolve_config_metadata(self.config, str(self.config.get("model_id", ""))),
|
|
)
|
|
|
|
@override
|
|
async def count_tokens(
|
|
self,
|
|
messages: Messages,
|
|
tool_specs: list[ToolSpec] | None = None,
|
|
system_prompt: str | None = None,
|
|
system_prompt_content: list[SystemContentBlock] | None = None,
|
|
) -> int:
|
|
"""Count tokens using the OpenAI Responses API input_tokens.count endpoint.
|
|
|
|
Uses the same message format as the Responses API to get accurate token counts
|
|
directly from the OpenAI service.
|
|
|
|
Args:
|
|
messages: List of message objects to count tokens for.
|
|
tool_specs: List of tool specifications to include in the count.
|
|
system_prompt: Plain string system prompt. Ignored if system_prompt_content is provided.
|
|
system_prompt_content: Structured system prompt content blocks.
|
|
|
|
Returns:
|
|
Total input token count.
|
|
"""
|
|
if self.config.get("use_native_token_count") is not True:
|
|
return await super().count_tokens(messages, tool_specs, system_prompt, system_prompt_content)
|
|
|
|
try:
|
|
# system_prompt_content is not used; this provider only accepts system_prompt as a plain string,
|
|
# matching the behavior of stream(). The caller always provides system_prompt alongside
|
|
# system_prompt_content, so the plain string is always available.
|
|
request = self._format_request(messages, tool_specs, system_prompt)
|
|
# Keep only fields accepted by input_tokens.count
|
|
count_tokens_fields = {"model", "input", "instructions", "tools"}
|
|
request = {k: request[k] for k in request.keys() & count_tokens_fields}
|
|
|
|
async with openai.AsyncOpenAI(**self._resolve_client_args()) as client:
|
|
response = await client.responses.input_tokens.count(**request)
|
|
total_tokens: int = response.input_tokens
|
|
|
|
logger.debug(
|
|
"model_id=<%s>, total_tokens=<%d> | native token count",
|
|
self.config["model_id"],
|
|
total_tokens,
|
|
)
|
|
return total_tokens
|
|
except Exception as e:
|
|
logger.debug(
|
|
"model_id=<%s>, error=<%s> | native token counting failed, falling back to estimation",
|
|
self.config["model_id"],
|
|
e,
|
|
)
|
|
return await super().count_tokens(messages, tool_specs, system_prompt, system_prompt_content)
|
|
|
|
@override
|
|
async def stream(
|
|
self,
|
|
messages: Messages,
|
|
tool_specs: list[ToolSpec] | None = None,
|
|
system_prompt: str | None = None,
|
|
*,
|
|
tool_choice: ToolChoice | None = None,
|
|
model_state: dict[str, Any] | None = None,
|
|
agent_metadata: AgentMetadata | None = None,
|
|
**kwargs: Any,
|
|
) -> AsyncGenerator[StreamEvent, None]:
|
|
"""Stream conversation with the OpenAI Responses API 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.
|
|
model_state: Runtime state for model providers (e.g., server-side response ids).
|
|
agent_metadata: Invoking agent's metadata.
|
|
**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: If the request is throttled by OpenAI (rate limits).
|
|
"""
|
|
logger.debug("formatting request for OpenAI Responses API")
|
|
request = self._format_request(messages, tool_specs, system_prompt, tool_choice, model_state, agent_metadata)
|
|
logger.debug("formatted request=<%s>", request)
|
|
|
|
logger.debug("invoking OpenAI Responses API model")
|
|
|
|
async with openai.AsyncOpenAI(**self._resolve_client_args()) as client:
|
|
try:
|
|
response = await client.responses.create(**request)
|
|
|
|
logger.debug("streaming response from OpenAI Responses API model")
|
|
|
|
yield self._format_chunk({"chunk_type": "message_start"})
|
|
|
|
tool_calls: dict[str, _ToolCallInfo] = {}
|
|
final_usage = None
|
|
data_type: str | None = None
|
|
stop_reason: str | None = None
|
|
|
|
async for event in response:
|
|
if hasattr(event, "type"):
|
|
if event.type == "response.created":
|
|
# Capture response id for server-side conversation chaining
|
|
if hasattr(event, "response"):
|
|
response_id = getattr(event.response, "id", None)
|
|
if model_state is not None and response_id:
|
|
model_state["response_id"] = response_id
|
|
|
|
elif event.type in (
|
|
"response.reasoning_text.delta",
|
|
"response.reasoning_summary_text.delta",
|
|
):
|
|
# Reasoning content streaming:
|
|
# - reasoning_text: full chain-of-thought (gpt-oss models)
|
|
# - reasoning_summary_text: condensed summary (o-series models)
|
|
chunks, data_type = self._stream_switch_content("reasoning_content", data_type)
|
|
for chunk in chunks:
|
|
yield chunk
|
|
if hasattr(event, "delta") and isinstance(event.delta, str):
|
|
yield self._format_chunk(
|
|
{
|
|
"chunk_type": "content_delta",
|
|
"data_type": "reasoning_content",
|
|
"data": event.delta,
|
|
}
|
|
)
|
|
|
|
elif event.type == "response.output_text.delta":
|
|
# Text content streaming
|
|
chunks, data_type = self._stream_switch_content("text", data_type)
|
|
for chunk in chunks:
|
|
yield chunk
|
|
if hasattr(event, "delta") and isinstance(event.delta, str):
|
|
yield self._format_chunk(
|
|
{"chunk_type": "content_delta", "data_type": "text", "data": event.delta}
|
|
)
|
|
|
|
elif event.type == "response.output_text.annotation.added":
|
|
if hasattr(event, "annotation"):
|
|
if event.annotation.get("type") == "url_citation":
|
|
yield self._format_chunk(
|
|
{
|
|
"chunk_type": "content_delta",
|
|
"data_type": "citation",
|
|
"data": event.annotation,
|
|
}
|
|
)
|
|
else:
|
|
logger.warning(
|
|
"annotation_type=<%s> | unsupported annotation type",
|
|
event.annotation.get("type"),
|
|
)
|
|
|
|
elif event.type == "response.output_item.added":
|
|
# Tool call started
|
|
if (
|
|
hasattr(event, "item")
|
|
and hasattr(event.item, "type")
|
|
and event.item.type == "function_call"
|
|
):
|
|
call_id = getattr(event.item, "call_id", "unknown")
|
|
tool_calls[call_id] = {
|
|
"name": getattr(event.item, "name", ""),
|
|
"arguments": "",
|
|
"call_id": call_id,
|
|
"item_id": getattr(event.item, "id", ""),
|
|
}
|
|
|
|
elif event.type == "response.function_call_arguments.delta":
|
|
# Tool arguments streaming - accumulate deltas by item_id
|
|
if hasattr(event, "delta") and hasattr(event, "item_id"):
|
|
for _call_id, call_info in tool_calls.items():
|
|
if call_info["item_id"] == event.item_id:
|
|
call_info["arguments"] += event.delta
|
|
break
|
|
|
|
elif event.type == "response.function_call_arguments.done":
|
|
# Tool arguments complete - use final arguments as source of truth
|
|
if hasattr(event, "arguments") and hasattr(event, "item_id"):
|
|
for _call_id, call_info in tool_calls.items():
|
|
if call_info["item_id"] == event.item_id:
|
|
call_info["arguments"] = event.arguments
|
|
break
|
|
|
|
elif event.type == "response.failed":
|
|
error = getattr(event.response, "error", None)
|
|
raise _OpenAIResponsesStreamError(
|
|
getattr(error, "message", None), getattr(error, "code", None)
|
|
)
|
|
|
|
elif event.type == "error":
|
|
raise _OpenAIResponsesStreamError(
|
|
getattr(event, "message", None), getattr(event, "code", None)
|
|
)
|
|
|
|
elif event.type == "response.incomplete":
|
|
# Response stopped early (e.g., max tokens reached)
|
|
if hasattr(event, "response"):
|
|
if hasattr(event.response, "usage"):
|
|
final_usage = event.response.usage
|
|
# Check if stopped due to max_output_tokens
|
|
if (
|
|
hasattr(event.response, "incomplete_details")
|
|
and event.response.incomplete_details
|
|
and getattr(event.response.incomplete_details, "reason", None)
|
|
== "max_output_tokens"
|
|
):
|
|
stop_reason = "length"
|
|
break
|
|
|
|
elif event.type == "response.completed":
|
|
# Response complete
|
|
if hasattr(event, "response") and hasattr(event.response, "usage"):
|
|
final_usage = event.response.usage
|
|
break
|
|
except (openai.APIError, _OpenAIResponsesStreamError) as error:
|
|
error_kind = classify_openai_error(error)
|
|
if error_kind == "throttling":
|
|
logger.warning(_RATE_LIMIT_MSG)
|
|
raise ModelThrottledException(str(error)) from error
|
|
if error_kind == "context_overflow":
|
|
logger.warning(_CONTEXT_WINDOW_OVERFLOW_MSG)
|
|
raise ContextWindowOverflowException(str(error)) from error
|
|
raise
|
|
|
|
# Close current content block if we had any
|
|
if data_type:
|
|
yield self._format_chunk({"chunk_type": "content_stop", "data_type": data_type})
|
|
|
|
# Emit tool calls with complete arguments.
|
|
# We emit a single delta per tool containing the full arguments rather than streaming
|
|
# incremental argument deltas. The Responses API streams argument chunks via separate
|
|
# events (response.function_call_arguments.delta) which we accumulate above, then use
|
|
# the final arguments from response.function_call_arguments.done. This approach ensures
|
|
# we emit valid, complete JSON arguments rather than partial fragments.
|
|
for call_info in tool_calls.values():
|
|
tool_call = SimpleNamespace(
|
|
function=SimpleNamespace(name=call_info["name"], arguments=call_info["arguments"]),
|
|
id=call_info["call_id"],
|
|
)
|
|
|
|
yield self._format_chunk({"chunk_type": "content_start", "data_type": "tool", "data": tool_call})
|
|
yield self._format_chunk({"chunk_type": "content_delta", "data_type": "tool", "data": tool_call})
|
|
yield self._format_chunk({"chunk_type": "content_stop", "data_type": "tool"})
|
|
|
|
# Determine finish reason: max_tokens (length) > tool_calls > normal stop.
|
|
# A function call that is still in flight when the response is cut off has truncated
|
|
# arguments, so it must surface as max_tokens rather than be executed.
|
|
if stop_reason == "length":
|
|
finish_reason = "length"
|
|
elif tool_calls:
|
|
finish_reason = "tool_calls"
|
|
else:
|
|
finish_reason = "stop"
|
|
yield self._format_chunk({"chunk_type": "message_stop", "data": finish_reason})
|
|
|
|
if final_usage:
|
|
yield self._format_chunk({"chunk_type": "metadata", "data": final_usage})
|
|
|
|
logger.debug("finished streaming response from OpenAI Responses API 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 OpenAI Responses API 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.
|
|
|
|
Raises:
|
|
ContextWindowOverflowException: If the input exceeds the model's context window.
|
|
ModelThrottledException: If the request is throttled by OpenAI (rate limits).
|
|
"""
|
|
async with openai.AsyncOpenAI(**self._resolve_client_args()) as client:
|
|
try:
|
|
request = self._format_request(prompt, system_prompt=system_prompt)
|
|
request.pop("stream", None)
|
|
response = await client.responses.parse(**request, text_format=output_model)
|
|
except openai.APIError as error:
|
|
error_kind = classify_openai_error(error)
|
|
if error_kind == "throttling":
|
|
logger.warning(_RATE_LIMIT_MSG)
|
|
raise ModelThrottledException(str(error)) from error
|
|
if error_kind == "context_overflow":
|
|
logger.warning(_CONTEXT_WINDOW_OVERFLOW_MSG)
|
|
raise ContextWindowOverflowException(str(error)) from error
|
|
raise
|
|
|
|
if response.output_parsed:
|
|
yield {"output": response.output_parsed}
|
|
else:
|
|
raise ValueError("No valid parsed output found in the OpenAI Responses API response.")
|
|
|
|
def _format_request(
|
|
self,
|
|
messages: Messages,
|
|
tool_specs: list[ToolSpec] | None = None,
|
|
system_prompt: str | None = None,
|
|
tool_choice: ToolChoice | None = None,
|
|
model_state: dict[str, Any] | None = None,
|
|
agent_metadata: AgentMetadata | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Format an OpenAI Responses API compatible response 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.
|
|
model_state: Runtime state for model providers (e.g., server-side response ids).
|
|
agent_metadata: Invoking agent's metadata.
|
|
|
|
Returns:
|
|
An OpenAI Responses API compatible response streaming request.
|
|
|
|
Raises:
|
|
TypeError: If a message contains a content block type that cannot be converted to an OpenAI-compatible
|
|
format.
|
|
"""
|
|
input_items = self._format_request_messages(messages)
|
|
request: dict[str, Any] = {
|
|
"model": self.config["model_id"],
|
|
"input": input_items,
|
|
"stream": True,
|
|
**cast(dict[str, Any], self.config.get("params", {})),
|
|
"store": self.stateful,
|
|
}
|
|
|
|
response_id = model_state.get("response_id") if model_state else None
|
|
if response_id and self.stateful:
|
|
request["previous_response_id"] = response_id
|
|
|
|
if system_prompt:
|
|
request["instructions"] = system_prompt
|
|
|
|
# Add tools if provided
|
|
if tool_specs:
|
|
# Merge function tools with any built-in tools (e.g. web_search) carried in from params.
|
|
# Build a new list rather than extending in place: ** unpacking above aliases
|
|
# self.config["params"]["tools"] by reference, so mutating it would duplicate every tool
|
|
# spec into the stored config on each call.
|
|
request["tools"] = [
|
|
*request.get("tools", []),
|
|
*(
|
|
{
|
|
"type": "function",
|
|
"name": tool_spec["name"],
|
|
"description": tool_spec.get("description", ""),
|
|
"parameters": tool_spec["inputSchema"]["json"],
|
|
}
|
|
for tool_spec in tool_specs
|
|
),
|
|
]
|
|
request.update(self._format_request_tool_choice(tool_choice))
|
|
|
|
apply_cache_config(request, cast(CacheConfig | None, self.config.get("cache_config")), agent_metadata)
|
|
|
|
return request
|
|
|
|
@classmethod
|
|
def _format_request_tool_choice(cls, tool_choice: ToolChoice | None) -> dict[str, Any]:
|
|
"""Format a tool choice for OpenAI Responses API compatibility.
|
|
|
|
Args:
|
|
tool_choice: Tool choice configuration.
|
|
|
|
Returns:
|
|
OpenAI Responses API compatible tool choice format.
|
|
"""
|
|
if not tool_choice:
|
|
return {}
|
|
|
|
match tool_choice:
|
|
case {"auto": _}:
|
|
return {"tool_choice": "auto"}
|
|
case {"any": _}:
|
|
return {"tool_choice": "required"}
|
|
case {"tool": {"name": tool_name}}:
|
|
return {"tool_choice": {"type": "function", "name": tool_name}}
|
|
case _:
|
|
# Default to auto for unknown formats
|
|
return {"tool_choice": "auto"}
|
|
|
|
@classmethod
|
|
def _format_request_messages(cls, messages: Messages) -> list[dict[str, Any]]:
|
|
"""Format an OpenAI compatible messages array.
|
|
|
|
Args:
|
|
messages: List of message objects to be processed by the model.
|
|
|
|
Returns:
|
|
An OpenAI compatible messages array.
|
|
"""
|
|
formatted_messages: list[dict[str, Any]] = []
|
|
|
|
for message in messages:
|
|
role = message["role"]
|
|
contents = message["content"]
|
|
|
|
if any("reasoningContent" in content for content in contents):
|
|
logger.warning(
|
|
"reasoningContent is not yet supported in multi-turn conversations with the Responses API"
|
|
)
|
|
|
|
if any("cachePoint" in content for content in contents):
|
|
logger.warning("cachePoint content block is not supported by OpenAI Responses | skipping")
|
|
|
|
formatted_contents = [
|
|
cls._format_request_message_content(content, role=role)
|
|
for content in contents
|
|
if not any(
|
|
block_type in content for block_type in ["toolResult", "toolUse", "reasoningContent", "cachePoint"]
|
|
)
|
|
]
|
|
|
|
formatted_tool_calls = [
|
|
cls._format_request_message_tool_call(content["toolUse"])
|
|
for content in contents
|
|
if "toolUse" in content
|
|
]
|
|
|
|
formatted_tool_messages = [
|
|
cls._format_request_tool_message(content["toolResult"])
|
|
for content in contents
|
|
if "toolResult" in content
|
|
]
|
|
|
|
if formatted_contents:
|
|
if role == "assistant":
|
|
# Only the string EasyInputMessage form is a valid assistant *input* shape
|
|
# without the full output-item metadata (id/status/annotations) that has
|
|
# already been discarded by this point. Replaying verbatim output items is
|
|
# tracked in https://github.com/strands-agents/harness-sdk/issues/3389.
|
|
dropped_types = [
|
|
part.get("type", "unknown") for part in formatted_contents if part.get("type") != "output_text"
|
|
]
|
|
if dropped_types:
|
|
logger.warning(
|
|
"content_type=<%s> | non-text assistant history content has no valid "
|
|
"Responses API input shape | dropping",
|
|
", ".join(dropped_types),
|
|
)
|
|
text = "\n".join(part["text"] for part in formatted_contents if part.get("type") == "output_text")
|
|
if text:
|
|
formatted_messages.append({"role": "assistant", "content": text})
|
|
else:
|
|
formatted_messages.append(
|
|
{
|
|
"role": role, # "user"
|
|
"content": formatted_contents,
|
|
}
|
|
)
|
|
|
|
formatted_messages.extend(formatted_tool_calls)
|
|
formatted_messages.extend(formatted_tool_messages)
|
|
|
|
return [
|
|
message
|
|
for message in formatted_messages
|
|
if message.get("content") or message.get("type") in ["function_call", "function_call_output"]
|
|
]
|
|
|
|
@classmethod
|
|
def _format_request_message_content(cls, content: ContentBlock, *, role: Role = "user") -> dict[str, Any]:
|
|
"""Format an OpenAI compatible content block.
|
|
|
|
Args:
|
|
content: Message content.
|
|
role: Message role ("user" or "assistant"). Controls text content
|
|
type: "input_text" for user, "output_text" for assistant.
|
|
|
|
Returns:
|
|
OpenAI compatible content block.
|
|
|
|
Raises:
|
|
TypeError: If the content block type cannot be converted to an OpenAI-compatible format.
|
|
ValueError: If the image or document size exceeds the maximum allowed size (20MB).
|
|
"""
|
|
if "document" in content:
|
|
doc = content["document"]
|
|
data_url = _encode_media_to_data_url(doc["source"]["bytes"], doc["format"], "document")
|
|
name = doc.get("name", "document")
|
|
suffix = f".{doc['format']}"
|
|
filename = name if name.endswith(suffix) else f"{name}{suffix}"
|
|
return {"type": "input_file", "filename": filename, "file_data": data_url}
|
|
|
|
if "image" in content:
|
|
img = content["image"]
|
|
data_url = _encode_media_to_data_url(img["source"]["bytes"], img["format"], "image")
|
|
return {"type": "input_image", "image_url": data_url}
|
|
|
|
if "text" in content:
|
|
text_type = "output_text" if role == "assistant" else "input_text"
|
|
return {"type": text_type, "text": content["text"]}
|
|
|
|
if "citationsContent" in content:
|
|
text = "".join(c["text"] for c in content["citationsContent"].get("content", []) if "text" in c)
|
|
text_type = "output_text" if role == "assistant" else "input_text"
|
|
return {"type": text_type, "text": text}
|
|
|
|
raise TypeError(f"content_type=<{next(iter(content))}> | unsupported type")
|
|
|
|
@classmethod
|
|
def _format_request_message_tool_call(cls, tool_use: ToolUse) -> dict[str, Any]:
|
|
"""Format an OpenAI compatible tool call.
|
|
|
|
Args:
|
|
tool_use: Tool use requested by the model.
|
|
|
|
Returns:
|
|
OpenAI compatible tool call.
|
|
"""
|
|
return {
|
|
"type": "function_call",
|
|
"call_id": tool_use["toolUseId"],
|
|
"name": tool_use["name"],
|
|
"arguments": json.dumps(tool_use["input"], ensure_ascii=False),
|
|
}
|
|
|
|
@classmethod
|
|
def _format_request_tool_message(cls, tool_result: ToolResult) -> dict[str, Any]:
|
|
"""Format an OpenAI compatible tool message.
|
|
|
|
Args:
|
|
tool_result: Tool result collected from a tool execution.
|
|
|
|
Returns:
|
|
OpenAI compatible tool message.
|
|
|
|
Raises:
|
|
ValueError: If the image or document size exceeds the maximum allowed size (20MB).
|
|
|
|
Note:
|
|
The Responses API's function_call_output can be either a string (typically JSON encoded)
|
|
or an array of content objects when returning images/files.
|
|
See: https://platform.openai.com/docs/guides/function-calling
|
|
"""
|
|
output_parts: list[dict[str, Any]] = []
|
|
has_media = False
|
|
|
|
for content in tool_result["content"]:
|
|
if "json" in content:
|
|
output_parts.append({"type": "input_text", "text": json.dumps(content["json"], ensure_ascii=False)})
|
|
elif "text" in content:
|
|
output_parts.append({"type": "input_text", "text": content["text"]})
|
|
elif "image" in content:
|
|
has_media = True
|
|
img = content["image"]
|
|
data_url = _encode_media_to_data_url(img["source"]["bytes"], img["format"], "image")
|
|
output_parts.append({"type": "input_image", "image_url": data_url})
|
|
elif "document" in content:
|
|
has_media = True
|
|
doc = content["document"]
|
|
data_url = _encode_media_to_data_url(doc["source"]["bytes"], doc["format"], "document")
|
|
name = doc.get("name", "document")
|
|
suffix = f".{doc['format']}"
|
|
filename = name if name.endswith(suffix) else f"{name}{suffix}"
|
|
output_parts.append({"type": "input_file", "filename": filename, "file_data": data_url})
|
|
|
|
# Return array if has media content, otherwise join as string for simpler text-only cases
|
|
output: list[dict[str, Any]] | str
|
|
if has_media:
|
|
output = output_parts
|
|
else:
|
|
output = "\n".join(part.get("text", "") for part in output_parts) if output_parts else ""
|
|
|
|
return {
|
|
"type": "function_call_output",
|
|
"call_id": tool_result["toolUseId"],
|
|
"output": output,
|
|
}
|
|
|
|
def _stream_switch_content(self, data_type: str, prev_data_type: str | None) -> tuple[list[StreamEvent], str]:
|
|
"""Handle switching to a new content stream.
|
|
|
|
Args:
|
|
data_type: The next content data type.
|
|
prev_data_type: The previous content data type.
|
|
|
|
Returns:
|
|
Tuple containing:
|
|
- Stop block for previous content and the start block for the next content.
|
|
- Next content data type.
|
|
"""
|
|
chunks: list[StreamEvent] = []
|
|
if data_type != prev_data_type:
|
|
if prev_data_type is not None:
|
|
chunks.append(self._format_chunk({"chunk_type": "content_stop", "data_type": prev_data_type}))
|
|
chunks.append(self._format_chunk({"chunk_type": "content_start", "data_type": data_type}))
|
|
|
|
return chunks, data_type
|
|
|
|
def _format_chunk(self, event: dict[str, Any]) -> StreamEvent:
|
|
"""Format an OpenAI response event into a standardized message chunk.
|
|
|
|
Args:
|
|
event: A response event from the OpenAI compatible model.
|
|
|
|
Returns:
|
|
The formatted chunk.
|
|
|
|
Raises:
|
|
RuntimeError: If chunk_type is not recognized.
|
|
This error should never be encountered as chunk_type is controlled in the stream method.
|
|
"""
|
|
match event["chunk_type"]:
|
|
case "message_start":
|
|
return {"messageStart": {"role": "assistant"}}
|
|
|
|
case "content_start":
|
|
if event["data_type"] == "tool":
|
|
return {
|
|
"contentBlockStart": {
|
|
"start": {
|
|
"toolUse": {
|
|
"name": event["data"].function.name,
|
|
"toolUseId": event["data"].id,
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return {"contentBlockStart": {"start": {}}}
|
|
|
|
case "content_delta":
|
|
if event["data_type"] == "tool":
|
|
return {
|
|
"contentBlockDelta": {"delta": {"toolUse": {"input": event["data"].function.arguments or ""}}}
|
|
}
|
|
|
|
if event["data_type"] == "reasoning_content":
|
|
return {"contentBlockDelta": {"delta": {"reasoningContent": {"text": event["data"]}}}}
|
|
|
|
if event["data_type"] == "citation":
|
|
web_location: WebLocationDict = {"web": {"url": event["data"].get("url", "")}}
|
|
return {
|
|
"contentBlockDelta": {
|
|
"delta": {
|
|
"citation": {
|
|
"title": event["data"].get("title", ""),
|
|
"location": web_location,
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return {"contentBlockDelta": {"delta": {"text": event["data"]}}}
|
|
|
|
case "content_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":
|
|
# Responses API uses input_tokens/output_tokens naming convention
|
|
usage_data: Usage = {
|
|
"inputTokens": getattr(event["data"], "input_tokens", 0),
|
|
"outputTokens": getattr(event["data"], "output_tokens", 0),
|
|
"totalTokens": getattr(event["data"], "total_tokens", 0),
|
|
}
|
|
|
|
if tokens_details := getattr(event["data"], "input_tokens_details", None):
|
|
cached = getattr(tokens_details, "cached_tokens", None)
|
|
if isinstance(cached, int) and cached:
|
|
usage_data["cacheReadInputTokens"] = cached
|
|
|
|
return {
|
|
"metadata": {
|
|
"usage": usage_data,
|
|
"metrics": {
|
|
"latencyMs": 0, # TODO
|
|
},
|
|
},
|
|
}
|
|
|
|
case _:
|
|
raise RuntimeError(f"chunk_type=<{event['chunk_type']}> | unknown type")
|