pi-ai-python 1.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pi_agent/__init__.py +127 -0
- pi_agent/agent.py +617 -0
- pi_agent/agent_loop.py +820 -0
- pi_agent/proxy.py +269 -0
- pi_agent/py.typed +0 -0
- pi_agent/stream_fn.py +27 -0
- pi_agent/tools.py +327 -0
- pi_agent/types.py +394 -0
- pi_ai/__init__.py +263 -0
- pi_ai/abort.py +315 -0
- pi_ai/api/__init__.py +1 -0
- pi_ai/api/anthropic_messages.py +1782 -0
- pi_ai/api/azure_openai_config.py +138 -0
- pi_ai/api/azure_openai_responses.py +245 -0
- pi_ai/api/bedrock_converse_stream.py +1598 -0
- pi_ai/api/classifier_shared.py +211 -0
- pi_ai/api/cloudflare.py +36 -0
- pi_ai/api/cloudflare_workers_ai_system_one.py +80 -0
- pi_ai/api/constrained_sampling.py +279 -0
- pi_ai/api/github_copilot_headers.py +30 -0
- pi_ai/api/google_generative_ai.py +138 -0
- pi_ai/api/google_shared.py +927 -0
- pi_ai/api/google_vertex.py +234 -0
- pi_ai/api/lazy.py +156 -0
- pi_ai/api/llama_cpp_classify.py +449 -0
- pi_ai/api/mistral_conversations.py +756 -0
- pi_ai/api/openai_codex_responses.py +1545 -0
- pi_ai/api/openai_completions.py +1594 -0
- pi_ai/api/openai_decisions.py +210 -0
- pi_ai/api/openai_prompt_cache.py +19 -0
- pi_ai/api/openai_responses.py +418 -0
- pi_ai/api/openai_responses_shared.py +840 -0
- pi_ai/api/openai_sdk.py +204 -0
- pi_ai/api/openrouter_images.py +153 -0
- pi_ai/api/pi_messages.py +449 -0
- pi_ai/api/registry.py +103 -0
- pi_ai/api/simple_options.py +119 -0
- pi_ai/api/system_one_shared.py +148 -0
- pi_ai/api/transform_messages.py +211 -0
- pi_ai/api/typesafe_system_one.py +43 -0
- pi_ai/auth/__init__.py +1 -0
- pi_ai/auth/context.py +47 -0
- pi_ai/auth/credential_store.py +208 -0
- pi_ai/auth/helpers.py +123 -0
- pi_ai/auth/oauth/__init__.py +5 -0
- pi_ai/auth/oauth/_common.py +454 -0
- pi_ai/auth/oauth/anthropic.py +283 -0
- pi_ai/auth/oauth/callback_server.py +339 -0
- pi_ai/auth/oauth/device_code.py +127 -0
- pi_ai/auth/oauth/github_copilot.py +508 -0
- pi_ai/auth/oauth/kimi_coding.py +283 -0
- pi_ai/auth/oauth/load.py +64 -0
- pi_ai/auth/oauth/meta.py +238 -0
- pi_ai/auth/oauth/openai_chatgpt.py +321 -0
- pi_ai/auth/oauth/openai_codex.py +391 -0
- pi_ai/auth/oauth/openrouter.py +173 -0
- pi_ai/auth/oauth/pkce.py +23 -0
- pi_ai/auth/oauth/radius.py +311 -0
- pi_ai/auth/oauth/xai.py +220 -0
- pi_ai/auth/resolve.py +190 -0
- pi_ai/auth/types.py +292 -0
- pi_ai/cli.py +265 -0
- pi_ai/compat.py +651 -0
- pi_ai/env_api_keys.py +137 -0
- pi_ai/event_stream.py +329 -0
- pi_ai/models.py +1191 -0
- pi_ai/models_store.py +121 -0
- pi_ai/oauth.py +90 -0
- pi_ai/providers/__init__.py +1 -0
- pi_ai/providers/all.py +154 -0
- pi_ai/providers/amazon_bedrock.py +116 -0
- pi_ai/providers/ant_ling.py +23 -0
- pi_ai/providers/anthropic.py +105 -0
- pi_ai/providers/azure.py +103 -0
- pi_ai/providers/baseten.py +23 -0
- pi_ai/providers/catalog.py +167 -0
- pi_ai/providers/cerebras.py +23 -0
- pi_ai/providers/cloudflare_ai_gateway.py +30 -0
- pi_ai/providers/cloudflare_auth.py +112 -0
- pi_ai/providers/cloudflare_stream.py +84 -0
- pi_ai/providers/cloudflare_workers_ai.py +26 -0
- pi_ai/providers/data/amazon-bedrock.json +1 -0
- pi_ai/providers/data/ant-ling.json +1 -0
- pi_ai/providers/data/anthropic.json +1 -0
- pi_ai/providers/data/azure.json +1 -0
- pi_ai/providers/data/baseten.json +1 -0
- pi_ai/providers/data/cerebras.json +1 -0
- pi_ai/providers/data/cloudflare-ai-gateway.json +1 -0
- pi_ai/providers/data/cloudflare-workers-ai.json +1 -0
- pi_ai/providers/data/deepseek.json +1 -0
- pi_ai/providers/data/fireworks.json +1 -0
- pi_ai/providers/data/github-copilot.json +1 -0
- pi_ai/providers/data/google-vertex.json +1 -0
- pi_ai/providers/data/google.json +1 -0
- pi_ai/providers/data/groq.json +1 -0
- pi_ai/providers/data/huggingface.json +1 -0
- pi_ai/providers/data/kimi-coding.json +1 -0
- pi_ai/providers/data/manifest.json +1 -0
- pi_ai/providers/data/meta.json +1 -0
- pi_ai/providers/data/minimax-cn.json +1 -0
- pi_ai/providers/data/minimax.json +1 -0
- pi_ai/providers/data/mistral.json +1 -0
- pi_ai/providers/data/moonshotai-cn.json +1 -0
- pi_ai/providers/data/moonshotai.json +1 -0
- pi_ai/providers/data/nvidia.json +1 -0
- pi_ai/providers/data/openai-codex.json +1 -0
- pi_ai/providers/data/openai.json +1 -0
- pi_ai/providers/data/opencode-go.json +1 -0
- pi_ai/providers/data/opencode.json +1 -0
- pi_ai/providers/data/openrouter.json +1 -0
- pi_ai/providers/data/qwen-token-plan-cn.json +1 -0
- pi_ai/providers/data/qwen-token-plan-individual.json +1 -0
- pi_ai/providers/data/qwen-token-plan.json +1 -0
- pi_ai/providers/data/radius.json +1 -0
- pi_ai/providers/data/together.json +1 -0
- pi_ai/providers/data/typesafe.json +1 -0
- pi_ai/providers/data/vercel-ai-gateway.json +1 -0
- pi_ai/providers/data/xai.json +1 -0
- pi_ai/providers/data/xiaomi-token-plan-ams.json +1 -0
- pi_ai/providers/data/xiaomi-token-plan-cn.json +1 -0
- pi_ai/providers/data/xiaomi-token-plan-sgp.json +1 -0
- pi_ai/providers/data/xiaomi.json +1 -0
- pi_ai/providers/data/zai-coding-cn.json +1 -0
- pi_ai/providers/data/zai.json +1 -0
- pi_ai/providers/deepseek.py +23 -0
- pi_ai/providers/faux.py +680 -0
- pi_ai/providers/fireworks.py +26 -0
- pi_ai/providers/github_copilot.py +46 -0
- pi_ai/providers/google.py +23 -0
- pi_ai/providers/google_vertex.py +131 -0
- pi_ai/providers/groq.py +23 -0
- pi_ai/providers/huggingface.py +23 -0
- pi_ai/providers/kimi_coding.py +32 -0
- pi_ai/providers/meta.py +32 -0
- pi_ai/providers/minimax.py +23 -0
- pi_ai/providers/minimax_cn.py +23 -0
- pi_ai/providers/mistral.py +23 -0
- pi_ai/providers/moonshotai.py +23 -0
- pi_ai/providers/moonshotai_cn.py +23 -0
- pi_ai/providers/nvidia.py +23 -0
- pi_ai/providers/openai.py +44 -0
- pi_ai/providers/openai_codex.py +26 -0
- pi_ai/providers/opencode.py +36 -0
- pi_ai/providers/opencode_go.py +33 -0
- pi_ai/providers/opencode_headers.py +60 -0
- pi_ai/providers/openrouter.py +145 -0
- pi_ai/providers/openrouter_api.py +122 -0
- pi_ai/providers/qwen_token_plan.py +23 -0
- pi_ai/providers/qwen_token_plan_cn.py +23 -0
- pi_ai/providers/qwen_token_plan_individual.py +23 -0
- pi_ai/providers/radius.py +146 -0
- pi_ai/providers/radius_config.py +136 -0
- pi_ai/providers/together.py +23 -0
- pi_ai/providers/typesafe.py +22 -0
- pi_ai/providers/vercel_ai_gateway.py +25 -0
- pi_ai/providers/xai.py +32 -0
- pi_ai/providers/xiaomi.py +23 -0
- pi_ai/providers/xiaomi_token_plan_ams.py +23 -0
- pi_ai/providers/xiaomi_token_plan_cn.py +23 -0
- pi_ai/providers/xiaomi_token_plan_sgp.py +23 -0
- pi_ai/providers/zai.py +23 -0
- pi_ai/providers/zai_coding_cn.py +23 -0
- pi_ai/py.typed +0 -0
- pi_ai/serialization.py +477 -0
- pi_ai/session_resources.py +33 -0
- pi_ai/types.py +1003 -0
- pi_ai/utils/__init__.py +1 -0
- pi_ai/utils/callbacks.py +15 -0
- pi_ai/utils/diagnostics.py +70 -0
- pi_ai/utils/error_body.py +89 -0
- pi_ai/utils/estimate.py +125 -0
- pi_ai/utils/fastjson.py +63 -0
- pi_ai/utils/http.py +409 -0
- pi_ai/utils/json_parse.py +424 -0
- pi_ai/utils/misc.py +193 -0
- pi_ai/utils/model_operations.py +73 -0
- pi_ai/utils/models_error.py +35 -0
- pi_ai/utils/oauth_page.py +121 -0
- pi_ai/utils/overflow.py +87 -0
- pi_ai/utils/retry.py +193 -0
- pi_ai/utils/schema.py +283 -0
- pi_ai/utils/text.py +65 -0
- pi_ai/utils/transcript.py +271 -0
- pi_ai/utils/validation.py +813 -0
- pi_ai_python-1.3.0.dist-info/METADATA +167 -0
- pi_ai_python-1.3.0.dist-info/RECORD +189 -0
- pi_ai_python-1.3.0.dist-info/WHEEL +4 -0
- pi_ai_python-1.3.0.dist-info/entry_points.txt +2 -0
- pi_ai_python-1.3.0.dist-info/licenses/LICENSE +22 -0
|
@@ -0,0 +1,840 @@
|
|
|
1
|
+
# pyright: reportAttributeAccessIssue=false, reportArgumentType=false
|
|
2
|
+
# Converters branch on ``role``/``type`` discriminators held in locals, which pyright does not narrow.
|
|
3
|
+
"""Message/tool conversion and stream processing shared by the OpenAI Responses API family.
|
|
4
|
+
|
|
5
|
+
Used by ``openai-responses``, ``azure-openai-responses`` and ``openai-codex-responses``.
|
|
6
|
+
Request items and stream events are plain dicts in the OpenAI Responses wire format.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
from collections.abc import AsyncIterable, Callable, Mapping, Sequence
|
|
14
|
+
from collections.abc import Set as AbstractSet
|
|
15
|
+
from typing import Any, Literal, TypedDict
|
|
16
|
+
|
|
17
|
+
from ..event_stream import AssistantMessageEventStream
|
|
18
|
+
from ..models import calculate_cost
|
|
19
|
+
from ..types import (
|
|
20
|
+
AssistantMessage,
|
|
21
|
+
ImageContent,
|
|
22
|
+
Model,
|
|
23
|
+
StopReason,
|
|
24
|
+
SystemMessage,
|
|
25
|
+
TextContent,
|
|
26
|
+
TextDeltaEvent,
|
|
27
|
+
TextEndEvent,
|
|
28
|
+
TextStartEvent,
|
|
29
|
+
ThinkingContent,
|
|
30
|
+
ThinkingDeltaEvent,
|
|
31
|
+
ThinkingEndEvent,
|
|
32
|
+
ThinkingStartEvent,
|
|
33
|
+
Tool,
|
|
34
|
+
ToolCall,
|
|
35
|
+
ToolCallDeltaEvent,
|
|
36
|
+
ToolCallEndEvent,
|
|
37
|
+
ToolCallStartEvent,
|
|
38
|
+
TranscriptContext,
|
|
39
|
+
Usage,
|
|
40
|
+
)
|
|
41
|
+
from ..utils import fastjson
|
|
42
|
+
from ..utils.callbacks import maybe_await
|
|
43
|
+
from ..utils.json_parse import StreamingJsonParser, parse_tool_call_arguments
|
|
44
|
+
from ..utils.misc import sanitize_surrogates, short_hash
|
|
45
|
+
from ..utils.text import append_text, append_thinking, get_system_message_text, render_system_message_update
|
|
46
|
+
from ..utils.transcript import resolve_transcript, resolve_transcript_tools
|
|
47
|
+
from .constrained_sampling import (
|
|
48
|
+
GrammarToolInputJsonBuffer,
|
|
49
|
+
append_grammar_tool_input_json_delta,
|
|
50
|
+
get_grammar_tool_input,
|
|
51
|
+
get_json_schema_tool_parameters,
|
|
52
|
+
resolve_grammar_constrained_sampling,
|
|
53
|
+
resolve_json_schema_strict_sampling,
|
|
54
|
+
)
|
|
55
|
+
from .transform_messages import transform_messages
|
|
56
|
+
|
|
57
|
+
__all__ = [
|
|
58
|
+
"OpenAIResponsesStreamOptions",
|
|
59
|
+
"ConvertResponsesMessagesOptions",
|
|
60
|
+
"ConvertResponsesToolsOptions",
|
|
61
|
+
"convert_responses_messages",
|
|
62
|
+
"convert_responses_tools",
|
|
63
|
+
"process_responses_stream",
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
# =============================================================================
|
|
67
|
+
# Utilities
|
|
68
|
+
# =============================================================================
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _json_stringify(value: Any) -> str:
|
|
72
|
+
"""Compact JSON like ``JSON.stringify`` (non-ASCII kept as is)."""
|
|
73
|
+
return fastjson.dumps(value)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _encode_text_signature_v1(item_id: Any, phase: Any = None) -> str:
|
|
77
|
+
payload: dict[str, Any] = {"v": 1}
|
|
78
|
+
if item_id is not None:
|
|
79
|
+
payload["id"] = item_id
|
|
80
|
+
if phase:
|
|
81
|
+
payload["phase"] = phase
|
|
82
|
+
return json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _parse_text_signature(signature: str | None) -> tuple[str, str | None] | None:
|
|
86
|
+
if not signature:
|
|
87
|
+
return None
|
|
88
|
+
if signature.startswith("{"):
|
|
89
|
+
try:
|
|
90
|
+
parsed = json.loads(signature)
|
|
91
|
+
except ValueError:
|
|
92
|
+
parsed = None # Fall through to legacy plain-string handling.
|
|
93
|
+
if isinstance(parsed, dict):
|
|
94
|
+
version = parsed.get("v")
|
|
95
|
+
if version == 1 and not isinstance(version, bool) and isinstance(parsed.get("id"), str):
|
|
96
|
+
phase = parsed.get("phase")
|
|
97
|
+
return parsed["id"], phase if phase in ("commentary", "final_answer") else None
|
|
98
|
+
return signature, None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _convert_tool_result_output(model: Model, content: Sequence[TextContent | ImageContent]) -> str | list[Any]:
|
|
102
|
+
text_result = "\n".join(block.text for block in content if block.type == "text")
|
|
103
|
+
images = [block for block in content if block.type == "image"]
|
|
104
|
+
has_text = len(text_result.strip()) > 0
|
|
105
|
+
|
|
106
|
+
if not images or "image" not in model.input:
|
|
107
|
+
if has_text:
|
|
108
|
+
return sanitize_surrogates(text_result)
|
|
109
|
+
return "(see attached image)" if images else "(no tool output)"
|
|
110
|
+
|
|
111
|
+
output: list[Any] = []
|
|
112
|
+
if has_text:
|
|
113
|
+
output.append({"type": "input_text", "text": sanitize_surrogates(text_result)})
|
|
114
|
+
for image in images:
|
|
115
|
+
output.append(
|
|
116
|
+
{"type": "input_image", "detail": "auto", "image_url": f"data:{image.mime_type};base64,{image.data}"}
|
|
117
|
+
)
|
|
118
|
+
return output
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class OpenAIResponsesStreamOptions(TypedDict, total=False):
|
|
122
|
+
on_provider_stream_event: Callable[[Any, Any], Any]
|
|
123
|
+
service_tier: str
|
|
124
|
+
#: Grammar (custom) tool name -> its single string input property.
|
|
125
|
+
grammar_tool_input_properties: Mapping[str, str]
|
|
126
|
+
#: ``(response_service_tier, request_service_tier) -> service tier`` used for pricing.
|
|
127
|
+
resolve_service_tier: Callable[[Any, Any], Any]
|
|
128
|
+
#: ``(usage, service_tier) -> None``; adjusts ``usage.cost`` in place.
|
|
129
|
+
apply_service_tier_pricing: Callable[[Usage, Any], None]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class ConvertResponsesToolsOptions(TypedDict, total=False):
|
|
133
|
+
#: Default ``strict`` value of function tools. Absent means ``False``; ``None`` sends ``null``.
|
|
134
|
+
strict: bool | None
|
|
135
|
+
supports_strict_mode: bool
|
|
136
|
+
supports_openai_grammar_tools: bool
|
|
137
|
+
tool_search_result: bool
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class ConvertResponsesMessagesOptions(TypedDict, total=False):
|
|
141
|
+
include_system_prompt: bool
|
|
142
|
+
grammar_tool_input_properties: Mapping[str, str]
|
|
143
|
+
#: Whether later system messages are sent in place; otherwise they are folded into the leading prompt.
|
|
144
|
+
supports_mid_convo_system_messages: bool
|
|
145
|
+
supports_additional_tools: bool
|
|
146
|
+
supports_tool_search: bool
|
|
147
|
+
tool_options: ConvertResponsesToolsOptions
|
|
148
|
+
#: Effort for the reply being generated. When set, the thinking level is sent as
|
|
149
|
+
#: ``configuration_update`` items instead of a changing ``reasoning.effort``, which would invalidate
|
|
150
|
+
#: the prompt cache. Each item sets the effort from that point on: one goes before every earlier
|
|
151
|
+
#: reply with the level recorded on it (``provider_thinking_level``), and one with this effort goes
|
|
152
|
+
#: at the end. Earlier input therefore never changes. OpenAI documents the item before user
|
|
153
|
+
#: messages only. After tool results it was verified on gpt-6-luna (October 2026) to be accepted
|
|
154
|
+
#: and to apply to the next reply.
|
|
155
|
+
configuration_update_effort: str
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# =============================================================================
|
|
159
|
+
# Message conversion
|
|
160
|
+
# =============================================================================
|
|
161
|
+
|
|
162
|
+
_ID_PART_INVALID = re.compile(r"[^a-zA-Z0-9_-]")
|
|
163
|
+
_TRAILING_UNDERSCORES = re.compile(r"_+$")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _replace_invalid_id_char(match: re.Match[str]) -> str:
|
|
167
|
+
# JavaScript replaces per UTF-16 code unit, so astral characters become two underscores.
|
|
168
|
+
return "__" if ord(match.group()) > 0xFFFF else "_"
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _normalize_id_part(part: str) -> str:
|
|
172
|
+
sanitized = _ID_PART_INVALID.sub(_replace_invalid_id_char, part)
|
|
173
|
+
normalized = sanitized[:64] if len(sanitized) > 64 else sanitized
|
|
174
|
+
return _TRAILING_UNDERSCORES.sub("", normalized)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _build_foreign_responses_item_id(item_id: str) -> str:
|
|
178
|
+
normalized = f"fc_{short_hash(item_id)}"
|
|
179
|
+
return normalized[:64] if len(normalized) > 64 else normalized
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def convert_responses_messages(
|
|
183
|
+
model: Model,
|
|
184
|
+
context: TranscriptContext,
|
|
185
|
+
allowed_tool_call_providers: AbstractSet[str],
|
|
186
|
+
options: ConvertResponsesMessagesOptions | None = None,
|
|
187
|
+
) -> list[dict[str, Any]]:
|
|
188
|
+
"""Convert a transcript into OpenAI Responses ``input`` items."""
|
|
189
|
+
options = options or {}
|
|
190
|
+
normalized_context = resolve_transcript(context, options.get("supports_mid_convo_system_messages"))
|
|
191
|
+
messages: list[dict[str, Any]] = []
|
|
192
|
+
|
|
193
|
+
def normalize_tool_call_id(tool_call_id: str, _target_model: Model, source: AssistantMessage) -> str:
|
|
194
|
+
if model.provider not in allowed_tool_call_providers:
|
|
195
|
+
return _normalize_id_part(tool_call_id)
|
|
196
|
+
if "|" not in tool_call_id:
|
|
197
|
+
return _normalize_id_part(tool_call_id)
|
|
198
|
+
parts = tool_call_id.split("|")
|
|
199
|
+
call_id, item_id = parts[0], parts[1]
|
|
200
|
+
normalized_call_id = _normalize_id_part(call_id)
|
|
201
|
+
is_foreign_tool_call = source.provider != model.provider or source.api != model.api
|
|
202
|
+
normalized_item_id = (
|
|
203
|
+
_build_foreign_responses_item_id(item_id) if is_foreign_tool_call else _normalize_id_part(item_id)
|
|
204
|
+
)
|
|
205
|
+
# OpenAI Responses API requires item id to start with "fc"
|
|
206
|
+
if not normalized_item_id.startswith("fc_"):
|
|
207
|
+
normalized_item_id = _normalize_id_part(f"fc_{normalized_item_id}")
|
|
208
|
+
return f"{normalized_call_id}|{normalized_item_id}"
|
|
209
|
+
|
|
210
|
+
transformed_messages = transform_messages(normalized_context.messages, model, normalize_tool_call_id)
|
|
211
|
+
supports_additional_tools = bool(options.get("supports_additional_tools"))
|
|
212
|
+
supports_tool_search = bool(options.get("supports_tool_search"))
|
|
213
|
+
transcript_tools = resolve_transcript_tools(
|
|
214
|
+
normalized_context.messages, supports_additional_tools or supports_tool_search
|
|
215
|
+
)
|
|
216
|
+
tool_options = options.get("tool_options")
|
|
217
|
+
grammar_properties = options.get("grammar_tool_input_properties") or {}
|
|
218
|
+
|
|
219
|
+
def append_system_tool_additions(message: SystemMessage, seed: str) -> None:
|
|
220
|
+
tools = (message.tools_added or []) if transcript_tools.anchors_additions else []
|
|
221
|
+
if not tools:
|
|
222
|
+
return
|
|
223
|
+
if supports_additional_tools:
|
|
224
|
+
messages.append(
|
|
225
|
+
{"type": "additional_tools", "role": "developer", "tools": convert_responses_tools(tools, tool_options)}
|
|
226
|
+
)
|
|
227
|
+
return
|
|
228
|
+
if not supports_tool_search:
|
|
229
|
+
return
|
|
230
|
+
names = [tool.name for tool in tools]
|
|
231
|
+
call_id = f"pi_tool_load_{short_hash(seed + ':' + ','.join(names))}"
|
|
232
|
+
messages.append(
|
|
233
|
+
{
|
|
234
|
+
"type": "tool_search_call",
|
|
235
|
+
"call_id": call_id,
|
|
236
|
+
"execution": "client",
|
|
237
|
+
"status": "completed",
|
|
238
|
+
"arguments": {"query": " ".join(names), "limit": len(names)},
|
|
239
|
+
}
|
|
240
|
+
)
|
|
241
|
+
messages.append(
|
|
242
|
+
{
|
|
243
|
+
"type": "tool_search_output",
|
|
244
|
+
"call_id": call_id,
|
|
245
|
+
"execution": "client",
|
|
246
|
+
"status": "completed",
|
|
247
|
+
"tools": convert_responses_tools(tools, {**(tool_options or {}), "tool_search_result": True}),
|
|
248
|
+
}
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
include_initial_system_message = options.get("include_system_prompt")
|
|
252
|
+
if include_initial_system_message is None:
|
|
253
|
+
include_initial_system_message = True
|
|
254
|
+
configuration_update_effort = options.get("configuration_update_effort")
|
|
255
|
+
|
|
256
|
+
def push_configuration_update(effort: str) -> None:
|
|
257
|
+
messages.append({"type": "configuration_update", "reasoning": {"effort": effort}})
|
|
258
|
+
|
|
259
|
+
compat = model.compat or {}
|
|
260
|
+
instruction_role = "developer" if model.reasoning and compat.get("supportsDeveloperRole") is not False else "system"
|
|
261
|
+
|
|
262
|
+
msg_index = 0
|
|
263
|
+
source_index = 0
|
|
264
|
+
for msg in transformed_messages:
|
|
265
|
+
is_leading_system_message = source_index == 0 and msg.role == "system"
|
|
266
|
+
source_index += 1
|
|
267
|
+
role = msg.role
|
|
268
|
+
if role == "system":
|
|
269
|
+
if not is_leading_system_message:
|
|
270
|
+
append_system_tool_additions(msg, f"system:{msg_index}")
|
|
271
|
+
if not is_leading_system_message or include_initial_system_message:
|
|
272
|
+
text = get_system_message_text(msg) if is_leading_system_message else render_system_message_update(msg)
|
|
273
|
+
if text:
|
|
274
|
+
messages.append({"role": instruction_role, "content": sanitize_surrogates(text)})
|
|
275
|
+
elif role == "user":
|
|
276
|
+
if isinstance(msg.content, str):
|
|
277
|
+
messages.append(
|
|
278
|
+
{"role": "user", "content": [{"type": "input_text", "text": sanitize_surrogates(msg.content)}]}
|
|
279
|
+
)
|
|
280
|
+
else:
|
|
281
|
+
content: list[dict[str, Any]] = []
|
|
282
|
+
for item in msg.content:
|
|
283
|
+
if item.type == "text":
|
|
284
|
+
content.append({"type": "input_text", "text": sanitize_surrogates(item.text)})
|
|
285
|
+
else:
|
|
286
|
+
content.append(
|
|
287
|
+
{
|
|
288
|
+
"type": "input_image",
|
|
289
|
+
"detail": "auto",
|
|
290
|
+
"image_url": f"data:{item.mime_type};base64,{item.data}",
|
|
291
|
+
}
|
|
292
|
+
)
|
|
293
|
+
if not content:
|
|
294
|
+
continue
|
|
295
|
+
messages.append({"role": "user", "content": content})
|
|
296
|
+
elif role == "assistant":
|
|
297
|
+
output: list[dict[str, Any]] = []
|
|
298
|
+
is_same_provider_and_api = msg.provider == model.provider and msg.api == model.api
|
|
299
|
+
is_same_model = is_same_provider_and_api and msg.model == model.id
|
|
300
|
+
is_different_model = is_same_provider_and_api and msg.model != model.id
|
|
301
|
+
text_block_index = 0
|
|
302
|
+
|
|
303
|
+
for block in msg.content:
|
|
304
|
+
kind = block.type
|
|
305
|
+
if kind == "thinking":
|
|
306
|
+
if block.thinking_signature:
|
|
307
|
+
output.append(fastjson.loads(block.thinking_signature))
|
|
308
|
+
elif kind == "text":
|
|
309
|
+
parsed_signature = _parse_text_signature(block.text_signature)
|
|
310
|
+
fallback_message_id = (
|
|
311
|
+
f"msg_pi_{msg_index}" if text_block_index == 0 else f"msg_pi_{msg_index}_{text_block_index}"
|
|
312
|
+
)
|
|
313
|
+
text_block_index += 1
|
|
314
|
+
# OpenAI requires id to be max 64 characters
|
|
315
|
+
msg_id = parsed_signature[0] if parsed_signature else None
|
|
316
|
+
if not msg_id:
|
|
317
|
+
msg_id = fallback_message_id
|
|
318
|
+
elif len(msg_id) > 64:
|
|
319
|
+
msg_id = f"msg_{short_hash(msg_id)}"
|
|
320
|
+
message_item: dict[str, Any] = {
|
|
321
|
+
"type": "message",
|
|
322
|
+
"role": "assistant",
|
|
323
|
+
"content": [
|
|
324
|
+
{"type": "output_text", "text": sanitize_surrogates(block.text), "annotations": []}
|
|
325
|
+
],
|
|
326
|
+
"status": "completed",
|
|
327
|
+
"id": msg_id,
|
|
328
|
+
}
|
|
329
|
+
phase = parsed_signature[1] if parsed_signature else None
|
|
330
|
+
if phase is not None:
|
|
331
|
+
message_item["phase"] = phase
|
|
332
|
+
output.append(message_item)
|
|
333
|
+
elif kind == "toolCall":
|
|
334
|
+
id_parts = block.id.split("|")
|
|
335
|
+
call_id = id_parts[0]
|
|
336
|
+
item_id: str | None = id_parts[1] if len(id_parts) > 1 else None
|
|
337
|
+
custom_input_property = grammar_properties.get(block.name)
|
|
338
|
+
|
|
339
|
+
# For different-model messages, set id to undefined to avoid pairing validation.
|
|
340
|
+
# OpenAI tracks which item IDs were paired with rs_xxx reasoning items.
|
|
341
|
+
# By omitting the id, we avoid triggering that validation (like cross-provider does).
|
|
342
|
+
# Also drop ids that do not match the replayed item type: function_call ids must be fc_*
|
|
343
|
+
# and custom_tool_call ids must be ctc_*. Foreign tool call ids are normalized to fc_*, and
|
|
344
|
+
# a call can switch between the two types when grammar tool support differs.
|
|
345
|
+
item_id_prefix = "fc_" if custom_input_property is None else "ctc_"
|
|
346
|
+
if is_different_model or not (item_id is not None and item_id.startswith(item_id_prefix)):
|
|
347
|
+
item_id = None
|
|
348
|
+
|
|
349
|
+
call_item: dict[str, Any]
|
|
350
|
+
if custom_input_property is not None:
|
|
351
|
+
call_item = {"type": "custom_tool_call"}
|
|
352
|
+
if item_id is not None:
|
|
353
|
+
call_item["id"] = item_id
|
|
354
|
+
call_item["call_id"] = call_id
|
|
355
|
+
call_item["name"] = block.name
|
|
356
|
+
call_item["input"] = sanitize_surrogates(
|
|
357
|
+
get_grammar_tool_input(block.name, block.arguments, custom_input_property)
|
|
358
|
+
)
|
|
359
|
+
else:
|
|
360
|
+
call_item = {"type": "function_call"}
|
|
361
|
+
if item_id is not None:
|
|
362
|
+
call_item["id"] = item_id
|
|
363
|
+
call_item["call_id"] = call_id
|
|
364
|
+
call_item["name"] = block.name
|
|
365
|
+
call_item["arguments"] = _json_stringify(block.arguments)
|
|
366
|
+
if is_same_model and block.namespace is not None:
|
|
367
|
+
call_item["namespace"] = block.namespace
|
|
368
|
+
output.append(call_item)
|
|
369
|
+
if not output:
|
|
370
|
+
continue
|
|
371
|
+
if configuration_update_effort is not None and is_same_model and msg.provider_thinking_level is not None:
|
|
372
|
+
push_configuration_update(msg.provider_thinking_level)
|
|
373
|
+
messages.extend(output)
|
|
374
|
+
elif role == "toolResult":
|
|
375
|
+
call_id = msg.tool_call_id.split("|")[0]
|
|
376
|
+
tool_output = _convert_tool_result_output(model, msg.content)
|
|
377
|
+
if msg.tool_name in grammar_properties:
|
|
378
|
+
messages.append({"type": "custom_tool_call_output", "call_id": call_id, "output": tool_output})
|
|
379
|
+
else:
|
|
380
|
+
messages.append({"type": "function_call_output", "call_id": call_id, "output": tool_output})
|
|
381
|
+
if not is_leading_system_message:
|
|
382
|
+
msg_index += 1
|
|
383
|
+
if configuration_update_effort is not None:
|
|
384
|
+
push_configuration_update(configuration_update_effort)
|
|
385
|
+
|
|
386
|
+
return messages
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
# =============================================================================
|
|
390
|
+
# Tool conversion
|
|
391
|
+
# =============================================================================
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def convert_responses_tools(
|
|
395
|
+
tools: Sequence[Tool], options: ConvertResponsesToolsOptions | None = None
|
|
396
|
+
) -> list[dict[str, Any]]:
|
|
397
|
+
"""Convert pi tools to OpenAI Responses ``function``/``custom`` (grammar) tools."""
|
|
398
|
+
options = options or {}
|
|
399
|
+
default_strict = options.get("strict", False)
|
|
400
|
+
supports_strict_mode = options.get("supports_strict_mode")
|
|
401
|
+
if supports_strict_mode is None:
|
|
402
|
+
supports_strict_mode = True
|
|
403
|
+
supports_openai_grammar_tools = bool(options.get("supports_openai_grammar_tools"))
|
|
404
|
+
tool_search_result = bool(options.get("tool_search_result"))
|
|
405
|
+
|
|
406
|
+
result: list[dict[str, Any]] = []
|
|
407
|
+
for tool in tools:
|
|
408
|
+
grammar = resolve_grammar_constrained_sampling(tool, supports_openai_grammar_tools)
|
|
409
|
+
if grammar is not None:
|
|
410
|
+
custom_tool: dict[str, Any] = {
|
|
411
|
+
"type": "custom",
|
|
412
|
+
"name": tool.name,
|
|
413
|
+
"description": tool.description,
|
|
414
|
+
"format": {"type": "grammar", "syntax": grammar.format, "definition": grammar.definition},
|
|
415
|
+
}
|
|
416
|
+
if tool_search_result:
|
|
417
|
+
custom_tool["defer_loading"] = True
|
|
418
|
+
result.append(custom_tool)
|
|
419
|
+
continue
|
|
420
|
+
|
|
421
|
+
constrained_strict = resolve_json_schema_strict_sampling(tool, supports_strict_mode)
|
|
422
|
+
strict = constrained_strict if constrained_strict is not None else default_strict
|
|
423
|
+
function_tool: dict[str, Any] = {
|
|
424
|
+
"type": "function",
|
|
425
|
+
"name": tool.name,
|
|
426
|
+
"description": tool.description,
|
|
427
|
+
"parameters": get_json_schema_tool_parameters(tool, strict is True),
|
|
428
|
+
}
|
|
429
|
+
if tool_search_result:
|
|
430
|
+
function_tool["defer_loading"] = True
|
|
431
|
+
if supports_strict_mode:
|
|
432
|
+
function_tool["strict"] = strict
|
|
433
|
+
result.append(function_tool)
|
|
434
|
+
return result
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
# =============================================================================
|
|
438
|
+
# Stream processing
|
|
439
|
+
# =============================================================================
|
|
440
|
+
|
|
441
|
+
_SlotKind = Literal["thinking", "text", "toolCall"]
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
class _Slot:
|
|
445
|
+
"""Streaming state of one output item (scratch data never stored on the content block)."""
|
|
446
|
+
|
|
447
|
+
__slots__ = ("block", "content_index", "custom_buffer", "custom_property", "kind", "parser")
|
|
448
|
+
|
|
449
|
+
def __init__(self, kind: _SlotKind, block: Any, content_index: int) -> None:
|
|
450
|
+
self.kind = kind
|
|
451
|
+
self.block = block
|
|
452
|
+
self.content_index = content_index
|
|
453
|
+
#: Function-call argument parser; ``None`` once the call finished (pi's ``partialJson``).
|
|
454
|
+
self.parser: StreamingJsonParser | None = None
|
|
455
|
+
#: Grammar tool input property and JSON delta buffer; ``None`` once finished (pi's ``customInput``).
|
|
456
|
+
self.custom_property: str | None = None
|
|
457
|
+
self.custom_buffer: GrammarToolInputJsonBuffer | None = None
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _custom_tool_call_input(slot: _Slot) -> str:
|
|
461
|
+
if slot.custom_property is None:
|
|
462
|
+
return ""
|
|
463
|
+
value = slot.block.arguments.get(slot.custom_property)
|
|
464
|
+
return value if isinstance(value, str) else ""
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _append_custom_tool_call_input(slot: _Slot, next_input: str, close: bool) -> str | None:
|
|
468
|
+
buffer = slot.custom_buffer
|
|
469
|
+
prop = slot.custom_property
|
|
470
|
+
if buffer is None or prop is None:
|
|
471
|
+
return None
|
|
472
|
+
delta = append_grammar_tool_input_json_delta(buffer, prop, next_input, close)
|
|
473
|
+
slot.block.arguments = {prop: next_input}
|
|
474
|
+
return delta
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _js_template(event: Mapping[str, Any], key: str) -> str:
|
|
478
|
+
"""Render a field the way a JavaScript template literal would (missing -> ``undefined``)."""
|
|
479
|
+
if key not in event:
|
|
480
|
+
return "undefined"
|
|
481
|
+
value = event[key]
|
|
482
|
+
if value is None:
|
|
483
|
+
return "null"
|
|
484
|
+
if isinstance(value, bool):
|
|
485
|
+
return "true" if value else "false"
|
|
486
|
+
if isinstance(value, str):
|
|
487
|
+
return value
|
|
488
|
+
if isinstance(value, (int, float)):
|
|
489
|
+
return str(value)
|
|
490
|
+
return "[object Object]" if isinstance(value, dict) else ",".join(str(item) for item in value)
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
async def process_responses_stream(
|
|
494
|
+
events: AsyncIterable[Any],
|
|
495
|
+
output: AssistantMessage,
|
|
496
|
+
stream: AssistantMessageEventStream,
|
|
497
|
+
model: Model,
|
|
498
|
+
options: OpenAIResponsesStreamOptions | None = None,
|
|
499
|
+
) -> None:
|
|
500
|
+
"""Normalize OpenAI Responses stream events into ``output`` and push pi events to ``stream``.
|
|
501
|
+
|
|
502
|
+
Raises when the stream fails, ends before a terminal response event, or completes with an
|
|
503
|
+
unfinished tool call.
|
|
504
|
+
"""
|
|
505
|
+
options = options or {}
|
|
506
|
+
on_provider_stream_event = options.get("on_provider_stream_event")
|
|
507
|
+
grammar_properties = options.get("grammar_tool_input_properties") or {}
|
|
508
|
+
saw_terminal_response_event = False
|
|
509
|
+
output_slots: dict[Any, _Slot] = {}
|
|
510
|
+
reasoning_blocks_by_id: dict[Any, ThinkingContent] = {}
|
|
511
|
+
#: Tool-call blocks (by identity) whose ``output_item.done`` has not arrived yet.
|
|
512
|
+
unfinished_tool_calls: set[int] = set()
|
|
513
|
+
content = output.content
|
|
514
|
+
push = stream.push
|
|
515
|
+
|
|
516
|
+
def apply_message_phase_stop_reason(item: Mapping[str, Any]) -> None:
|
|
517
|
+
if item.get("type") == "message" and item.get("phase") == "final_answer":
|
|
518
|
+
output.stop_reason = "stop"
|
|
519
|
+
|
|
520
|
+
def get_slot(output_index: Any, kind: _SlotKind) -> _Slot | None:
|
|
521
|
+
slot = output_slots.get(output_index)
|
|
522
|
+
return slot if slot is not None and slot.kind == kind else None
|
|
523
|
+
|
|
524
|
+
def push_tool_call_delta(slot: _Slot, delta: str | None) -> None:
|
|
525
|
+
if delta is None:
|
|
526
|
+
return
|
|
527
|
+
push(ToolCallDeltaEvent(slot.content_index, delta, output))
|
|
528
|
+
|
|
529
|
+
def create_slot(output_index: Any, item: Mapping[str, Any]) -> _Slot | None:
|
|
530
|
+
item_type = item.get("type")
|
|
531
|
+
if item_type == "reasoning":
|
|
532
|
+
block: Any = ThinkingContent("")
|
|
533
|
+
content.append(block)
|
|
534
|
+
slot = _Slot("thinking", block, len(content) - 1)
|
|
535
|
+
output_slots[output_index] = slot
|
|
536
|
+
push(ThinkingStartEvent(slot.content_index, output))
|
|
537
|
+
return slot
|
|
538
|
+
if item_type == "message":
|
|
539
|
+
apply_message_phase_stop_reason(item)
|
|
540
|
+
block = TextContent("")
|
|
541
|
+
content.append(block)
|
|
542
|
+
slot = _Slot("text", block, len(content) - 1)
|
|
543
|
+
output_slots[output_index] = slot
|
|
544
|
+
push(TextStartEvent(slot.content_index, output))
|
|
545
|
+
return slot
|
|
546
|
+
if item_type == "function_call":
|
|
547
|
+
block = ToolCall(
|
|
548
|
+
f"{_js_template(item, 'call_id')}|{_js_template(item, 'id')}",
|
|
549
|
+
item.get("name"), # type: ignore[arg-type]
|
|
550
|
+
{},
|
|
551
|
+
namespace=item.get("namespace"),
|
|
552
|
+
)
|
|
553
|
+
content.append(block)
|
|
554
|
+
slot = _Slot("toolCall", block, len(content) - 1)
|
|
555
|
+
parser = StreamingJsonParser()
|
|
556
|
+
parser.feed(item.get("arguments") or "")
|
|
557
|
+
slot.parser = parser
|
|
558
|
+
unfinished_tool_calls.add(id(block))
|
|
559
|
+
output_slots[output_index] = slot
|
|
560
|
+
push(ToolCallStartEvent(slot.content_index, output))
|
|
561
|
+
return slot
|
|
562
|
+
if item_type == "custom_tool_call":
|
|
563
|
+
input_property = grammar_properties.get(item.get("name"), "input") # type: ignore[arg-type]
|
|
564
|
+
block = ToolCall(
|
|
565
|
+
f"{_js_template(item, 'call_id')}|{_js_template(item, 'id')}",
|
|
566
|
+
item.get("name"), # type: ignore[arg-type]
|
|
567
|
+
{input_property: item.get("input") or ""},
|
|
568
|
+
namespace=item.get("namespace"),
|
|
569
|
+
)
|
|
570
|
+
content.append(block)
|
|
571
|
+
slot = _Slot("toolCall", block, len(content) - 1)
|
|
572
|
+
slot.custom_property = input_property
|
|
573
|
+
slot.custom_buffer = GrammarToolInputJsonBuffer()
|
|
574
|
+
unfinished_tool_calls.add(id(block))
|
|
575
|
+
output_slots[output_index] = slot
|
|
576
|
+
push(ToolCallStartEvent(slot.content_index, output))
|
|
577
|
+
return slot
|
|
578
|
+
return None
|
|
579
|
+
|
|
580
|
+
def backfill_reasoning_signatures(response_output: Any) -> None:
|
|
581
|
+
# Azure OpenAI can omit reasoning.encrypted_content from response.output_item.done
|
|
582
|
+
# and provide it only in response.completed.response.output. Backfill the
|
|
583
|
+
# persisted reasoning signature from the terminal response to keep store:false
|
|
584
|
+
# multi-turn replay stateless. See https://github.com/earendil-works/pi/issues/6409.
|
|
585
|
+
for item in response_output or ():
|
|
586
|
+
if not isinstance(item, dict) or item.get("type") != "reasoning" or not item.get("encrypted_content"):
|
|
587
|
+
continue
|
|
588
|
+
block = reasoning_blocks_by_id.get(item.get("id"))
|
|
589
|
+
if block is None or not block.thinking_signature:
|
|
590
|
+
continue
|
|
591
|
+
stored_item = json.loads(block.thinking_signature)
|
|
592
|
+
if stored_item.get("encrypted_content"):
|
|
593
|
+
continue
|
|
594
|
+
block.thinking_signature = _json_stringify({**stored_item, "encrypted_content": item["encrypted_content"]})
|
|
595
|
+
|
|
596
|
+
def finalize_response(response: Any) -> None:
|
|
597
|
+
nonlocal saw_terminal_response_event
|
|
598
|
+
saw_terminal_response_event = True
|
|
599
|
+
response = response if isinstance(response, dict) else {}
|
|
600
|
+
backfill_reasoning_signatures(response.get("output"))
|
|
601
|
+
if response.get("id"):
|
|
602
|
+
output.response_id = response["id"]
|
|
603
|
+
usage = response.get("usage")
|
|
604
|
+
if usage:
|
|
605
|
+
input_details = usage.get("input_tokens_details") or {}
|
|
606
|
+
cached_tokens = input_details.get("cached_tokens") or 0
|
|
607
|
+
cache_write_tokens = input_details.get("cache_write_tokens") or 0
|
|
608
|
+
output_details = usage.get("output_tokens_details") or {}
|
|
609
|
+
output.usage = Usage(
|
|
610
|
+
# OpenAI includes cached and cache-write tokens in input_tokens, so subtract both.
|
|
611
|
+
input=max(0, (usage.get("input_tokens") or 0) - cached_tokens - cache_write_tokens),
|
|
612
|
+
output=usage.get("output_tokens") or 0,
|
|
613
|
+
cache_read=cached_tokens,
|
|
614
|
+
cache_write=cache_write_tokens,
|
|
615
|
+
total_tokens=usage.get("total_tokens") or 0,
|
|
616
|
+
reasoning=output_details.get("reasoning_tokens") or 0,
|
|
617
|
+
)
|
|
618
|
+
calculate_cost(model, output.usage)
|
|
619
|
+
apply_service_tier_pricing = options.get("apply_service_tier_pricing")
|
|
620
|
+
if apply_service_tier_pricing is not None:
|
|
621
|
+
resolve_service_tier = options.get("resolve_service_tier")
|
|
622
|
+
response_tier = response.get("service_tier")
|
|
623
|
+
request_tier = options.get("service_tier")
|
|
624
|
+
if resolve_service_tier is not None:
|
|
625
|
+
service_tier = resolve_service_tier(response_tier, request_tier)
|
|
626
|
+
else:
|
|
627
|
+
service_tier = response_tier if response_tier is not None else request_tier
|
|
628
|
+
apply_service_tier_pricing(output.usage, service_tier)
|
|
629
|
+
# Map status to stop reason. For incomplete responses, retain the provider's
|
|
630
|
+
# specific reason so max-output truncation and content filtering stay distinct.
|
|
631
|
+
status = response.get("status")
|
|
632
|
+
incomplete_details = response.get("incomplete_details")
|
|
633
|
+
reason = incomplete_details.get("reason") if isinstance(incomplete_details, dict) else None
|
|
634
|
+
incomplete_reason = reason if isinstance(reason, str) else None
|
|
635
|
+
output.raw_stop_reason = f"{status}.{incomplete_reason}" if incomplete_reason else status
|
|
636
|
+
stop_reason, error_message = _map_stop_reason(status, incomplete_reason)
|
|
637
|
+
output.stop_reason = stop_reason
|
|
638
|
+
output.error_message = error_message
|
|
639
|
+
if output.stop_reason == "stop" and any(block.type == "toolCall" for block in content):
|
|
640
|
+
output.stop_reason = "toolUse"
|
|
641
|
+
|
|
642
|
+
async for event in events:
|
|
643
|
+
if on_provider_stream_event is not None:
|
|
644
|
+
await maybe_await(on_provider_stream_event(event, model))
|
|
645
|
+
if not isinstance(event, dict):
|
|
646
|
+
continue
|
|
647
|
+
event_type = event.get("type")
|
|
648
|
+
if event_type == "response.created":
|
|
649
|
+
response = event.get("response")
|
|
650
|
+
output.response_id = response.get("id") if isinstance(response, dict) else None
|
|
651
|
+
elif event_type == "response.output_item.added":
|
|
652
|
+
item = event.get("item")
|
|
653
|
+
if isinstance(item, dict):
|
|
654
|
+
create_slot(event.get("output_index"), item)
|
|
655
|
+
elif event_type in (
|
|
656
|
+
"response.reasoning_summary_text.delta",
|
|
657
|
+
"response.reasoning_text.delta",
|
|
658
|
+
):
|
|
659
|
+
slot = get_slot(event.get("output_index"), "thinking")
|
|
660
|
+
if slot is None:
|
|
661
|
+
continue
|
|
662
|
+
delta = event.get("delta") or ""
|
|
663
|
+
append_thinking(slot.block, delta)
|
|
664
|
+
push(ThinkingDeltaEvent(slot.content_index, delta, output))
|
|
665
|
+
elif event_type == "response.reasoning_summary_part.done":
|
|
666
|
+
slot = get_slot(event.get("output_index"), "thinking")
|
|
667
|
+
if slot is None:
|
|
668
|
+
continue
|
|
669
|
+
append_thinking(slot.block, "\n\n")
|
|
670
|
+
push(ThinkingDeltaEvent(slot.content_index, "\n\n", output))
|
|
671
|
+
elif event_type in ("response.output_text.delta", "response.refusal.delta"):
|
|
672
|
+
slot = get_slot(event.get("output_index"), "text")
|
|
673
|
+
if slot is None:
|
|
674
|
+
continue
|
|
675
|
+
delta = event.get("delta") or ""
|
|
676
|
+
append_text(slot.block, delta)
|
|
677
|
+
push(TextDeltaEvent(slot.content_index, delta, output))
|
|
678
|
+
elif event_type == "response.function_call_arguments.delta":
|
|
679
|
+
slot = get_slot(event.get("output_index"), "toolCall")
|
|
680
|
+
if slot is None or slot.parser is None:
|
|
681
|
+
continue
|
|
682
|
+
delta = event.get("delta") or ""
|
|
683
|
+
slot.parser.feed(delta)
|
|
684
|
+
slot.block.arguments = slot.parser.value()
|
|
685
|
+
push_tool_call_delta(slot, delta)
|
|
686
|
+
elif event_type == "response.function_call_arguments.done":
|
|
687
|
+
slot = get_slot(event.get("output_index"), "toolCall")
|
|
688
|
+
if slot is None or slot.parser is None:
|
|
689
|
+
continue
|
|
690
|
+
final_arguments = event.get("arguments") or ""
|
|
691
|
+
previous_partial_json = slot.parser.text
|
|
692
|
+
if final_arguments.startswith(previous_partial_json):
|
|
693
|
+
delta = final_arguments[len(previous_partial_json) :]
|
|
694
|
+
slot.parser.feed(delta)
|
|
695
|
+
slot.block.arguments = slot.parser.value()
|
|
696
|
+
if delta:
|
|
697
|
+
push_tool_call_delta(slot, delta)
|
|
698
|
+
else:
|
|
699
|
+
parser = StreamingJsonParser()
|
|
700
|
+
parser.feed(final_arguments)
|
|
701
|
+
slot.parser = parser
|
|
702
|
+
slot.block.arguments = parser.value()
|
|
703
|
+
elif event_type == "response.custom_tool_call_input.delta":
|
|
704
|
+
slot = get_slot(event.get("output_index"), "toolCall")
|
|
705
|
+
if slot is None or slot.custom_buffer is None:
|
|
706
|
+
continue
|
|
707
|
+
push_tool_call_delta(
|
|
708
|
+
slot,
|
|
709
|
+
_append_custom_tool_call_input(slot, _custom_tool_call_input(slot) + (event.get("delta") or ""), False),
|
|
710
|
+
)
|
|
711
|
+
elif event_type == "response.custom_tool_call_input.done":
|
|
712
|
+
slot = get_slot(event.get("output_index"), "toolCall")
|
|
713
|
+
if slot is None or slot.custom_buffer is None:
|
|
714
|
+
continue
|
|
715
|
+
push_tool_call_delta(slot, _append_custom_tool_call_input(slot, event.get("input") or "", True))
|
|
716
|
+
elif event_type == "response.output_item.done":
|
|
717
|
+
item = event.get("item")
|
|
718
|
+
if not isinstance(item, dict):
|
|
719
|
+
continue
|
|
720
|
+
output_index = event.get("output_index")
|
|
721
|
+
apply_message_phase_stop_reason(item)
|
|
722
|
+
slot = output_slots.get(output_index) or create_slot(output_index, item)
|
|
723
|
+
item_type = item.get("type")
|
|
724
|
+
|
|
725
|
+
if item_type == "reasoning" and slot is not None and slot.kind == "thinking":
|
|
726
|
+
block = slot.block
|
|
727
|
+
summary_text = "\n\n".join(_text_of(part) for part in item.get("summary") or ())
|
|
728
|
+
content_text = "\n\n".join(_text_of(part) for part in item.get("content") or ())
|
|
729
|
+
block.thinking = summary_text or content_text or block.thinking
|
|
730
|
+
block.thinking_signature = _json_stringify(item)
|
|
731
|
+
reasoning_blocks_by_id[item.get("id")] = block
|
|
732
|
+
push(ThinkingEndEvent(slot.content_index, block.thinking, output))
|
|
733
|
+
del output_slots[output_index]
|
|
734
|
+
elif item_type == "message" and slot is not None and slot.kind == "text":
|
|
735
|
+
block = slot.block
|
|
736
|
+
block.text = "".join(
|
|
737
|
+
(part.get("text") if part.get("type") == "output_text" else part.get("refusal")) or ""
|
|
738
|
+
for part in item.get("content") or ()
|
|
739
|
+
if isinstance(part, dict)
|
|
740
|
+
)
|
|
741
|
+
block.text_signature = _encode_text_signature_v1(item.get("id"), item.get("phase"))
|
|
742
|
+
push(TextEndEvent(slot.content_index, block.text, output))
|
|
743
|
+
del output_slots[output_index]
|
|
744
|
+
elif (
|
|
745
|
+
item_type == "function_call"
|
|
746
|
+
and slot is not None
|
|
747
|
+
and slot.kind == "toolCall"
|
|
748
|
+
and slot.parser is not None
|
|
749
|
+
):
|
|
750
|
+
block = slot.block
|
|
751
|
+
arguments, arguments_error = parse_tool_call_arguments(item.get("arguments") or slot.parser.text)
|
|
752
|
+
block.arguments = arguments
|
|
753
|
+
if arguments_error is not None:
|
|
754
|
+
block.arguments_error = arguments_error
|
|
755
|
+
if item.get("namespace") is not None:
|
|
756
|
+
block.namespace = item["namespace"]
|
|
757
|
+
# Finalize in-place and drop the scratch buffer so replay only carries parsed arguments.
|
|
758
|
+
slot.parser = None
|
|
759
|
+
unfinished_tool_calls.discard(id(block))
|
|
760
|
+
push(ToolCallEndEvent(slot.content_index, block, output))
|
|
761
|
+
del output_slots[output_index]
|
|
762
|
+
elif (
|
|
763
|
+
item_type == "custom_tool_call"
|
|
764
|
+
and slot is not None
|
|
765
|
+
and slot.kind == "toolCall"
|
|
766
|
+
and slot.custom_buffer is not None
|
|
767
|
+
):
|
|
768
|
+
block = slot.block
|
|
769
|
+
final_input = item.get("input")
|
|
770
|
+
push_tool_call_delta(
|
|
771
|
+
slot,
|
|
772
|
+
_append_custom_tool_call_input(
|
|
773
|
+
slot, final_input if final_input is not None else _custom_tool_call_input(slot), True
|
|
774
|
+
),
|
|
775
|
+
)
|
|
776
|
+
if item.get("namespace") is not None:
|
|
777
|
+
block.namespace = item["namespace"]
|
|
778
|
+
slot.custom_buffer = None
|
|
779
|
+
slot.custom_property = None
|
|
780
|
+
unfinished_tool_calls.discard(id(block))
|
|
781
|
+
push(ToolCallEndEvent(slot.content_index, block, output))
|
|
782
|
+
del output_slots[output_index]
|
|
783
|
+
elif event_type in ("response.completed", "response.incomplete"):
|
|
784
|
+
finalize_response(event.get("response"))
|
|
785
|
+
elif event_type == "error":
|
|
786
|
+
raise RuntimeError(f"Error Code {_js_template(event, 'code')}: {_js_template(event, 'message')}")
|
|
787
|
+
elif event_type == "response.failed":
|
|
788
|
+
saw_terminal_response_event = True
|
|
789
|
+
response = event.get("response")
|
|
790
|
+
response = response if isinstance(response, dict) else {}
|
|
791
|
+
output.raw_stop_reason = response.get("status")
|
|
792
|
+
error = response.get("error")
|
|
793
|
+
details = response.get("incomplete_details")
|
|
794
|
+
if error:
|
|
795
|
+
message = f"{error.get('code') or 'unknown'}: {error.get('message') or 'no message'}"
|
|
796
|
+
elif isinstance(details, dict) and details.get("reason"):
|
|
797
|
+
message = f"incomplete: {details['reason']}"
|
|
798
|
+
else:
|
|
799
|
+
message = "Unknown error (no error details in response)"
|
|
800
|
+
raise RuntimeError(message)
|
|
801
|
+
|
|
802
|
+
if not saw_terminal_response_event:
|
|
803
|
+
raise RuntimeError("OpenAI Responses stream ended before a terminal response event")
|
|
804
|
+
# The agent runs every tool call in the final message. Refuse to hand over calls whose
|
|
805
|
+
# output_item.done never arrived: their arguments may be cut off or mixed up, e.g. when a
|
|
806
|
+
# non-compliant server omits output_index. Finished calls have their scratch buffers removed.
|
|
807
|
+
if output.stop_reason == "toolUse":
|
|
808
|
+
for block in content:
|
|
809
|
+
if block.type == "toolCall" and id(block) in unfinished_tool_calls:
|
|
810
|
+
raise RuntimeError(
|
|
811
|
+
f"OpenAI Responses stream completed with an unfinished tool call: {block.name} ({block.id})"
|
|
812
|
+
)
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
def _text_of(part: Any) -> str:
|
|
816
|
+
if isinstance(part, dict):
|
|
817
|
+
text = part.get("text")
|
|
818
|
+
return text if isinstance(text, str) else ""
|
|
819
|
+
return ""
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
def _map_stop_reason(status: Any, incomplete_reason: str | None = None) -> tuple[StopReason, str | None]:
|
|
823
|
+
if not status:
|
|
824
|
+
return "stop", None
|
|
825
|
+
if status == "completed":
|
|
826
|
+
return "stop", None
|
|
827
|
+
if status == "incomplete":
|
|
828
|
+
if incomplete_reason == "max_output_tokens":
|
|
829
|
+
return "length", None
|
|
830
|
+
return "error", (
|
|
831
|
+
f"Response incomplete: {incomplete_reason}"
|
|
832
|
+
if incomplete_reason
|
|
833
|
+
else "Response incomplete without a provider reason"
|
|
834
|
+
)
|
|
835
|
+
if status in ("failed", "cancelled"):
|
|
836
|
+
return "error", None
|
|
837
|
+
# These two are wonky ...
|
|
838
|
+
if status in ("in_progress", "queued"):
|
|
839
|
+
return "stop", None
|
|
840
|
+
raise RuntimeError(f"Unhandled stop reason: {status}")
|