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,1594 @@
|
|
|
1
|
+
# pyright: reportAttributeAccessIssue=false, reportArgumentType=false
|
|
2
|
+
# Converters branch on ``role``/``type`` discriminators held in locals, which pyright does not narrow.
|
|
3
|
+
"""OpenAI Chat Completions API (``openai-completions``).
|
|
4
|
+
|
|
5
|
+
Port of pi's ``api/openai-completions.ts``. Streams ``chat.completions.create`` through
|
|
6
|
+
the official ``openai`` SDK (``AsyncOpenAI``) and covers the many OpenAI-compatible
|
|
7
|
+
servers through ``model.compat`` (auto-detected from the provider id and base URL
|
|
8
|
+
when unset).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
from collections.abc import Mapping, Sequence
|
|
16
|
+
from typing import Any, Literal, TypeAlias, TypedDict
|
|
17
|
+
|
|
18
|
+
from ..abort import AbortError, abort_scope
|
|
19
|
+
from ..event_stream import AssistantMessageEventStream
|
|
20
|
+
from ..models import calculate_cost, clamp_thinking_level
|
|
21
|
+
from ..types import (
|
|
22
|
+
AssistantMessage,
|
|
23
|
+
DoneEvent,
|
|
24
|
+
ErrorEvent,
|
|
25
|
+
ImageContent,
|
|
26
|
+
Message,
|
|
27
|
+
Model,
|
|
28
|
+
ProviderResponse,
|
|
29
|
+
StartEvent,
|
|
30
|
+
StreamOptions,
|
|
31
|
+
TextContent,
|
|
32
|
+
TextDeltaEvent,
|
|
33
|
+
TextEndEvent,
|
|
34
|
+
TextStartEvent,
|
|
35
|
+
ThinkingBudgets,
|
|
36
|
+
ThinkingContent,
|
|
37
|
+
ThinkingDeltaEvent,
|
|
38
|
+
ThinkingEndEvent,
|
|
39
|
+
ThinkingStartEvent,
|
|
40
|
+
Tool,
|
|
41
|
+
ToolCall,
|
|
42
|
+
ToolCallDeltaEvent,
|
|
43
|
+
ToolCallEndEvent,
|
|
44
|
+
ToolCallStartEvent,
|
|
45
|
+
ToolResultMessage,
|
|
46
|
+
TranscriptContext,
|
|
47
|
+
Usage,
|
|
48
|
+
)
|
|
49
|
+
from ..utils import fastjson
|
|
50
|
+
from ..utils.callbacks import maybe_await
|
|
51
|
+
from ..utils.error_body import format_provider_error, normalize_provider_error
|
|
52
|
+
from ..utils.json_parse import StreamingJsonParser, parse_tool_call_arguments
|
|
53
|
+
from ..utils.misc import get_pi_user_agent, get_provider_env_value, headers_to_record, sanitize_surrogates, short_hash
|
|
54
|
+
from ..utils.text import append_text, append_thinking, get_system_message_text, render_system_message_update
|
|
55
|
+
from ..utils.transcript import get_declared_tools, resolve_transcript, resolve_transcript_tools
|
|
56
|
+
from .constrained_sampling import (
|
|
57
|
+
GrammarToolInputJsonBuffer,
|
|
58
|
+
append_grammar_tool_input_json_delta,
|
|
59
|
+
create_grammar_tool_input_properties,
|
|
60
|
+
get_grammar_tool_input,
|
|
61
|
+
get_json_schema_tool_parameters,
|
|
62
|
+
resolve_grammar_constrained_sampling,
|
|
63
|
+
resolve_json_schema_strict_sampling,
|
|
64
|
+
)
|
|
65
|
+
from .github_copilot_headers import build_copilot_dynamic_headers, has_copilot_vision_input
|
|
66
|
+
from .openai_prompt_cache import clamp_openai_prompt_cache_key
|
|
67
|
+
from .openai_sdk import (
|
|
68
|
+
create_openai_client,
|
|
69
|
+
iter_sdk_stream,
|
|
70
|
+
merge_sdk_headers,
|
|
71
|
+
open_sdk_stream,
|
|
72
|
+
openai_request,
|
|
73
|
+
sdk_body_arguments,
|
|
74
|
+
)
|
|
75
|
+
from .simple_options import (
|
|
76
|
+
build_base_options,
|
|
77
|
+
clamp_thinking_budget_to_answer_room,
|
|
78
|
+
resolve_sampling_params,
|
|
79
|
+
thinking_budget_for_level,
|
|
80
|
+
)
|
|
81
|
+
from .transform_messages import transform_messages
|
|
82
|
+
|
|
83
|
+
__all__ = [
|
|
84
|
+
"OpenAICompletionsOptions",
|
|
85
|
+
"ConvertCompletionsMessagesOptions",
|
|
86
|
+
"ResolvedOpenAICompletionsCompat",
|
|
87
|
+
"stream",
|
|
88
|
+
"stream_simple",
|
|
89
|
+
"build_params",
|
|
90
|
+
"convert_messages",
|
|
91
|
+
"convert_tools",
|
|
92
|
+
"detect_compat",
|
|
93
|
+
"get_compat",
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class OpenAICompletionsOptions(StreamOptions, total=False):
|
|
98
|
+
"""Options for :func:`stream` (pi's ``OpenAICompletionsOptions``)."""
|
|
99
|
+
|
|
100
|
+
#: ``"auto"``, ``"none"``, ``"required"`` or a ``{"type": "function", ...}`` object.
|
|
101
|
+
tool_choice: str | dict[str, Any]
|
|
102
|
+
reasoning_effort: Literal["minimal", "low", "medium", "high", "xhigh", "max"]
|
|
103
|
+
#: Token budgets per thinking level. Used when ``compat.thinkingTokenBudgetField`` or
|
|
104
|
+
#: ``compat.supportsThinkingTokenBudget`` is set, or by ``{"$var": "thinking.budget"}``.
|
|
105
|
+
thinking_budgets: ThinkingBudgets
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class ConvertCompletionsMessagesOptions(TypedDict, total=False):
|
|
109
|
+
#: Grammar tool name -> its single string input property.
|
|
110
|
+
grammar_tool_input_properties: Mapping[str, str]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
#: Fully resolved ``OpenAICompletionsCompat`` (camelCase keys, as in pi).
|
|
114
|
+
ResolvedOpenAICompletionsCompat: TypeAlias = dict[str, Any]
|
|
115
|
+
|
|
116
|
+
_REASONING_FIELDS = ("reasoning_content", "reasoning", "reasoning_text")
|
|
117
|
+
_REPLAY_REASONING_FIELDS = frozenset(("reasoning", "reasoning_content", "reasoning_text"))
|
|
118
|
+
# Characters removed by JavaScript's String.prototype.trim().
|
|
119
|
+
_JS_WHITESPACE = (
|
|
120
|
+
"\t\n\v\f\r \u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005"
|
|
121
|
+
"\u2006\u2007\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000\ufeff"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class _Undefined:
|
|
126
|
+
"""JavaScript ``undefined``: distinct from JSON ``null`` (``None``)."""
|
|
127
|
+
|
|
128
|
+
__slots__ = ()
|
|
129
|
+
|
|
130
|
+
def __repr__(self) -> str:
|
|
131
|
+
return "undefined"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
_UNDEFINED: Any = _Undefined()
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# ---------------------------------------------------------------------------
|
|
138
|
+
# Small JavaScript-semantics helpers
|
|
139
|
+
# ---------------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _truthy(value: Any) -> bool:
|
|
143
|
+
"""JavaScript truthiness (empty objects and arrays are truthy)."""
|
|
144
|
+
if value is None or value is False or value is _UNDEFINED:
|
|
145
|
+
return False
|
|
146
|
+
if isinstance(value, str):
|
|
147
|
+
return value != ""
|
|
148
|
+
if isinstance(value, (int, float)):
|
|
149
|
+
return value != 0 and value == value
|
|
150
|
+
return True
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _is_number(value: Any) -> bool:
|
|
154
|
+
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _prop(obj: Any, key: str) -> Any:
|
|
158
|
+
"""``obj?.[key]`` for parsed JSON (``None`` for missing keys and non-objects)."""
|
|
159
|
+
return obj.get(key) if isinstance(obj, dict) else None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _js_trim(text: str) -> str:
|
|
163
|
+
return text.strip(_JS_WHITESPACE)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _js_string(value: Any) -> str:
|
|
167
|
+
"""JavaScript ``String(value)`` for parsed JSON values."""
|
|
168
|
+
if isinstance(value, str):
|
|
169
|
+
return value
|
|
170
|
+
if value is None:
|
|
171
|
+
return "null"
|
|
172
|
+
if isinstance(value, bool):
|
|
173
|
+
return "true" if value else "false"
|
|
174
|
+
if isinstance(value, float) and value.is_integer():
|
|
175
|
+
return str(int(value))
|
|
176
|
+
if isinstance(value, (int, float)):
|
|
177
|
+
return str(value)
|
|
178
|
+
if isinstance(value, list):
|
|
179
|
+
return ",".join("" if item is None else _js_string(item) for item in value)
|
|
180
|
+
return "[object Object]"
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _json_compact(value: Any) -> str:
|
|
184
|
+
"""``JSON.stringify(value)``."""
|
|
185
|
+
try:
|
|
186
|
+
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
|
187
|
+
except (TypeError, ValueError):
|
|
188
|
+
return str(value)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _level_value(model: Model, level: str | None) -> Any:
|
|
192
|
+
"""``model.thinkingLevelMap?.[level]``: ``_UNDEFINED`` when absent, ``None`` for an explicit null."""
|
|
193
|
+
level_map = model.thinking_level_map
|
|
194
|
+
if level_map is None or level is None or level not in level_map:
|
|
195
|
+
return _UNDEFINED
|
|
196
|
+
return level_map[level]
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _level_value_or(model: Model, level: str | None, fallback: Any) -> Any:
|
|
200
|
+
"""``model.thinkingLevelMap?.[level] ?? fallback``."""
|
|
201
|
+
value = _level_value(model, level)
|
|
202
|
+
return fallback if value is None or value is _UNDEFINED else value
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _has_nonblank_header(headers: Mapping[str, Any] | None, name: str) -> bool:
|
|
206
|
+
if not headers:
|
|
207
|
+
return False
|
|
208
|
+
expected = name.lower()
|
|
209
|
+
return any(
|
|
210
|
+
key.lower() == expected and isinstance(value, str) and bool(_js_trim(value)) for key, value in headers.items()
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _get_client_api_key(provider: str, api_key: str | None, headers: Mapping[str, Any] | None) -> str:
|
|
215
|
+
if api_key:
|
|
216
|
+
return api_key
|
|
217
|
+
if _has_nonblank_header(headers, "authorization") or _has_nonblank_header(headers, "cf-aig-authorization"):
|
|
218
|
+
return "unused"
|
|
219
|
+
raise RuntimeError(f"No API key for provider: {provider}")
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _has_tool_history(messages: Sequence[Message]) -> bool:
|
|
223
|
+
"""Whether the transcript contains tool calls or tool results.
|
|
224
|
+
|
|
225
|
+
Anthropic (via a LiteLLM-style proxy) requires the ``tools`` param when the
|
|
226
|
+
conversation includes tool calls or tool role messages.
|
|
227
|
+
"""
|
|
228
|
+
for message in messages:
|
|
229
|
+
role = message.role
|
|
230
|
+
if role == "toolResult":
|
|
231
|
+
return True
|
|
232
|
+
if role == "assistant" and any(block.type == "toolCall" for block in message.content):
|
|
233
|
+
return True
|
|
234
|
+
return False
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _resolve_cache_retention(cache_retention: str | None, env: Mapping[str, str] | None) -> str:
|
|
238
|
+
if cache_retention:
|
|
239
|
+
return cache_retention
|
|
240
|
+
if get_provider_env_value("PI_CACHE_RETENTION", env) == "long":
|
|
241
|
+
return "long"
|
|
242
|
+
return "short"
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
# ---------------------------------------------------------------------------
|
|
246
|
+
# reasoning_details (OpenRouter-style structured reasoning replay data)
|
|
247
|
+
# ---------------------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _is_reasoning_detail(detail: Any) -> bool:
|
|
251
|
+
if not isinstance(detail, dict):
|
|
252
|
+
return False
|
|
253
|
+
if "id" in detail and detail["id"] is not None and not isinstance(detail["id"], str):
|
|
254
|
+
return False
|
|
255
|
+
if "format" in detail and not isinstance(detail["format"], str):
|
|
256
|
+
return False
|
|
257
|
+
if "index" in detail and not _is_number(detail["index"]):
|
|
258
|
+
return False
|
|
259
|
+
kind = detail.get("type")
|
|
260
|
+
if kind == "reasoning.summary":
|
|
261
|
+
return isinstance(detail.get("summary"), str)
|
|
262
|
+
if kind == "reasoning.encrypted":
|
|
263
|
+
return isinstance(detail.get("data"), str)
|
|
264
|
+
if kind == "reasoning.text":
|
|
265
|
+
signature = detail.get("signature")
|
|
266
|
+
return isinstance(detail.get("text"), str) and (signature is None or isinstance(signature, str))
|
|
267
|
+
return False
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _parse_reasoning_details(signature: str | None) -> list[dict[str, Any]] | None:
|
|
271
|
+
if not signature:
|
|
272
|
+
return None
|
|
273
|
+
try:
|
|
274
|
+
parsed = json.loads(signature)
|
|
275
|
+
except (TypeError, ValueError):
|
|
276
|
+
return None
|
|
277
|
+
if isinstance(parsed, list) and parsed and all(_is_reasoning_detail(detail) for detail in parsed):
|
|
278
|
+
return parsed
|
|
279
|
+
return None
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _parse_legacy_encrypted_reasoning_detail(signature: str | None) -> dict[str, Any] | None:
|
|
283
|
+
if not signature:
|
|
284
|
+
return None
|
|
285
|
+
try:
|
|
286
|
+
parsed = json.loads(signature)
|
|
287
|
+
except (TypeError, ValueError):
|
|
288
|
+
return None
|
|
289
|
+
if (
|
|
290
|
+
_is_reasoning_detail(parsed)
|
|
291
|
+
and parsed["type"] == "reasoning.encrypted"
|
|
292
|
+
and isinstance(parsed.get("id"), str)
|
|
293
|
+
and len(parsed["id"]) > 0
|
|
294
|
+
and len(parsed["data"]) > 0
|
|
295
|
+
):
|
|
296
|
+
return parsed
|
|
297
|
+
return None
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _assign_from(target: dict[str, Any], key: str, source: Mapping[str, Any]) -> None:
|
|
301
|
+
# ``target[key] = source[key]`` where a missing source key assigns ``undefined``.
|
|
302
|
+
target[key] = source.get(key, _UNDEFINED)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _nullish(value: Any) -> bool:
|
|
306
|
+
return value is None or value is _UNDEFINED
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _fill_missing_common_fields(target: dict[str, Any], source: Mapping[str, Any]) -> None:
|
|
310
|
+
if _nullish(target.get("id")):
|
|
311
|
+
_assign_from(target, "id", source)
|
|
312
|
+
if not _truthy(target.get("format")):
|
|
313
|
+
_assign_from(target, "format", source)
|
|
314
|
+
if _nullish(target.get("index")):
|
|
315
|
+
_assign_from(target, "index", source)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _append_reasoning_detail(details: list[dict[str, Any]], detail: dict[str, Any]) -> None:
|
|
319
|
+
last = details[-1] if details else None
|
|
320
|
+
kind = detail["type"]
|
|
321
|
+
if kind == "reasoning.text" and last is not None and last.get("type") == "reasoning.text":
|
|
322
|
+
last["text"] += detail["text"]
|
|
323
|
+
if not _truthy(last.get("signature")):
|
|
324
|
+
_assign_from(last, "signature", detail)
|
|
325
|
+
_fill_missing_common_fields(last, detail)
|
|
326
|
+
return
|
|
327
|
+
if kind == "reasoning.summary" and last is not None and last.get("type") == "reasoning.summary":
|
|
328
|
+
last["summary"] += detail["summary"]
|
|
329
|
+
_fill_missing_common_fields(last, detail)
|
|
330
|
+
return
|
|
331
|
+
details.append(dict(detail))
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _serialize_reasoning_details(details: list[dict[str, Any]]) -> str:
|
|
335
|
+
# JSON.stringify drops ``undefined`` members but keeps the key order of the rest.
|
|
336
|
+
return fastjson.dumps([{key: value for key, value in d.items() if value is not _UNDEFINED} for d in details])
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
# ---------------------------------------------------------------------------
|
|
340
|
+
# Public entry points
|
|
341
|
+
# ---------------------------------------------------------------------------
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def stream(
|
|
345
|
+
model: Model, context: TranscriptContext, options: Mapping[str, Any] | None = None
|
|
346
|
+
) -> AssistantMessageEventStream:
|
|
347
|
+
"""Stream a Chat Completions response as pi assistant message events."""
|
|
348
|
+
options = dict(options or {})
|
|
349
|
+
out = AssistantMessageEventStream()
|
|
350
|
+
normalized = resolve_transcript(context, get_compat(model).get("supportsMidConvoSystemMessages"))
|
|
351
|
+
out.start(_run(model, normalized, options, out))
|
|
352
|
+
return out
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def stream_simple(
|
|
356
|
+
model: Model, context: TranscriptContext, options: Mapping[str, Any] | None = None
|
|
357
|
+
) -> AssistantMessageEventStream:
|
|
358
|
+
"""Stream with provider-neutral options (``reasoning``, ``tool_choice``, ``thinking_budgets``).
|
|
359
|
+
|
|
360
|
+
Raises ``RuntimeError`` synchronously when no API key (or authorization header) is given.
|
|
361
|
+
"""
|
|
362
|
+
options = options or {}
|
|
363
|
+
_get_client_api_key(model.provider, options.get("api_key"), options.get("headers"))
|
|
364
|
+
base = build_base_options(model, context, options, options.get("api_key"))
|
|
365
|
+
tool_choice = options.get("tool_choice")
|
|
366
|
+
if tool_choice is not None:
|
|
367
|
+
base["tool_choice"] = tool_choice
|
|
368
|
+
reasoning = options.get("reasoning")
|
|
369
|
+
clamped = clamp_thinking_level(model, reasoning) if reasoning else None
|
|
370
|
+
if clamped and clamped != "off":
|
|
371
|
+
base["reasoning_effort"] = clamped
|
|
372
|
+
thinking_budgets = options.get("thinking_budgets")
|
|
373
|
+
if thinking_budgets is not None:
|
|
374
|
+
base["thinking_budgets"] = thinking_budgets
|
|
375
|
+
return stream(model, context, base)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
# ---------------------------------------------------------------------------
|
|
379
|
+
# Streaming
|
|
380
|
+
# ---------------------------------------------------------------------------
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _build_request_headers(
|
|
384
|
+
model: Model,
|
|
385
|
+
context: TranscriptContext,
|
|
386
|
+
options_headers: Mapping[str, str | None] | None,
|
|
387
|
+
session_id: str | None,
|
|
388
|
+
compat: ResolvedOpenAICompletionsCompat,
|
|
389
|
+
) -> dict[str, str | None]:
|
|
390
|
+
"""The client's ``default_headers``: ``None`` removes a header, SDK defaults included."""
|
|
391
|
+
copilot_headers: dict[str, str] | None = None
|
|
392
|
+
if model.provider == "github-copilot":
|
|
393
|
+
messages = context.messages
|
|
394
|
+
copilot_headers = build_copilot_dynamic_headers(
|
|
395
|
+
messages=messages, has_images=has_copilot_vision_input(messages)
|
|
396
|
+
)
|
|
397
|
+
session_headers: dict[str, str] = {}
|
|
398
|
+
if session_id and compat.get("sendSessionAffinityHeaders"):
|
|
399
|
+
if compat.get("sessionAffinityFormat") == "openrouter":
|
|
400
|
+
session_headers["x-session-id"] = session_id
|
|
401
|
+
else:
|
|
402
|
+
if compat.get("sessionAffinityFormat") == "openai":
|
|
403
|
+
session_headers["session_id"] = session_id
|
|
404
|
+
session_headers["x-client-request-id"] = session_id
|
|
405
|
+
session_headers["x-session-affinity"] = session_id
|
|
406
|
+
# Options headers last so they can override defaults.
|
|
407
|
+
return merge_sdk_headers(
|
|
408
|
+
{"User-Agent": get_pi_user_agent()}, model.headers, copilot_headers, session_headers, options_headers
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
class _CustomInput:
|
|
413
|
+
__slots__ = ("buffer", "property")
|
|
414
|
+
|
|
415
|
+
def __init__(self, property: str) -> None:
|
|
416
|
+
self.property = property
|
|
417
|
+
self.buffer = GrammarToolInputJsonBuffer()
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
class _StreamProcessor:
|
|
421
|
+
"""Normalizes Chat Completions chunks into ``output`` and pushes pi events.
|
|
422
|
+
|
|
423
|
+
Streaming scratch state (argument parsers, grammar input buffers, stream
|
|
424
|
+
indexes) is keyed by block identity so the content blocks stay clean.
|
|
425
|
+
"""
|
|
426
|
+
|
|
427
|
+
__slots__ = (
|
|
428
|
+
"custom_inputs",
|
|
429
|
+
"grammar_properties",
|
|
430
|
+
"has_finish_reason",
|
|
431
|
+
"index_of",
|
|
432
|
+
"model",
|
|
433
|
+
"out",
|
|
434
|
+
"output",
|
|
435
|
+
"parsers",
|
|
436
|
+
"reasoning_details",
|
|
437
|
+
"stream_indexes",
|
|
438
|
+
"text_block",
|
|
439
|
+
"thinking_block",
|
|
440
|
+
"tool_by_id",
|
|
441
|
+
"tool_by_index",
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
def __init__(
|
|
445
|
+
self,
|
|
446
|
+
model: Model,
|
|
447
|
+
out: AssistantMessageEventStream,
|
|
448
|
+
output: AssistantMessage,
|
|
449
|
+
grammar_properties: Mapping[str, str],
|
|
450
|
+
) -> None:
|
|
451
|
+
self.model = model
|
|
452
|
+
self.out = out
|
|
453
|
+
self.output = output
|
|
454
|
+
self.grammar_properties = grammar_properties
|
|
455
|
+
self.text_block: TextContent | None = None
|
|
456
|
+
self.thinking_block: ThinkingContent | None = None
|
|
457
|
+
self.has_finish_reason = False
|
|
458
|
+
self.tool_by_index: dict[Any, ToolCall] = {}
|
|
459
|
+
self.tool_by_id: dict[Any, ToolCall] = {}
|
|
460
|
+
self.index_of: dict[int, int] = {}
|
|
461
|
+
self.parsers: dict[int, StreamingJsonParser] = {}
|
|
462
|
+
self.custom_inputs: dict[int, _CustomInput] = {}
|
|
463
|
+
self.stream_indexes: dict[int, Any] = {}
|
|
464
|
+
# ``reasoning_details`` are replay metadata, not user-visible stream deltas. Keep
|
|
465
|
+
# them in memory during streaming and serialize once when the block is finalized.
|
|
466
|
+
self.reasoning_details: list[dict[str, Any]] | None = None
|
|
467
|
+
|
|
468
|
+
# -- blocks ---------------------------------------------------------------
|
|
469
|
+
|
|
470
|
+
def _add_block(self, block: Any) -> int:
|
|
471
|
+
content = self.output.content
|
|
472
|
+
index = len(content)
|
|
473
|
+
content.append(block)
|
|
474
|
+
self.index_of[id(block)] = index
|
|
475
|
+
return index
|
|
476
|
+
|
|
477
|
+
def apply_reasoning_details(self, block: ThinkingContent) -> None:
|
|
478
|
+
if self.reasoning_details is not None:
|
|
479
|
+
block.thinking_signature = _serialize_reasoning_details(self.reasoning_details)
|
|
480
|
+
|
|
481
|
+
def ensure_text_block(self) -> tuple[TextContent, int]:
|
|
482
|
+
block = self.text_block
|
|
483
|
+
if block is None:
|
|
484
|
+
block = TextContent("")
|
|
485
|
+
self.text_block = block
|
|
486
|
+
index = self._add_block(block)
|
|
487
|
+
self.out.push(TextStartEvent(index, self.output))
|
|
488
|
+
return block, index
|
|
489
|
+
return block, self.index_of[id(block)]
|
|
490
|
+
|
|
491
|
+
def ensure_thinking_block(self, signature: str) -> tuple[ThinkingContent, int]:
|
|
492
|
+
block = self.thinking_block
|
|
493
|
+
if block is None:
|
|
494
|
+
block = ThinkingContent("", thinking_signature=signature)
|
|
495
|
+
self.thinking_block = block
|
|
496
|
+
index = self._add_block(block)
|
|
497
|
+
self.out.push(ThinkingStartEvent(index, self.output))
|
|
498
|
+
return block, index
|
|
499
|
+
return block, self.index_of[id(block)]
|
|
500
|
+
|
|
501
|
+
def _custom_input_value(self, block: ToolCall) -> str:
|
|
502
|
+
custom = self.custom_inputs.get(id(block))
|
|
503
|
+
if custom is None:
|
|
504
|
+
return ""
|
|
505
|
+
value = block.arguments.get(custom.property) if isinstance(block.arguments, dict) else None
|
|
506
|
+
return value if isinstance(value, str) else ""
|
|
507
|
+
|
|
508
|
+
def _append_custom_input(self, block: ToolCall, next_input: str, close: bool) -> str | None:
|
|
509
|
+
custom = self.custom_inputs.get(id(block))
|
|
510
|
+
if custom is None:
|
|
511
|
+
return None
|
|
512
|
+
delta = append_grammar_tool_input_json_delta(custom.buffer, custom.property, next_input, close)
|
|
513
|
+
block.arguments = {custom.property: next_input}
|
|
514
|
+
return delta
|
|
515
|
+
|
|
516
|
+
def ensure_tool_call_block(self, tool_call: dict[str, Any]) -> ToolCall:
|
|
517
|
+
index = tool_call.get("index")
|
|
518
|
+
stream_index = index if _is_number(index) else None
|
|
519
|
+
function = tool_call.get("function")
|
|
520
|
+
custom = tool_call.get("custom")
|
|
521
|
+
name = _prop(function, "name")
|
|
522
|
+
if name is None:
|
|
523
|
+
name = _prop(custom, "name")
|
|
524
|
+
if name is None:
|
|
525
|
+
name = ""
|
|
526
|
+
tool_id = tool_call.get("id")
|
|
527
|
+
is_custom = _truthy(custom) and not _truthy(function)
|
|
528
|
+
|
|
529
|
+
block = self.tool_by_index.get(stream_index) if stream_index is not None else None
|
|
530
|
+
if block is None and _truthy(tool_id):
|
|
531
|
+
block = self.tool_by_id.get(tool_id)
|
|
532
|
+
if block is None:
|
|
533
|
+
# Note: the "input" fallback should not be taken. In case the LLM makes up a
|
|
534
|
+
# tool we don't know about, we at least have a place to stash its input.
|
|
535
|
+
custom_property = self.grammar_properties.get(name, "input") if is_custom else None
|
|
536
|
+
block = ToolCall(tool_id if _truthy(tool_id) else "", name, {custom_property: ""} if is_custom else {})
|
|
537
|
+
key = id(block)
|
|
538
|
+
if custom_property is not None:
|
|
539
|
+
self.custom_inputs[key] = _CustomInput(custom_property)
|
|
540
|
+
else:
|
|
541
|
+
self.parsers[key] = StreamingJsonParser()
|
|
542
|
+
if stream_index is not None:
|
|
543
|
+
self.stream_indexes[key] = stream_index
|
|
544
|
+
self.tool_by_index[stream_index] = block
|
|
545
|
+
if _truthy(tool_id):
|
|
546
|
+
self.tool_by_id[tool_id] = block
|
|
547
|
+
content_index = self._add_block(block)
|
|
548
|
+
self.out.push(ToolCallStartEvent(content_index, self.output))
|
|
549
|
+
key = id(block)
|
|
550
|
+
if stream_index is not None and key not in self.stream_indexes:
|
|
551
|
+
self.stream_indexes[key] = stream_index
|
|
552
|
+
self.tool_by_index[stream_index] = block
|
|
553
|
+
if _truthy(tool_id):
|
|
554
|
+
self.tool_by_id[tool_id] = block
|
|
555
|
+
if not block.name and name:
|
|
556
|
+
block.name = name
|
|
557
|
+
if is_custom and key not in self.custom_inputs:
|
|
558
|
+
custom_property = self.grammar_properties.get(block.name, "input")
|
|
559
|
+
block.arguments = {custom_property: ""}
|
|
560
|
+
self.custom_inputs[key] = _CustomInput(custom_property)
|
|
561
|
+
self.parsers.pop(key, None)
|
|
562
|
+
return block
|
|
563
|
+
|
|
564
|
+
def finish_block(self, block: Any) -> None:
|
|
565
|
+
content_index = self.index_of.get(id(block))
|
|
566
|
+
if content_index is None:
|
|
567
|
+
return
|
|
568
|
+
out = self.out
|
|
569
|
+
output = self.output
|
|
570
|
+
kind = block.type
|
|
571
|
+
if kind == "text":
|
|
572
|
+
out.push(TextEndEvent(content_index, block.text, output))
|
|
573
|
+
elif kind == "thinking":
|
|
574
|
+
self.apply_reasoning_details(block)
|
|
575
|
+
out.push(ThinkingEndEvent(content_index, block.thinking, output))
|
|
576
|
+
elif kind == "toolCall":
|
|
577
|
+
key = id(block)
|
|
578
|
+
if key in self.custom_inputs:
|
|
579
|
+
delta = self._append_custom_input(block, self._custom_input_value(block), True)
|
|
580
|
+
if delta is not None:
|
|
581
|
+
out.push(ToolCallDeltaEvent(content_index, delta, output))
|
|
582
|
+
else:
|
|
583
|
+
parser = self.parsers.get(key)
|
|
584
|
+
arguments, arguments_error = parse_tool_call_arguments(parser.text if parser is not None else None)
|
|
585
|
+
block.arguments = arguments
|
|
586
|
+
if arguments_error is not None:
|
|
587
|
+
block.arguments_error = arguments_error
|
|
588
|
+
# Finalize and drop the scratch buffers so replay only carries parsed arguments.
|
|
589
|
+
self.parsers.pop(key, None)
|
|
590
|
+
self.custom_inputs.pop(key, None)
|
|
591
|
+
self.stream_indexes.pop(key, None)
|
|
592
|
+
out.push(ToolCallEndEvent(content_index, block, output))
|
|
593
|
+
|
|
594
|
+
def finish(self) -> None:
|
|
595
|
+
for block in list(self.output.content):
|
|
596
|
+
self.finish_block(block)
|
|
597
|
+
|
|
598
|
+
# -- chunks ---------------------------------------------------------------
|
|
599
|
+
|
|
600
|
+
def handle_chunk(self, chunk: Any) -> None:
|
|
601
|
+
if not isinstance(chunk, dict):
|
|
602
|
+
return
|
|
603
|
+
output = self.output
|
|
604
|
+
model = self.model
|
|
605
|
+
out = self.out
|
|
606
|
+
|
|
607
|
+
# OpenAI documents ChatCompletionChunk.id as the unique chat completion identifier,
|
|
608
|
+
# and each chunk in a streamed completion carries the same id.
|
|
609
|
+
if not output.response_id:
|
|
610
|
+
output.response_id = chunk.get("id")
|
|
611
|
+
chunk_model = chunk.get("model")
|
|
612
|
+
if isinstance(chunk_model, str) and chunk_model and chunk_model != model.id and not output.response_model:
|
|
613
|
+
output.response_model = chunk_model
|
|
614
|
+
usage = chunk.get("usage")
|
|
615
|
+
if _truthy(usage):
|
|
616
|
+
output.usage = _parse_chunk_usage(usage, model)
|
|
617
|
+
|
|
618
|
+
choices = chunk.get("choices")
|
|
619
|
+
choice = choices[0] if isinstance(choices, list) and choices else None
|
|
620
|
+
if not isinstance(choice, dict):
|
|
621
|
+
return
|
|
622
|
+
|
|
623
|
+
# Fallback: some providers (e.g., Moonshot) return usage in choice.usage
|
|
624
|
+
# instead of the standard chunk.usage.
|
|
625
|
+
if not _truthy(usage):
|
|
626
|
+
choice_usage = choice.get("usage")
|
|
627
|
+
if _truthy(choice_usage):
|
|
628
|
+
output.usage = _parse_chunk_usage(choice_usage, model)
|
|
629
|
+
|
|
630
|
+
finish_reason = choice.get("finish_reason")
|
|
631
|
+
if _truthy(finish_reason):
|
|
632
|
+
output.raw_stop_reason = finish_reason
|
|
633
|
+
stop_reason, error_message = _map_stop_reason(finish_reason)
|
|
634
|
+
output.stop_reason = stop_reason # type: ignore[assignment]
|
|
635
|
+
if error_message:
|
|
636
|
+
output.error_message = error_message
|
|
637
|
+
self.has_finish_reason = True
|
|
638
|
+
|
|
639
|
+
delta = choice.get("delta")
|
|
640
|
+
if not _truthy(delta) or not isinstance(delta, dict):
|
|
641
|
+
return
|
|
642
|
+
|
|
643
|
+
# Handle reasoning before content: some proxies (e.g. LiteLLM) aggregate reasoning and
|
|
644
|
+
# answer into one delta, and the thinking block must precede the text block.
|
|
645
|
+
# Some endpoints return reasoning in reasoning_content (llama.cpp), or reasoning (other
|
|
646
|
+
# OpenAI-compatible endpoints). Use the first non-empty reasoning field to avoid
|
|
647
|
+
# duplication (e.g., chutes.ai returns both reasoning_content and reasoning).
|
|
648
|
+
for field in _REASONING_FIELDS:
|
|
649
|
+
reasoning = delta.get(field)
|
|
650
|
+
if isinstance(reasoning, str) and reasoning:
|
|
651
|
+
signature = "reasoning_content" if model.provider == "opencode-go" and field == "reasoning" else field
|
|
652
|
+
thinking_block, thinking_index = self.ensure_thinking_block(signature)
|
|
653
|
+
append_thinking(thinking_block, reasoning)
|
|
654
|
+
out.push(ThinkingDeltaEvent(thinking_index, reasoning, output))
|
|
655
|
+
break
|
|
656
|
+
|
|
657
|
+
reasoning_details = delta.get("reasoning_details")
|
|
658
|
+
if isinstance(reasoning_details, list):
|
|
659
|
+
for detail in reasoning_details:
|
|
660
|
+
if not _is_reasoning_detail(detail):
|
|
661
|
+
continue
|
|
662
|
+
self.ensure_thinking_block("")
|
|
663
|
+
if self.reasoning_details is None:
|
|
664
|
+
self.reasoning_details = []
|
|
665
|
+
# Keep provider replay data in the existing signature slot. OpenRouter streams
|
|
666
|
+
# reasoning_details as deltas: consecutive text/summary deltas are merged into
|
|
667
|
+
# logical entries, while encrypted entries remain opaque and discrete.
|
|
668
|
+
_append_reasoning_detail(self.reasoning_details, detail)
|
|
669
|
+
|
|
670
|
+
content = delta.get("content")
|
|
671
|
+
if isinstance(content, str) and content:
|
|
672
|
+
text_block, text_index = self.ensure_text_block()
|
|
673
|
+
append_text(text_block, content)
|
|
674
|
+
out.push(TextDeltaEvent(text_index, content, output))
|
|
675
|
+
|
|
676
|
+
tool_calls = delta.get("tool_calls")
|
|
677
|
+
if isinstance(tool_calls, list):
|
|
678
|
+
for tool_call in tool_calls:
|
|
679
|
+
if not isinstance(tool_call, dict):
|
|
680
|
+
continue
|
|
681
|
+
self._handle_tool_call_delta(tool_call)
|
|
682
|
+
|
|
683
|
+
def _handle_tool_call_delta(self, tool_call: dict[str, Any]) -> None:
|
|
684
|
+
block = self.ensure_tool_call_block(tool_call)
|
|
685
|
+
tool_id = tool_call.get("id")
|
|
686
|
+
if not block.id and _truthy(tool_id):
|
|
687
|
+
block.id = tool_id
|
|
688
|
+
self.tool_by_id[tool_id] = block
|
|
689
|
+
function = tool_call.get("function")
|
|
690
|
+
custom = tool_call.get("custom")
|
|
691
|
+
name = _prop(function, "name")
|
|
692
|
+
if name is None:
|
|
693
|
+
name = _prop(custom, "name")
|
|
694
|
+
if not block.name and _truthy(name):
|
|
695
|
+
block.name = name
|
|
696
|
+
|
|
697
|
+
key = id(block)
|
|
698
|
+
delta = ""
|
|
699
|
+
arguments = _prop(function, "arguments")
|
|
700
|
+
if _truthy(arguments):
|
|
701
|
+
delta = arguments if isinstance(arguments, str) else _js_string(arguments)
|
|
702
|
+
parser = self.parsers.get(key)
|
|
703
|
+
if parser is None:
|
|
704
|
+
parser = StreamingJsonParser()
|
|
705
|
+
self.parsers[key] = parser
|
|
706
|
+
parser.feed(delta)
|
|
707
|
+
block.arguments = parser.value()
|
|
708
|
+
else:
|
|
709
|
+
custom_input = _prop(custom, "input")
|
|
710
|
+
if _truthy(custom_input):
|
|
711
|
+
next_input = self._custom_input_value(block) + _js_string(custom_input)
|
|
712
|
+
custom_delta = self._append_custom_input(block, next_input, False)
|
|
713
|
+
delta = custom_delta if custom_delta is not None else ""
|
|
714
|
+
self.out.push(ToolCallDeltaEvent(self.index_of[key], delta, self.output))
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
async def _run(
|
|
718
|
+
model: Model,
|
|
719
|
+
context: TranscriptContext,
|
|
720
|
+
options: dict[str, Any],
|
|
721
|
+
out: AssistantMessageEventStream,
|
|
722
|
+
) -> None:
|
|
723
|
+
output = AssistantMessage(
|
|
724
|
+
[], api=model.api, provider=model.provider, model=model.id, usage=Usage(), stop_reason="pending"
|
|
725
|
+
)
|
|
726
|
+
signal = options.get("signal")
|
|
727
|
+
processor: _StreamProcessor | None = None
|
|
728
|
+
try:
|
|
729
|
+
api_key = _get_client_api_key(model.provider, options.get("api_key"), options.get("headers"))
|
|
730
|
+
compat = get_compat(model)
|
|
731
|
+
grammar_properties = create_grammar_tool_input_properties(
|
|
732
|
+
get_declared_tools(context.messages), bool(compat.get("supportsOpenAIGrammarTools"))
|
|
733
|
+
)
|
|
734
|
+
cache_retention = _resolve_cache_retention(options.get("cache_retention"), options.get("env"))
|
|
735
|
+
cache_session_id = None if cache_retention == "none" else options.get("session_id")
|
|
736
|
+
headers = _build_request_headers(model, context, options.get("headers"), cache_session_id, compat)
|
|
737
|
+
client = create_openai_client(api_key=api_key, base_url=model.base_url, headers=headers, options=options)
|
|
738
|
+
params: Any = build_params(model, context, options, compat, cache_retention, grammar_properties)
|
|
739
|
+
on_payload = options.get("on_payload")
|
|
740
|
+
if on_payload is not None:
|
|
741
|
+
next_params = await maybe_await(on_payload(params, model))
|
|
742
|
+
if next_params is not None:
|
|
743
|
+
params = next_params
|
|
744
|
+
|
|
745
|
+
create = client.chat.completions.with_raw_response.create
|
|
746
|
+
body_arguments = sdk_body_arguments(params, "model", "messages", headers=headers)
|
|
747
|
+
try:
|
|
748
|
+
async with abort_scope(signal):
|
|
749
|
+
raw = await openai_request(lambda: create(stream=True, **body_arguments), options)
|
|
750
|
+
except AbortError as error:
|
|
751
|
+
if signal is not None and signal.aborted:
|
|
752
|
+
raise AbortError("Request aborted") from error
|
|
753
|
+
raise
|
|
754
|
+
|
|
755
|
+
processor = _StreamProcessor(model, out, output, grammar_properties)
|
|
756
|
+
sdk_stream = open_sdk_stream(raw)
|
|
757
|
+
try:
|
|
758
|
+
on_response = options.get("on_response")
|
|
759
|
+
if on_response is not None:
|
|
760
|
+
await maybe_await(on_response(ProviderResponse(raw.status_code, headers_to_record(raw.headers)), model))
|
|
761
|
+
out.push(StartEvent(output))
|
|
762
|
+
on_event = options.get("on_provider_stream_event")
|
|
763
|
+
try:
|
|
764
|
+
async with abort_scope(signal):
|
|
765
|
+
async for chunk in iter_sdk_stream(sdk_stream):
|
|
766
|
+
if on_event is not None:
|
|
767
|
+
await maybe_await(on_event(chunk, model))
|
|
768
|
+
processor.handle_chunk(chunk)
|
|
769
|
+
except AbortError:
|
|
770
|
+
# The OpenAI SDK ends the chunk iteration silently when the request is aborted.
|
|
771
|
+
if signal is None or not signal.aborted:
|
|
772
|
+
raise
|
|
773
|
+
finally:
|
|
774
|
+
await sdk_stream.close()
|
|
775
|
+
|
|
776
|
+
processor.finish()
|
|
777
|
+
if signal is not None and signal.aborted:
|
|
778
|
+
raise RuntimeError("Request was aborted")
|
|
779
|
+
if output.stop_reason == "aborted":
|
|
780
|
+
raise RuntimeError("Request was aborted")
|
|
781
|
+
supports_finish_reason = compat.get("supportsFinishReason")
|
|
782
|
+
if not processor.has_finish_reason and not supports_finish_reason:
|
|
783
|
+
output.stop_reason = "toolUse" if any(block.type == "toolCall" for block in output.content) else "stop"
|
|
784
|
+
if output.stop_reason == "error":
|
|
785
|
+
raise RuntimeError(output.error_message or "Provider returned an error stop reason")
|
|
786
|
+
if (supports_finish_reason and not processor.has_finish_reason) or output.stop_reason == "pending":
|
|
787
|
+
raise RuntimeError("Stream ended without finish_reason")
|
|
788
|
+
|
|
789
|
+
out.push(DoneEvent(output.stop_reason, output)) # type: ignore[arg-type]
|
|
790
|
+
out.end()
|
|
791
|
+
except Exception as error:
|
|
792
|
+
if processor is not None and processor.thinking_block is not None:
|
|
793
|
+
processor.apply_reasoning_details(processor.thinking_block)
|
|
794
|
+
output.stop_reason = "aborted" if signal is not None and signal.aborted else "error"
|
|
795
|
+
message = format_provider_error(normalize_provider_error(error))
|
|
796
|
+
# Some providers via OpenRouter give additional information in this field.
|
|
797
|
+
# normalize_provider_error already stringifies the parsed body (error.error) into the
|
|
798
|
+
# message, so only append the raw metadata when it is not already present.
|
|
799
|
+
raw_metadata = _prop(_prop(getattr(error, "error", None), "metadata"), "raw")
|
|
800
|
+
if _truthy(raw_metadata):
|
|
801
|
+
raw_text = _js_string(raw_metadata)
|
|
802
|
+
if raw_text not in message:
|
|
803
|
+
message += f"\n{raw_text}"
|
|
804
|
+
output.error_message = message
|
|
805
|
+
out.push(ErrorEvent(output.stop_reason, output))
|
|
806
|
+
out.end()
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
# ---------------------------------------------------------------------------
|
|
810
|
+
# Request parameters
|
|
811
|
+
# ---------------------------------------------------------------------------
|
|
812
|
+
|
|
813
|
+
|
|
814
|
+
def build_params(
|
|
815
|
+
model: Model,
|
|
816
|
+
context: TranscriptContext,
|
|
817
|
+
options: Mapping[str, Any] | None = None,
|
|
818
|
+
compat: ResolvedOpenAICompletionsCompat | None = None,
|
|
819
|
+
cache_retention: str | None = None,
|
|
820
|
+
grammar_tool_input_properties: Mapping[str, str] | None = None,
|
|
821
|
+
) -> dict[str, Any]:
|
|
822
|
+
"""Build the ``chat.completions.create`` request body (``stream: true``)."""
|
|
823
|
+
options = options or {}
|
|
824
|
+
if compat is None:
|
|
825
|
+
compat = get_compat(model)
|
|
826
|
+
if cache_retention is None:
|
|
827
|
+
cache_retention = _resolve_cache_retention(options.get("cache_retention"), options.get("env"))
|
|
828
|
+
if grammar_tool_input_properties is None:
|
|
829
|
+
grammar_tool_input_properties = create_grammar_tool_input_properties(
|
|
830
|
+
get_declared_tools(context.messages), bool(compat.get("supportsOpenAIGrammarTools"))
|
|
831
|
+
)
|
|
832
|
+
transcript_tools = resolve_transcript_tools(
|
|
833
|
+
context.messages,
|
|
834
|
+
compat.get("supportsMidConvoSystemMessages") is True and compat.get("supportsMidConvoToolAdditions") is True,
|
|
835
|
+
)
|
|
836
|
+
messages = convert_messages(
|
|
837
|
+
model, context, compat, {"grammar_tool_input_properties": grammar_tool_input_properties}
|
|
838
|
+
)
|
|
839
|
+
cache_control = _get_compat_cache_control(compat, cache_retention)
|
|
840
|
+
long_retention = cache_retention == "long" and _truthy(compat.get("supportsLongCacheRetention"))
|
|
841
|
+
|
|
842
|
+
params: dict[str, Any] = {"model": model.id, "messages": messages, "stream": True}
|
|
843
|
+
if ("api.openai.com" in model.base_url and cache_retention != "none") or long_retention:
|
|
844
|
+
prompt_cache_key = clamp_openai_prompt_cache_key(options.get("session_id"))
|
|
845
|
+
if prompt_cache_key is not None:
|
|
846
|
+
params["prompt_cache_key"] = prompt_cache_key
|
|
847
|
+
if long_retention:
|
|
848
|
+
params["prompt_cache_retention"] = "24h"
|
|
849
|
+
|
|
850
|
+
if compat.get("supportsUsageInStreaming") is not False:
|
|
851
|
+
params["stream_options"] = {"include_usage": True}
|
|
852
|
+
|
|
853
|
+
if _truthy(compat.get("supportsStore")):
|
|
854
|
+
params["store"] = False
|
|
855
|
+
|
|
856
|
+
max_tokens = options.get("max_tokens")
|
|
857
|
+
if _truthy(max_tokens):
|
|
858
|
+
if compat.get("maxTokensField") == "max_tokens":
|
|
859
|
+
# Deprecated by OpenAI, but some OpenAI-compatible providers only accept max_tokens.
|
|
860
|
+
params["max_tokens"] = max_tokens
|
|
861
|
+
else:
|
|
862
|
+
params["max_completion_tokens"] = max_tokens
|
|
863
|
+
|
|
864
|
+
temperature = options.get("temperature")
|
|
865
|
+
if temperature is not None:
|
|
866
|
+
params["temperature"] = temperature
|
|
867
|
+
|
|
868
|
+
if transcript_tools.request_tools:
|
|
869
|
+
params["tools"] = convert_tools(transcript_tools.request_tools, compat)
|
|
870
|
+
if _truthy(compat.get("zaiToolStream")):
|
|
871
|
+
params["tool_stream"] = True
|
|
872
|
+
elif _has_tool_history(context.messages):
|
|
873
|
+
# Anthropic (via LiteLLM/proxy) requires tools param when conversation has tool_calls/tool_results.
|
|
874
|
+
params["tools"] = []
|
|
875
|
+
|
|
876
|
+
if cache_control is not None:
|
|
877
|
+
_apply_anthropic_cache_control(messages, params.get("tools"), cache_control)
|
|
878
|
+
|
|
879
|
+
tool_choice = options.get("tool_choice")
|
|
880
|
+
if _truthy(tool_choice):
|
|
881
|
+
params["tool_choice"] = tool_choice
|
|
882
|
+
|
|
883
|
+
vllm_priority = compat.get("vllmPriority")
|
|
884
|
+
if vllm_priority is not None:
|
|
885
|
+
params["priority"] = vllm_priority
|
|
886
|
+
|
|
887
|
+
thinking_budget_field = _resolve_thinking_token_budget_field(compat)
|
|
888
|
+
thinking_budget = _resolve_clamped_thinking_budget(model, options, params)
|
|
889
|
+
_apply_thinking_params(model, options, compat, params, thinking_budget)
|
|
890
|
+
|
|
891
|
+
# Cap reasoning with a top-level budget field. Independent of thinkingFormat: the same
|
|
892
|
+
# server can serve zai, qwen or chat-template models. Reasoning and the answer share
|
|
893
|
+
# max_tokens here, so an uncapped reasoning phase can consume the whole response and
|
|
894
|
+
# leave no answer and no tool call.
|
|
895
|
+
if thinking_budget_field and thinking_budget is not None:
|
|
896
|
+
params[thinking_budget_field] = thinking_budget
|
|
897
|
+
|
|
898
|
+
model_compat = model.compat or {}
|
|
899
|
+
# OpenRouter provider routing preferences
|
|
900
|
+
open_router_routing = model_compat.get("openRouterRouting")
|
|
901
|
+
if _truthy(open_router_routing):
|
|
902
|
+
params["provider"] = open_router_routing
|
|
903
|
+
|
|
904
|
+
# Vercel AI Gateway provider routing preferences
|
|
905
|
+
vercel_routing = model_compat.get("vercelGatewayRouting")
|
|
906
|
+
if _truthy(vercel_routing):
|
|
907
|
+
only = _prop(vercel_routing, "only")
|
|
908
|
+
order = _prop(vercel_routing, "order")
|
|
909
|
+
if _truthy(only) or _truthy(order):
|
|
910
|
+
gateway_options: dict[str, Any] = {}
|
|
911
|
+
if _truthy(only):
|
|
912
|
+
gateway_options["only"] = only
|
|
913
|
+
if _truthy(order):
|
|
914
|
+
gateway_options["order"] = order
|
|
915
|
+
params["providerOptions"] = {"gateway": gateway_options}
|
|
916
|
+
|
|
917
|
+
# Last so model and request sampling parameters override named request fields.
|
|
918
|
+
sampling_params = resolve_sampling_params(
|
|
919
|
+
model, options.get("reasoning_effort") or "off", options.get("sampling_params")
|
|
920
|
+
)
|
|
921
|
+
if sampling_params:
|
|
922
|
+
params.update(sampling_params)
|
|
923
|
+
|
|
924
|
+
return params
|
|
925
|
+
|
|
926
|
+
|
|
927
|
+
def _apply_thinking_params(
|
|
928
|
+
model: Model,
|
|
929
|
+
options: Mapping[str, Any],
|
|
930
|
+
compat: ResolvedOpenAICompletionsCompat,
|
|
931
|
+
params: dict[str, Any],
|
|
932
|
+
thinking_budget: int | None,
|
|
933
|
+
) -> None:
|
|
934
|
+
effort: str | None = options.get("reasoning_effort") or None
|
|
935
|
+
thinking_format = compat.get("thinkingFormat")
|
|
936
|
+
reasoning = model.reasoning
|
|
937
|
+
supports_effort = _truthy(compat.get("supportsReasoningEffort"))
|
|
938
|
+
level_map = model.thinking_level_map
|
|
939
|
+
|
|
940
|
+
if thinking_format == "zai" and reasoning:
|
|
941
|
+
params["thinking"] = {"type": "enabled", "clear_thinking": False} if effort else {"type": "disabled"}
|
|
942
|
+
if effort and supports_effort:
|
|
943
|
+
mapped = _level_value(model, effort)
|
|
944
|
+
value = effort if mapped is _UNDEFINED else mapped
|
|
945
|
+
if isinstance(value, str):
|
|
946
|
+
params["reasoning_effort"] = value
|
|
947
|
+
elif thinking_format == "qwen" and reasoning:
|
|
948
|
+
params["enable_thinking"] = bool(effort)
|
|
949
|
+
if effort and supports_effort:
|
|
950
|
+
value = _level_value_or(model, effort, effort)
|
|
951
|
+
if isinstance(value, str):
|
|
952
|
+
params["reasoning_effort"] = value
|
|
953
|
+
elif thinking_format == "qwen-chat-template" and reasoning:
|
|
954
|
+
params["chat_template_kwargs"] = {"enable_thinking": bool(effort), "preserve_thinking": True}
|
|
955
|
+
elif thinking_format == "chat-template" and reasoning:
|
|
956
|
+
kwargs = _build_chat_template_values(model, options, compat.get("chatTemplateKwargs"), thinking_budget)
|
|
957
|
+
if kwargs is not None:
|
|
958
|
+
params["chat_template_kwargs"] = kwargs
|
|
959
|
+
elif thinking_format == "baseten" and reasoning:
|
|
960
|
+
args = _build_chat_template_values(model, options, compat.get("chatTemplateArgs"), thinking_budget)
|
|
961
|
+
if args is not None:
|
|
962
|
+
params["chat_template_args"] = args
|
|
963
|
+
if supports_effort:
|
|
964
|
+
mapped = _level_value(model, effort) if effort else _level_value(model, "off")
|
|
965
|
+
value = effort if mapped is _UNDEFINED else mapped
|
|
966
|
+
if isinstance(value, str):
|
|
967
|
+
params["reasoning_effort"] = value
|
|
968
|
+
elif thinking_format == "deepseek" and reasoning:
|
|
969
|
+
if effort:
|
|
970
|
+
params["thinking"] = {"type": "enabled"}
|
|
971
|
+
elif _level_value(model, "off") is not None:
|
|
972
|
+
params["thinking"] = {"type": "disabled"}
|
|
973
|
+
if effort and supports_effort:
|
|
974
|
+
params["reasoning_effort"] = _level_value_or(model, effort, effort)
|
|
975
|
+
elif thinking_format == "openrouter" and reasoning:
|
|
976
|
+
# OpenRouter normalizes reasoning across providers via a nested reasoning object.
|
|
977
|
+
if effort:
|
|
978
|
+
params["reasoning"] = {"effort": _level_value_or(model, effort, effort)}
|
|
979
|
+
elif _level_value(model, "off") is not None:
|
|
980
|
+
params["reasoning"] = {"effort": _level_value_or(model, "off", "none")}
|
|
981
|
+
elif thinking_format == "ant-ling" and reasoning and effort:
|
|
982
|
+
value = _level_value(model, effort)
|
|
983
|
+
if isinstance(value, str):
|
|
984
|
+
params["reasoning"] = {"effort": value}
|
|
985
|
+
elif thinking_format == "together" and reasoning:
|
|
986
|
+
params["reasoning"] = {"enabled": bool(effort)}
|
|
987
|
+
if effort and supports_effort:
|
|
988
|
+
params["reasoning_effort"] = _level_value_or(model, effort, effort)
|
|
989
|
+
elif thinking_format == "string-thinking" and reasoning:
|
|
990
|
+
if effort:
|
|
991
|
+
params["thinking"] = _level_value_or(model, effort, effort)
|
|
992
|
+
elif _level_value(model, "off") is not None:
|
|
993
|
+
params["thinking"] = _level_value_or(model, "off", "none")
|
|
994
|
+
elif effort and reasoning and supports_effort:
|
|
995
|
+
# OpenAI-style reasoning_effort
|
|
996
|
+
params["reasoning_effort"] = _level_value_or(model, effort, effort)
|
|
997
|
+
elif not effort and reasoning and supports_effort:
|
|
998
|
+
off_value = level_map.get("off") if level_map else None
|
|
999
|
+
if isinstance(off_value, str):
|
|
1000
|
+
params["reasoning_effort"] = off_value
|
|
1001
|
+
|
|
1002
|
+
|
|
1003
|
+
def _resolve_thinking_token_budget_field(compat: Mapping[str, Any]) -> str | None:
|
|
1004
|
+
field = compat.get("thinkingTokenBudgetField")
|
|
1005
|
+
if _truthy(field):
|
|
1006
|
+
return field
|
|
1007
|
+
if _truthy(compat.get("supportsThinkingTokenBudget")):
|
|
1008
|
+
return "thinking_token_budget"
|
|
1009
|
+
return None
|
|
1010
|
+
|
|
1011
|
+
|
|
1012
|
+
def _resolve_clamped_thinking_budget(model: Model, options: Mapping[str, Any], params: Mapping[str, Any]) -> int | None:
|
|
1013
|
+
effort = options.get("reasoning_effort")
|
|
1014
|
+
if not effort or not model.reasoning:
|
|
1015
|
+
return None
|
|
1016
|
+
ceiling = params.get("max_tokens")
|
|
1017
|
+
if ceiling is None:
|
|
1018
|
+
ceiling = params.get("max_completion_tokens")
|
|
1019
|
+
if ceiling is None:
|
|
1020
|
+
ceiling = model.max_tokens
|
|
1021
|
+
try:
|
|
1022
|
+
budget = clamp_thinking_budget_to_answer_room(
|
|
1023
|
+
thinking_budget_for_level(effort, options.get("thinking_budgets")), ceiling
|
|
1024
|
+
)
|
|
1025
|
+
except (KeyError, TypeError):
|
|
1026
|
+
# Unknown levels or budgets yield NaN upstream, which never counts as a budget.
|
|
1027
|
+
return None
|
|
1028
|
+
return budget if budget > 0 else None
|
|
1029
|
+
|
|
1030
|
+
|
|
1031
|
+
def _build_chat_template_values(
|
|
1032
|
+
model: Model, options: Mapping[str, Any], values: Any, thinking_budget: int | None
|
|
1033
|
+
) -> dict[str, Any] | None:
|
|
1034
|
+
if not isinstance(values, Mapping):
|
|
1035
|
+
return None
|
|
1036
|
+
resolved: dict[str, Any] = {}
|
|
1037
|
+
for key, value in values.items():
|
|
1038
|
+
result = _resolve_chat_template_value(model, options, value, thinking_budget)
|
|
1039
|
+
if result is not _UNDEFINED:
|
|
1040
|
+
resolved[key] = result
|
|
1041
|
+
return resolved or None
|
|
1042
|
+
|
|
1043
|
+
|
|
1044
|
+
def _resolve_chat_template_value(
|
|
1045
|
+
model: Model, options: Mapping[str, Any], value: Any, thinking_budget: int | None
|
|
1046
|
+
) -> Any:
|
|
1047
|
+
if not isinstance(value, dict):
|
|
1048
|
+
return value
|
|
1049
|
+
effort = options.get("reasoning_effort")
|
|
1050
|
+
if not effort and _truthy(value.get("omitWhenOff")):
|
|
1051
|
+
return _UNDEFINED
|
|
1052
|
+
variable = value.get("$var")
|
|
1053
|
+
if variable == "thinking.enabled":
|
|
1054
|
+
return bool(effort)
|
|
1055
|
+
if variable == "thinking.budget":
|
|
1056
|
+
return thinking_budget if thinking_budget is not None else _UNDEFINED
|
|
1057
|
+
mapped = _level_value(model, effort) if effort else _level_value(model, "off")
|
|
1058
|
+
if mapped is _UNDEFINED:
|
|
1059
|
+
return effort if effort else _UNDEFINED
|
|
1060
|
+
return mapped if isinstance(mapped, str) else _UNDEFINED
|
|
1061
|
+
|
|
1062
|
+
|
|
1063
|
+
# ---------------------------------------------------------------------------
|
|
1064
|
+
# Anthropic-style cache_control markers
|
|
1065
|
+
# ---------------------------------------------------------------------------
|
|
1066
|
+
|
|
1067
|
+
|
|
1068
|
+
def _get_compat_cache_control(compat: Mapping[str, Any], cache_retention: str) -> dict[str, Any] | None:
|
|
1069
|
+
if compat.get("cacheControlFormat") != "anthropic" or cache_retention == "none":
|
|
1070
|
+
return None
|
|
1071
|
+
cache_control: dict[str, Any] = {"type": "ephemeral"}
|
|
1072
|
+
if cache_retention == "long" and _truthy(compat.get("supportsLongCacheRetention")):
|
|
1073
|
+
cache_control["ttl"] = "1h"
|
|
1074
|
+
return cache_control
|
|
1075
|
+
|
|
1076
|
+
|
|
1077
|
+
def _apply_anthropic_cache_control(
|
|
1078
|
+
messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None, cache_control: dict[str, Any]
|
|
1079
|
+
) -> None:
|
|
1080
|
+
# System prompt
|
|
1081
|
+
for message in messages:
|
|
1082
|
+
if message.get("role") in ("system", "developer"):
|
|
1083
|
+
_add_cache_control_to_text_content(message, cache_control)
|
|
1084
|
+
break
|
|
1085
|
+
# Last tool definition
|
|
1086
|
+
if tools:
|
|
1087
|
+
tools[-1]["cache_control"] = cache_control
|
|
1088
|
+
# Last user, assistant, or tool-result text content
|
|
1089
|
+
for message in reversed(messages):
|
|
1090
|
+
if message.get("role") in ("user", "assistant", "tool") and _add_cache_control_to_text_content(
|
|
1091
|
+
message, cache_control
|
|
1092
|
+
):
|
|
1093
|
+
return
|
|
1094
|
+
|
|
1095
|
+
|
|
1096
|
+
def _add_cache_control_to_text_content(message: dict[str, Any], cache_control: dict[str, Any]) -> bool:
|
|
1097
|
+
content = message.get("content")
|
|
1098
|
+
if isinstance(content, str):
|
|
1099
|
+
if not content:
|
|
1100
|
+
return False
|
|
1101
|
+
message["content"] = [{"type": "text", "text": content, "cache_control": cache_control}]
|
|
1102
|
+
return True
|
|
1103
|
+
if not isinstance(content, list):
|
|
1104
|
+
return False
|
|
1105
|
+
for part in reversed(content):
|
|
1106
|
+
if isinstance(part, dict) and part.get("type") == "text":
|
|
1107
|
+
part["cache_control"] = cache_control
|
|
1108
|
+
return True
|
|
1109
|
+
return False
|
|
1110
|
+
|
|
1111
|
+
|
|
1112
|
+
# ---------------------------------------------------------------------------
|
|
1113
|
+
# Messages and tools
|
|
1114
|
+
# ---------------------------------------------------------------------------
|
|
1115
|
+
|
|
1116
|
+
_INVALID_ID_CHAR = re.compile(r"[^a-zA-Z0-9_-]")
|
|
1117
|
+
|
|
1118
|
+
|
|
1119
|
+
def _replace_invalid_id_char(match: re.Match[str]) -> str:
|
|
1120
|
+
# JavaScript regexes without the u flag replace each UTF-16 code unit.
|
|
1121
|
+
return "__" if ord(match.group()) > 0xFFFF else "_"
|
|
1122
|
+
|
|
1123
|
+
|
|
1124
|
+
def _sanitize_id_part(text: str) -> str:
|
|
1125
|
+
return _INVALID_ID_CHAR.sub(_replace_invalid_id_char, text)
|
|
1126
|
+
|
|
1127
|
+
|
|
1128
|
+
def _image_url(block: ImageContent) -> dict[str, Any]:
|
|
1129
|
+
return {"type": "image_url", "image_url": {"url": f"data:{block.mime_type};base64,{block.data}"}}
|
|
1130
|
+
|
|
1131
|
+
|
|
1132
|
+
def convert_messages(
|
|
1133
|
+
model: Model,
|
|
1134
|
+
context: TranscriptContext,
|
|
1135
|
+
compat: Mapping[str, Any],
|
|
1136
|
+
options: ConvertCompletionsMessagesOptions | Mapping[str, Any] | None = None,
|
|
1137
|
+
) -> list[dict[str, Any]]:
|
|
1138
|
+
"""Convert a pi transcript into Chat Completions ``messages``."""
|
|
1139
|
+
normalized = resolve_transcript(context, compat.get("supportsMidConvoSystemMessages"))
|
|
1140
|
+
grammar_properties: Mapping[str, str] | None = (options or {}).get("grammar_tool_input_properties")
|
|
1141
|
+
params: list[dict[str, Any]] = []
|
|
1142
|
+
provider = model.provider
|
|
1143
|
+
|
|
1144
|
+
def normalize_tool_call_id(tool_call_id: str, _model: Model, _source: AssistantMessage) -> str:
|
|
1145
|
+
# Handle pipe-separated IDs from the OpenAI Responses API: {call_id}|{id}, where {id}
|
|
1146
|
+
# can be 400+ chars with special chars (+, /, =). They come from providers like
|
|
1147
|
+
# github-copilot, openai-codex and opencode. Multiple tool calls in the same turn can
|
|
1148
|
+
# share call_id but differ by item_id, so preserve item-level uniqueness when
|
|
1149
|
+
# replaying into Chat Completions, which requires distinct tool call ids.
|
|
1150
|
+
if "|" in tool_call_id:
|
|
1151
|
+
# Sanitize to allowed chars and truncate to 40 chars (OpenAI limit).
|
|
1152
|
+
separator = tool_call_id.index("|")
|
|
1153
|
+
call_id = _sanitize_id_part(tool_call_id[:separator])
|
|
1154
|
+
item_id = _sanitize_id_part(tool_call_id[separator + 1 :])
|
|
1155
|
+
combined = f"{call_id}_{item_id}" if item_id else call_id
|
|
1156
|
+
if len(combined) <= 40:
|
|
1157
|
+
return combined
|
|
1158
|
+
digest = short_hash(tool_call_id)[:8]
|
|
1159
|
+
prefix = call_id[: max(1, 40 - len(digest) - 1)]
|
|
1160
|
+
return f"{prefix}_{digest}"
|
|
1161
|
+
if provider == "openai":
|
|
1162
|
+
return tool_call_id[:40] if len(tool_call_id) > 40 else tool_call_id
|
|
1163
|
+
return tool_call_id
|
|
1164
|
+
|
|
1165
|
+
transformed = transform_messages(normalized.messages, model, normalize_tool_call_id)
|
|
1166
|
+
transcript_tools = resolve_transcript_tools(
|
|
1167
|
+
normalized.messages,
|
|
1168
|
+
compat.get("supportsMidConvoSystemMessages") is True and compat.get("supportsMidConvoToolAdditions") is True,
|
|
1169
|
+
)
|
|
1170
|
+
instruction_role = "developer" if model.reasoning and _truthy(compat.get("supportsDeveloperRole")) else "system"
|
|
1171
|
+
requires_assistant_after_tool_result = _truthy(compat.get("requiresAssistantAfterToolResult"))
|
|
1172
|
+
supports_images = "image" in model.input
|
|
1173
|
+
|
|
1174
|
+
last_role: str | None = None
|
|
1175
|
+
i = 0
|
|
1176
|
+
count = len(transformed)
|
|
1177
|
+
while i < count:
|
|
1178
|
+
msg: Any = transformed[i]
|
|
1179
|
+
role = msg.role
|
|
1180
|
+
# Some providers don't allow user messages directly after tool results.
|
|
1181
|
+
# Insert a synthetic assistant message to bridge the gap.
|
|
1182
|
+
if requires_assistant_after_tool_result and last_role == "toolResult" and role == "user":
|
|
1183
|
+
params.append({"role": "assistant", "content": "I have processed the tool results."})
|
|
1184
|
+
|
|
1185
|
+
if role == "system":
|
|
1186
|
+
added_tools = (msg.tools_added or []) if i > 0 and transcript_tools.anchors_additions else []
|
|
1187
|
+
if added_tools:
|
|
1188
|
+
params.append({"role": "system", "tools": convert_tools(added_tools, compat)})
|
|
1189
|
+
text = get_system_message_text(msg) if i == 0 else render_system_message_update(msg)
|
|
1190
|
+
if text:
|
|
1191
|
+
params.append({"role": instruction_role, "content": sanitize_surrogates(text)})
|
|
1192
|
+
elif role == "user":
|
|
1193
|
+
if isinstance(msg.content, str):
|
|
1194
|
+
params.append({"role": "user", "content": sanitize_surrogates(msg.content)})
|
|
1195
|
+
else:
|
|
1196
|
+
parts: list[dict[str, Any]] = []
|
|
1197
|
+
for item in msg.content:
|
|
1198
|
+
if item.type == "text":
|
|
1199
|
+
if item.text:
|
|
1200
|
+
parts.append({"type": "text", "text": sanitize_surrogates(item.text)})
|
|
1201
|
+
else:
|
|
1202
|
+
parts.append(_image_url(item))
|
|
1203
|
+
if not parts:
|
|
1204
|
+
i += 1
|
|
1205
|
+
continue
|
|
1206
|
+
params.append({"role": "user", "content": parts})
|
|
1207
|
+
elif role == "assistant":
|
|
1208
|
+
if not _append_assistant_message(params, msg, model, compat, grammar_properties):
|
|
1209
|
+
i += 1
|
|
1210
|
+
continue
|
|
1211
|
+
elif role == "toolResult":
|
|
1212
|
+
image_blocks: list[dict[str, Any]] = []
|
|
1213
|
+
j = i
|
|
1214
|
+
while j < count and transformed[j].role == "toolResult":
|
|
1215
|
+
tool_msg: ToolResultMessage = transformed[j] # type: ignore[assignment]
|
|
1216
|
+
text_result = "\n".join(block.text for block in tool_msg.content if block.type == "text")
|
|
1217
|
+
has_images = any(block.type == "image" for block in tool_msg.content)
|
|
1218
|
+
# Always send tool result with text (or placeholder if only images).
|
|
1219
|
+
if _js_trim(text_result):
|
|
1220
|
+
tool_result_text = text_result
|
|
1221
|
+
else:
|
|
1222
|
+
tool_result_text = "(see attached image)" if has_images else "(no tool output)"
|
|
1223
|
+
tool_result: dict[str, Any] = {
|
|
1224
|
+
"role": "tool",
|
|
1225
|
+
"content": sanitize_surrogates(tool_result_text),
|
|
1226
|
+
"tool_call_id": tool_msg.tool_call_id,
|
|
1227
|
+
}
|
|
1228
|
+
# Some providers require the 'name' field in tool results.
|
|
1229
|
+
if _truthy(compat.get("requiresToolResultName")) and tool_msg.tool_name:
|
|
1230
|
+
tool_result["name"] = tool_msg.tool_name
|
|
1231
|
+
params.append(tool_result)
|
|
1232
|
+
if has_images and supports_images:
|
|
1233
|
+
image_blocks.extend(_image_url(block) for block in tool_msg.content if block.type == "image")
|
|
1234
|
+
j += 1
|
|
1235
|
+
i = j
|
|
1236
|
+
if image_blocks:
|
|
1237
|
+
if requires_assistant_after_tool_result:
|
|
1238
|
+
params.append({"role": "assistant", "content": "I have processed the tool results."})
|
|
1239
|
+
params.append(
|
|
1240
|
+
{
|
|
1241
|
+
"role": "user",
|
|
1242
|
+
"content": [{"type": "text", "text": "Attached image(s) from tool result:"}, *image_blocks],
|
|
1243
|
+
}
|
|
1244
|
+
)
|
|
1245
|
+
last_role = "user"
|
|
1246
|
+
else:
|
|
1247
|
+
last_role = "toolResult"
|
|
1248
|
+
continue
|
|
1249
|
+
|
|
1250
|
+
last_role = role
|
|
1251
|
+
i += 1
|
|
1252
|
+
|
|
1253
|
+
return params
|
|
1254
|
+
|
|
1255
|
+
|
|
1256
|
+
def _append_assistant_message(
|
|
1257
|
+
params: list[dict[str, Any]],
|
|
1258
|
+
msg: AssistantMessage,
|
|
1259
|
+
model: Model,
|
|
1260
|
+
compat: Mapping[str, Any],
|
|
1261
|
+
grammar_properties: Mapping[str, str] | None,
|
|
1262
|
+
) -> bool:
|
|
1263
|
+
# Some providers don't accept null content, use empty string instead.
|
|
1264
|
+
assistant: dict[str, Any] = {
|
|
1265
|
+
"role": "assistant",
|
|
1266
|
+
"content": "" if _truthy(compat.get("requiresAssistantAfterToolResult")) else None,
|
|
1267
|
+
}
|
|
1268
|
+
text_parts = [
|
|
1269
|
+
{"type": "text", "text": sanitize_surrogates(block.text)}
|
|
1270
|
+
for block in msg.content
|
|
1271
|
+
if block.type == "text" and _js_trim(block.text)
|
|
1272
|
+
]
|
|
1273
|
+
assistant_text = "".join(part["text"] for part in text_parts)
|
|
1274
|
+
|
|
1275
|
+
thinking_blocks: list[ThinkingContent] = [block for block in msg.content if block.type == "thinking"] # type: ignore[misc]
|
|
1276
|
+
tool_calls: list[ToolCall] = [block for block in msg.content if block.type == "toolCall"] # type: ignore[misc]
|
|
1277
|
+
signed_details = None
|
|
1278
|
+
for block in thinking_blocks:
|
|
1279
|
+
signed_details = _parse_reasoning_details(block.thinking_signature)
|
|
1280
|
+
if signed_details is not None:
|
|
1281
|
+
break
|
|
1282
|
+
legacy_details = [
|
|
1283
|
+
detail
|
|
1284
|
+
for detail in (_parse_legacy_encrypted_reasoning_detail(call.thought_signature) for call in tool_calls)
|
|
1285
|
+
if detail is not None
|
|
1286
|
+
]
|
|
1287
|
+
preserved_details = signed_details if signed_details is not None else (legacy_details or None)
|
|
1288
|
+
|
|
1289
|
+
non_empty_thinking = [block for block in thinking_blocks if _js_trim(block.thinking)]
|
|
1290
|
+
if non_empty_thinking:
|
|
1291
|
+
if _truthy(compat.get("requiresThinkingAsText")):
|
|
1292
|
+
# Convert thinking blocks to plain text (no tags to avoid the model mimicking them).
|
|
1293
|
+
thinking_text = "\n\n".join(sanitize_surrogates(block.thinking) for block in non_empty_thinking)
|
|
1294
|
+
assistant["content"] = [{"type": "text", "text": thinking_text}, *text_parts]
|
|
1295
|
+
else:
|
|
1296
|
+
# Always send assistant content as a plain string (OpenAI Chat Completions API
|
|
1297
|
+
# standard format). Sending an array of {type:"text", text:"..."} objects is
|
|
1298
|
+
# non-standard and causes some models (e.g. DeepSeek V3.2 via NVIDIA NIM) to mirror
|
|
1299
|
+
# the content-block structure literally in their output.
|
|
1300
|
+
if assistant_text:
|
|
1301
|
+
assistant["content"] = assistant_text
|
|
1302
|
+
# reasoning_details is the structured alternative to a raw reasoning field.
|
|
1303
|
+
if preserved_details is None:
|
|
1304
|
+
# Use the signature from the first thinking block if available (llama.cpp server + gpt-oss).
|
|
1305
|
+
signature = non_empty_thinking[0].thinking_signature
|
|
1306
|
+
if model.provider == "opencode-go" and signature == "reasoning":
|
|
1307
|
+
signature = "reasoning_content"
|
|
1308
|
+
if signature and signature in _REPLAY_REASONING_FIELDS:
|
|
1309
|
+
assistant[signature] = "\n".join(block.thinking for block in non_empty_thinking)
|
|
1310
|
+
elif assistant_text:
|
|
1311
|
+
assistant["content"] = assistant_text
|
|
1312
|
+
|
|
1313
|
+
if tool_calls:
|
|
1314
|
+
converted: list[dict[str, Any]] = []
|
|
1315
|
+
for call in tool_calls:
|
|
1316
|
+
custom_property = grammar_properties.get(call.name) if grammar_properties else None
|
|
1317
|
+
if custom_property is not None:
|
|
1318
|
+
converted.append(
|
|
1319
|
+
{
|
|
1320
|
+
"id": call.id,
|
|
1321
|
+
"type": "custom",
|
|
1322
|
+
"custom": {
|
|
1323
|
+
"name": call.name,
|
|
1324
|
+
"input": sanitize_surrogates(
|
|
1325
|
+
get_grammar_tool_input(call.name, call.arguments, custom_property)
|
|
1326
|
+
),
|
|
1327
|
+
},
|
|
1328
|
+
}
|
|
1329
|
+
)
|
|
1330
|
+
else:
|
|
1331
|
+
converted.append(
|
|
1332
|
+
{
|
|
1333
|
+
"id": call.id,
|
|
1334
|
+
"type": "function",
|
|
1335
|
+
"function": {"name": call.name, "arguments": fastjson.dumps(call.arguments)},
|
|
1336
|
+
}
|
|
1337
|
+
)
|
|
1338
|
+
assistant["tool_calls"] = converted
|
|
1339
|
+
if preserved_details is not None:
|
|
1340
|
+
assistant["reasoning_details"] = preserved_details
|
|
1341
|
+
if (
|
|
1342
|
+
_truthy(compat.get("requiresReasoningContentOnAssistantMessages"))
|
|
1343
|
+
and model.reasoning
|
|
1344
|
+
and "reasoning_content" not in assistant
|
|
1345
|
+
):
|
|
1346
|
+
assistant["reasoning_content"] = ""
|
|
1347
|
+
|
|
1348
|
+
# Skip assistant messages that have no content and no tool calls. Some providers require
|
|
1349
|
+
# "either content or tool_calls, but not none", and others reject empty assistant messages.
|
|
1350
|
+
# This handles aborted assistant responses that got no content.
|
|
1351
|
+
content = assistant["content"]
|
|
1352
|
+
if not content and "tool_calls" not in assistant:
|
|
1353
|
+
return False
|
|
1354
|
+
params.append(assistant)
|
|
1355
|
+
return True
|
|
1356
|
+
|
|
1357
|
+
|
|
1358
|
+
def convert_tools(tools: Sequence[Tool], compat: Mapping[str, Any]) -> list[dict[str, Any]]:
|
|
1359
|
+
"""Convert pi tools to Chat Completions tool definitions (function or grammar ``custom`` tools)."""
|
|
1360
|
+
supports_strict = compat.get("supportsStrictMode") is not False
|
|
1361
|
+
supports_grammar = _truthy(compat.get("supportsOpenAIGrammarTools"))
|
|
1362
|
+
result: list[dict[str, Any]] = []
|
|
1363
|
+
for tool in tools:
|
|
1364
|
+
grammar = resolve_grammar_constrained_sampling(tool, supports_grammar)
|
|
1365
|
+
if grammar is not None:
|
|
1366
|
+
result.append(
|
|
1367
|
+
{
|
|
1368
|
+
"type": "custom",
|
|
1369
|
+
"custom": {
|
|
1370
|
+
"name": tool.name,
|
|
1371
|
+
"description": tool.description,
|
|
1372
|
+
"format": {
|
|
1373
|
+
"type": "grammar",
|
|
1374
|
+
"grammar": {"syntax": grammar.format, "definition": grammar.definition},
|
|
1375
|
+
},
|
|
1376
|
+
},
|
|
1377
|
+
}
|
|
1378
|
+
)
|
|
1379
|
+
continue
|
|
1380
|
+
strict = resolve_json_schema_strict_sampling(tool, supports_strict)
|
|
1381
|
+
function: dict[str, Any] = {
|
|
1382
|
+
"name": tool.name,
|
|
1383
|
+
"description": tool.description,
|
|
1384
|
+
"parameters": get_json_schema_tool_parameters(tool, strict),
|
|
1385
|
+
}
|
|
1386
|
+
# Only include strict if the provider supports it. Some reject unknown fields.
|
|
1387
|
+
if supports_strict:
|
|
1388
|
+
function["strict"] = strict if strict is not None else False
|
|
1389
|
+
result.append({"type": "function", "function": function})
|
|
1390
|
+
return result
|
|
1391
|
+
|
|
1392
|
+
|
|
1393
|
+
# ---------------------------------------------------------------------------
|
|
1394
|
+
# Usage and stop reasons
|
|
1395
|
+
# ---------------------------------------------------------------------------
|
|
1396
|
+
|
|
1397
|
+
|
|
1398
|
+
def _parse_chunk_usage(raw_usage: Any, model: Model) -> Usage:
|
|
1399
|
+
raw = raw_usage if isinstance(raw_usage, dict) else {}
|
|
1400
|
+
prompt_tokens = raw.get("prompt_tokens") or 0
|
|
1401
|
+
prompt_details = raw.get("prompt_tokens_details")
|
|
1402
|
+
cache_read = _prop(prompt_details, "cached_tokens")
|
|
1403
|
+
if cache_read is None:
|
|
1404
|
+
cache_read = raw.get("prompt_cache_hit_tokens")
|
|
1405
|
+
if cache_read is None:
|
|
1406
|
+
cache_read = raw.get("cached_tokens")
|
|
1407
|
+
if cache_read is None:
|
|
1408
|
+
cache_read = 0
|
|
1409
|
+
cache_write = _prop(prompt_details, "cache_write_tokens") or 0
|
|
1410
|
+
|
|
1411
|
+
# Follow documented OpenAI/OpenRouter semantics: cached_tokens is cache-read tokens (hits).
|
|
1412
|
+
# Providers disagree on placement: OpenAI/OpenRouter use prompt_tokens_details.cached_tokens,
|
|
1413
|
+
# DeepSeek uses prompt_cache_hit_tokens, and Kimi documents top-level usage.cached_tokens on
|
|
1414
|
+
# the final usage chunk. OpenAI does not document or emit cache_write_tokens, but
|
|
1415
|
+
# OpenRouter-compatible providers can include it as a separate write count
|
|
1416
|
+
# (https://github.com/OpenRouterTeam/ai-sdk-provider/pull/409). Do not subtract writes from
|
|
1417
|
+
# cached_tokens, otherwise spec-compliant providers are under-reported
|
|
1418
|
+
# (DS4 mirrors this contract too: https://github.com/antirez/ds4/pull/29).
|
|
1419
|
+
input_tokens = max(0, prompt_tokens - cache_read - cache_write)
|
|
1420
|
+
# OpenAI completion_tokens already includes reasoning_tokens.
|
|
1421
|
+
output_tokens = raw.get("completion_tokens") or 0
|
|
1422
|
+
usage = Usage(
|
|
1423
|
+
input=input_tokens,
|
|
1424
|
+
output=output_tokens,
|
|
1425
|
+
cache_read=cache_read,
|
|
1426
|
+
cache_write=cache_write,
|
|
1427
|
+
total_tokens=input_tokens + output_tokens + cache_read + cache_write,
|
|
1428
|
+
reasoning=_prop(raw.get("completion_tokens_details"), "reasoning_tokens") or 0,
|
|
1429
|
+
)
|
|
1430
|
+
calculate_cost(model, usage)
|
|
1431
|
+
return usage
|
|
1432
|
+
|
|
1433
|
+
|
|
1434
|
+
def _map_stop_reason(reason: Any) -> tuple[str, str | None]:
|
|
1435
|
+
if reason is None:
|
|
1436
|
+
return "stop", None
|
|
1437
|
+
if reason in ("stop", "end"):
|
|
1438
|
+
return "stop", None
|
|
1439
|
+
if reason == "length":
|
|
1440
|
+
return "length", None
|
|
1441
|
+
if reason in ("function_call", "tool_calls"):
|
|
1442
|
+
return "toolUse", None
|
|
1443
|
+
return "error", f"Provider finish_reason: {_js_string(reason)}"
|
|
1444
|
+
|
|
1445
|
+
|
|
1446
|
+
# ---------------------------------------------------------------------------
|
|
1447
|
+
# Compatibility detection
|
|
1448
|
+
# ---------------------------------------------------------------------------
|
|
1449
|
+
|
|
1450
|
+
|
|
1451
|
+
def detect_compat(model: Model) -> ResolvedOpenAICompletionsCompat:
|
|
1452
|
+
"""Auto-detect compatibility settings from the provider id and base URL.
|
|
1453
|
+
|
|
1454
|
+
Used as the base when ``model.compat`` is not set; explicit ``model.compat``
|
|
1455
|
+
entries override these detected values (see :func:`get_compat`).
|
|
1456
|
+
"""
|
|
1457
|
+
provider = model.provider
|
|
1458
|
+
base_url = model.base_url
|
|
1459
|
+
|
|
1460
|
+
is_zai = provider in ("zai", "zai-coding-cn") or "api.z.ai" in base_url or "open.bigmodel.cn" in base_url
|
|
1461
|
+
is_together = provider == "together" or "api.together.ai" in base_url or "api.together.xyz" in base_url
|
|
1462
|
+
is_moonshot = provider in ("moonshotai", "moonshotai-cn") or "api.moonshot." in base_url
|
|
1463
|
+
is_open_router = provider == "openrouter" or "openrouter.ai" in base_url
|
|
1464
|
+
is_cloudflare_workers_ai = provider == "cloudflare-workers-ai" or "api.cloudflare.com" in base_url
|
|
1465
|
+
is_cloudflare_ai_gateway = provider == "cloudflare-ai-gateway" or "gateway.ai.cloudflare.com" in base_url
|
|
1466
|
+
is_nvidia = provider == "nvidia" or "integrate.api.nvidia.com" in base_url
|
|
1467
|
+
is_ant_ling = provider == "ant-ling" or "api.ant-ling.com" in base_url
|
|
1468
|
+
is_cerebras = provider == "cerebras" or "cerebras.ai" in base_url
|
|
1469
|
+
is_deepseek = provider == "deepseek" or "deepseek.com" in base_url.lower()
|
|
1470
|
+
is_chutes = "chutes.ai" in base_url
|
|
1471
|
+
is_grok = provider == "xai" or "api.x.ai" in base_url
|
|
1472
|
+
|
|
1473
|
+
is_non_standard = (
|
|
1474
|
+
is_nvidia
|
|
1475
|
+
or is_cerebras
|
|
1476
|
+
or is_grok
|
|
1477
|
+
or is_together
|
|
1478
|
+
or is_chutes
|
|
1479
|
+
or is_deepseek
|
|
1480
|
+
or is_zai
|
|
1481
|
+
or is_moonshot
|
|
1482
|
+
or provider == "opencode"
|
|
1483
|
+
or "opencode.ai" in base_url
|
|
1484
|
+
or is_cloudflare_workers_ai
|
|
1485
|
+
or is_cloudflare_ai_gateway
|
|
1486
|
+
or is_ant_ling
|
|
1487
|
+
)
|
|
1488
|
+
use_max_tokens = (
|
|
1489
|
+
is_chutes
|
|
1490
|
+
or is_deepseek
|
|
1491
|
+
or is_moonshot
|
|
1492
|
+
or is_cloudflare_ai_gateway
|
|
1493
|
+
or is_together
|
|
1494
|
+
or is_nvidia
|
|
1495
|
+
or is_ant_ling
|
|
1496
|
+
or is_zai
|
|
1497
|
+
)
|
|
1498
|
+
is_open_router_developer_role_model = is_open_router and (
|
|
1499
|
+
model.id.startswith("anthropic/") or model.id.startswith("openai/")
|
|
1500
|
+
)
|
|
1501
|
+
cache_control_format = "anthropic" if provider == "openrouter" and model.id.startswith("anthropic/") else None
|
|
1502
|
+
|
|
1503
|
+
if is_deepseek:
|
|
1504
|
+
thinking_format = "deepseek"
|
|
1505
|
+
elif is_zai:
|
|
1506
|
+
thinking_format = "zai"
|
|
1507
|
+
elif is_together:
|
|
1508
|
+
thinking_format = "together"
|
|
1509
|
+
elif is_ant_ling:
|
|
1510
|
+
thinking_format = "ant-ling"
|
|
1511
|
+
elif is_open_router:
|
|
1512
|
+
thinking_format = "openrouter"
|
|
1513
|
+
else:
|
|
1514
|
+
thinking_format = "openai"
|
|
1515
|
+
|
|
1516
|
+
return {
|
|
1517
|
+
"supportsStore": not is_non_standard,
|
|
1518
|
+
"supportsDeveloperRole": is_open_router_developer_role_model or (not is_non_standard and not is_open_router),
|
|
1519
|
+
"supportsReasoningEffort": not (
|
|
1520
|
+
is_grok or is_zai or is_moonshot or is_together or is_cloudflare_ai_gateway or is_nvidia or is_ant_ling
|
|
1521
|
+
),
|
|
1522
|
+
"supportsUsageInStreaming": True,
|
|
1523
|
+
"supportsFinishReason": True,
|
|
1524
|
+
"maxTokensField": "max_tokens" if use_max_tokens else "max_completion_tokens",
|
|
1525
|
+
"requiresToolResultName": False,
|
|
1526
|
+
"requiresAssistantAfterToolResult": False,
|
|
1527
|
+
"requiresThinkingAsText": False,
|
|
1528
|
+
"requiresReasoningContentOnAssistantMessages": is_deepseek,
|
|
1529
|
+
"thinkingFormat": thinking_format,
|
|
1530
|
+
"openRouterRouting": {},
|
|
1531
|
+
"vercelGatewayRouting": {},
|
|
1532
|
+
"chatTemplateKwargs": {},
|
|
1533
|
+
"chatTemplateArgs": {},
|
|
1534
|
+
"zaiToolStream": False,
|
|
1535
|
+
"supportsThinkingTokenBudget": False,
|
|
1536
|
+
"thinkingTokenBudgetField": None,
|
|
1537
|
+
# OpenAI compatibility alone does not imply strict JSON-schema tool support.
|
|
1538
|
+
"supportsStrictMode": False,
|
|
1539
|
+
"supportsOpenAIGrammarTools": False,
|
|
1540
|
+
"supportsMidConvoSystemMessages": False,
|
|
1541
|
+
"supportsMidConvoToolAdditions": False,
|
|
1542
|
+
"cacheControlFormat": cache_control_format,
|
|
1543
|
+
"sendSessionAffinityHeaders": is_open_router,
|
|
1544
|
+
"sessionAffinityFormat": "openrouter" if is_open_router else "openai",
|
|
1545
|
+
"supportsLongCacheRetention": not (
|
|
1546
|
+
is_together or is_cloudflare_workers_ai or is_cloudflare_ai_gateway or is_nvidia or is_ant_ling
|
|
1547
|
+
),
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
|
|
1551
|
+
_COMPAT_KEYS = (
|
|
1552
|
+
"supportsStore",
|
|
1553
|
+
"supportsDeveloperRole",
|
|
1554
|
+
"supportsReasoningEffort",
|
|
1555
|
+
"supportsUsageInStreaming",
|
|
1556
|
+
"supportsFinishReason",
|
|
1557
|
+
"maxTokensField",
|
|
1558
|
+
"requiresToolResultName",
|
|
1559
|
+
"requiresAssistantAfterToolResult",
|
|
1560
|
+
"requiresThinkingAsText",
|
|
1561
|
+
"requiresReasoningContentOnAssistantMessages",
|
|
1562
|
+
"thinkingFormat",
|
|
1563
|
+
"openRouterRouting",
|
|
1564
|
+
"vercelGatewayRouting",
|
|
1565
|
+
"chatTemplateKwargs",
|
|
1566
|
+
"chatTemplateArgs",
|
|
1567
|
+
"zaiToolStream",
|
|
1568
|
+
"supportsThinkingTokenBudget",
|
|
1569
|
+
"thinkingTokenBudgetField",
|
|
1570
|
+
"supportsStrictMode",
|
|
1571
|
+
"supportsOpenAIGrammarTools",
|
|
1572
|
+
"supportsMidConvoSystemMessages",
|
|
1573
|
+
"supportsMidConvoToolAdditions",
|
|
1574
|
+
"cacheControlFormat",
|
|
1575
|
+
"sendSessionAffinityHeaders",
|
|
1576
|
+
"sessionAffinityFormat",
|
|
1577
|
+
"supportsLongCacheRetention",
|
|
1578
|
+
)
|
|
1579
|
+
|
|
1580
|
+
|
|
1581
|
+
def get_compat(model: Model) -> ResolvedOpenAICompletionsCompat:
|
|
1582
|
+
"""Resolved compatibility settings: auto-detection overridden by explicit ``model.compat``."""
|
|
1583
|
+
detected = detect_compat(model)
|
|
1584
|
+
model_compat = model.compat
|
|
1585
|
+
if not model_compat:
|
|
1586
|
+
return detected
|
|
1587
|
+
resolved: ResolvedOpenAICompletionsCompat = {}
|
|
1588
|
+
for key in _COMPAT_KEYS:
|
|
1589
|
+
value = model_compat.get(key)
|
|
1590
|
+
resolved[key] = value if value is not None else detected[key]
|
|
1591
|
+
open_router_routing = model_compat.get("openRouterRouting")
|
|
1592
|
+
resolved["openRouterRouting"] = open_router_routing if open_router_routing is not None else {}
|
|
1593
|
+
resolved["vllmPriority"] = model_compat.get("vllmPriority")
|
|
1594
|
+
return resolved
|