Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/fast_agent/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
"""Anthropic code-execution container metadata for multi-turn request reuse."""
OPENAI_REASONING_ENCRYPTED = "openai-reasoning-encrypted"
"""Encrypted OpenAI reasoning items for stateless Responses API passback."""
OPENAI_TOOL_CALL_EXTRA_CONTENT = "openai-tool-call-extra-content"
"""Per-tool-call ``extra_content`` (Gemini thought signatures) for chat replay."""
OPENAI_COMPACTION_ITEMS = "openai-compaction-items"
"""Opaque OpenAI Responses compaction items carried by a provider-native checkpoint."""
OPENAI_COMPACTION_TRIGGER = "openai-compaction-trigger"
Expand Down
36 changes: 27 additions & 9 deletions src/fast_agent/llm/provider/openai/llm_openai.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import asyncio
import json
from collections.abc import Mapping
from contextlib import suppress
from dataclasses import dataclass, field
from functools import partial
Expand Down Expand Up @@ -58,6 +59,14 @@
should_strip_tool_schema_defaults,
)
from fast_agent.llm.provider.openai.structured_output import OpenAIStructuredOutputMixin
from fast_agent.llm.provider.openai.tool_call_extra import (
add_tool_call_extra_channel,
remember_chunk_tool_call_extra,
remember_tool_call_extra,
retain_completion_tool_call_extra,
tool_call_extra_by_id,
write_tool_call_extra_content,
)
from fast_agent.llm.provider.openai.tool_notifications import OpenAIToolNotificationMixin
from fast_agent.llm.provider.reasoning_config import reasoning_setting_from_config
from fast_agent.llm.provider.streaming_timeouts import (
Expand Down Expand Up @@ -810,6 +819,7 @@ async def _process_stream(
# Track tool call state for stream events
tool_call_started: dict[int, dict[str, Any]] = {}
notified_tool_indices: set[int] = set()
tool_call_extra_by_index: dict[int, dict[str, Any]] = {}

# Process the stream chunks
# Cancellation is handled via asyncio.Task.cancel() which raises CancelledError
Expand All @@ -820,6 +830,7 @@ async def _process_stream(
self._raise_stream_chunk_error(chunk)
# Handle chunk accumulation
state.handle_chunk(chunk)
remember_chunk_tool_call_extra(chunk, tool_call_extra_by_index)
# Process streaming events for tool calls
cumulative_content, estimated_tokens, reasoning_active, _ = (
self._process_stream_chunk_common(
Expand Down Expand Up @@ -903,6 +914,7 @@ async def _process_stream(
history_reasoning_segments,
source="OpenAI streaming response",
)
retain_completion_tool_call_extra(final_completion, tool_call_extra_by_index)

return final_completion, history_reasoning_segments

Expand Down Expand Up @@ -965,6 +977,7 @@ def _record_manual_tool_call_delta(

if delta_tool_call.id:
tool_call["id"] = delta_tool_call.id
remember_tool_call_extra(tool_call, delta_tool_call)
if not delta_tool_call.function:
return
if delta_tool_call.function.name:
Expand Down Expand Up @@ -1033,16 +1046,18 @@ def _manual_stream_tool_calls(
tool_call_data = state.tool_calls_map[idx]
if not tool_call_data["id"] or not tool_call_data["function"]["name"]:
continue
tool_calls.append(
ChatCompletionMessageToolCall(
id=tool_call_data["id"],
type=tool_call_data["type"],
function=Function(
name=tool_call_data["function"]["name"],
arguments=tool_call_data["function"]["arguments"],
),
)
tool_call = ChatCompletionMessageToolCall(
id=tool_call_data["id"],
type=tool_call_data["type"],
function=Function(
name=tool_call_data["function"]["name"],
arguments=tool_call_data["function"]["arguments"],
),
)
extra_content = tool_call_data.get("extra_content")
if isinstance(extra_content, dict) and extra_content:
write_tool_call_extra_content(tool_call, extra_content)
tool_calls.append(tool_call)
return tool_calls or None

def _raise_for_incomplete_manual_tools(
Expand Down Expand Up @@ -1461,10 +1476,12 @@ def _openai_response_channels(
reasoning_blocks: list[ContentBlock] | None,
choice: Any,
stop_result: _OpenAIStopResult,
tool_call_extra: Mapping[str, Mapping[str, Any]] | None = None,
) -> dict[str, list[ContentBlock]] | None:
channels: dict[str, list[ContentBlock]] = {}
if reasoning_blocks:
channels[REASONING] = reasoning_blocks
add_tool_call_extra_channel(channels, tool_call_extra)
if stop_result.stop_reason == LlmStopReason.SAFETY:
channels[FAST_AGENT_SAFETY_DETAILS] = [
TextContent(
Expand Down Expand Up @@ -1514,6 +1531,7 @@ async def _finalize_openai_completion(
reasoning_blocks=reasoning_blocks,
choice=choice,
stop_result=stop_result,
tool_call_extra=tool_call_extra_by_id(message.tool_calls),
),
stop_reason=stop_result.stop_reason,
)
Expand Down
29 changes: 19 additions & 10 deletions src/fast_agent/llm/provider/openai/multipart_converter_openai.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import json
from collections.abc import Iterable, Mapping
from typing import Any
from typing import Any, cast

from mcp_types import (
AudioContent,
Expand Down Expand Up @@ -28,6 +28,7 @@
from openai.types.chat.chat_completion_user_message_param import ChatCompletionContentPartParam

from fast_agent.core.logging.logger import get_logger
from fast_agent.llm.provider.openai.tool_call_extra import extra_content_by_tool_id
from fast_agent.mcp.helpers.content_helpers import (
canonicalize_tool_result_content_for_llm,
get_image_data,
Expand Down Expand Up @@ -135,7 +136,8 @@ def convert_to_openai(
ChatCompletionAssistantMessageParam(
role="assistant",
tool_calls=OpenAIConverter._convert_tool_calls_to_openai(
multipart_msg.tool_calls
multipart_msg.tool_calls,
extra_content_by_tool_id(multipart_msg.channels),
),
content=OpenAIConverter._assistant_tool_call_content(multipart_msg.content),
)
Expand Down Expand Up @@ -168,18 +170,25 @@ def convert_to_openai(
@staticmethod
def _convert_tool_calls_to_openai(
tool_calls: Mapping[str, CallToolRequest],
extra_content_by_id: Mapping[str, Mapping[str, Any]] | None = None,
) -> list[ChatCompletionMessageToolCallParam]:
return [
ChatCompletionMessageToolCallParam(
id=tool_id,
type="function",
function={
# Gemini 3 rejects a follow-up tool call that omits extra_content.
extras = extra_content_by_id or {}
converted: list[ChatCompletionMessageToolCallParam] = []
for tool_id, request in tool_calls.items():
payload: dict[str, Any] = {
"id": tool_id,
"type": "function",
"function": {
"name": request.params.name,
"arguments": json.dumps(request.params.arguments or {}),
},
)
for tool_id, request in tool_calls.items()
]
}
extra = extras.get(tool_id)
if extra:
payload["extra_content"] = dict(extra)
converted.append(cast("ChatCompletionMessageToolCallParam", payload))
return converted

@staticmethod
def _assistant_tool_call_content(content: list) -> str:
Expand Down
Loading
Loading