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
pi_ai/api/pi_messages.py
ADDED
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
# pyright: reportAttributeAccessIssue=false, reportArgumentType=false
|
|
2
|
+
# Converters branch on ``role``/``type`` discriminators held in locals, which pyright does not narrow.
|
|
3
|
+
"""pi-messages API implementation.
|
|
4
|
+
|
|
5
|
+
Streams pi's own message protocol directly to a backend: the request is a single
|
|
6
|
+
POST of ``{model, context, options}`` to ``<base_url>/messages``, the response is
|
|
7
|
+
an SSE stream of serialized assistant-message events plus a terminal
|
|
8
|
+
``done``/``error`` event. This is the wire protocol spoken by the Radius gateway,
|
|
9
|
+
but any backend implementing it can be used, e.g. via a models.json custom
|
|
10
|
+
provider with ``"api": "pi-messages"``.
|
|
11
|
+
|
|
12
|
+
``context`` is sent in pi's camelCase JSON (:func:`pi_ai.serialization.to_json_value`),
|
|
13
|
+
so the wire format is the one the TypeScript implementation produces.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
from collections.abc import AsyncIterator, Mapping
|
|
20
|
+
from typing import Any, Literal, TypeAlias, TypedDict
|
|
21
|
+
|
|
22
|
+
import httpx
|
|
23
|
+
|
|
24
|
+
from ..abort import abort_scope
|
|
25
|
+
from ..event_stream import AssistantMessageEventStream
|
|
26
|
+
from ..serialization import from_dict, snake_case, to_json_value
|
|
27
|
+
from ..types import (
|
|
28
|
+
AssistantMessage,
|
|
29
|
+
AssistantMessageEvent,
|
|
30
|
+
CacheRetention,
|
|
31
|
+
DoneEvent,
|
|
32
|
+
ErrorEvent,
|
|
33
|
+
Model,
|
|
34
|
+
ProviderResponse,
|
|
35
|
+
StartEvent,
|
|
36
|
+
StreamOptions,
|
|
37
|
+
TextContent,
|
|
38
|
+
TextDeltaEvent,
|
|
39
|
+
TextEndEvent,
|
|
40
|
+
TextStartEvent,
|
|
41
|
+
ThinkingContent,
|
|
42
|
+
ThinkingDeltaEvent,
|
|
43
|
+
ThinkingEndEvent,
|
|
44
|
+
ThinkingLevel,
|
|
45
|
+
ThinkingStartEvent,
|
|
46
|
+
ToolCall,
|
|
47
|
+
ToolCallDeltaEvent,
|
|
48
|
+
ToolCallEndEvent,
|
|
49
|
+
ToolCallStartEvent,
|
|
50
|
+
TranscriptContext,
|
|
51
|
+
Usage,
|
|
52
|
+
now_ms,
|
|
53
|
+
)
|
|
54
|
+
from ..utils import fastjson
|
|
55
|
+
from ..utils.callbacks import maybe_await
|
|
56
|
+
from ..utils.diagnostics import (
|
|
57
|
+
AssistantMessageDiagnostic,
|
|
58
|
+
append_assistant_message_diagnostic,
|
|
59
|
+
create_assistant_message_diagnostic,
|
|
60
|
+
)
|
|
61
|
+
from ..utils.http import ProviderHttpError, build_headers, send_request
|
|
62
|
+
from ..utils.json_parse import StreamingJsonParser
|
|
63
|
+
from ..utils.misc import get_provider_env_value, headers_to_record, provider_headers_to_record
|
|
64
|
+
from ..utils.text import append_text, append_thinking
|
|
65
|
+
|
|
66
|
+
__all__ = [
|
|
67
|
+
"PiMessagesOptions",
|
|
68
|
+
"PiMessagesToolChoice",
|
|
69
|
+
"PiMessagesRewriteImpact",
|
|
70
|
+
"PiMessagesEvent",
|
|
71
|
+
"PiMessagesResponseError",
|
|
72
|
+
"stream",
|
|
73
|
+
"stream_simple",
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
PiMessagesToolChoice: TypeAlias = "Literal['auto', 'none', 'required'] | dict[str, Any]"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class PiMessagesOptions(StreamOptions, total=False):
|
|
80
|
+
reasoning: ThinkingLevel
|
|
81
|
+
#: ``"auto" | "none" | "required"`` or ``{"type": "function", "function": {"name": ...}}``.
|
|
82
|
+
tool_choice: PiMessagesToolChoice
|
|
83
|
+
#: Ask the backend for debug metadata (e.g. routing response headers).
|
|
84
|
+
debug: bool
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class PiMessagesRewriteImpact(TypedDict):
|
|
88
|
+
"""Impact summary of a server-side message rewrite (e.g. a gateway policy). Wire JSON, camelCase keys."""
|
|
89
|
+
|
|
90
|
+
policyId: str
|
|
91
|
+
policyVersion: int
|
|
92
|
+
changed: bool
|
|
93
|
+
tokenCountChange: int
|
|
94
|
+
messageCountChange: int
|
|
95
|
+
systemPromptChanged: bool
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
#: Serialized assistant-message event as sent by a pi-messages backend (camelCase wire JSON):
|
|
99
|
+
#:
|
|
100
|
+
#: * ``{"type": "start"}``
|
|
101
|
+
#: * ``{"type": "text_start" | "thinking_start", "contentIndex": n}``
|
|
102
|
+
#: * ``{"type": "text_delta" | "thinking_delta" | "toolcall_delta", "contentIndex": n, "delta": str}``
|
|
103
|
+
#: * ``{"type": "text_end", "contentIndex": n, "content": str, "contentSignature"?: str}``
|
|
104
|
+
#: * ``{"type": "thinking_end", "contentIndex": n, "content": str, "contentSignature"?: str, "redacted"?: bool}``
|
|
105
|
+
#: * ``{"type": "toolcall_start", "contentIndex": n, "id": str, "toolName": str}``
|
|
106
|
+
#: * ``{"type": "toolcall_end", "contentIndex": n, "toolCall": {...}}``
|
|
107
|
+
#: * ``{"type": "done", "reason": "stop" | "length" | "toolUse", "usage": {...}, "responseId"?: str,
|
|
108
|
+
#: "providerThinkingLevel"?: str, "rewrite"?: PiMessagesRewriteImpact}``
|
|
109
|
+
#: * ``{"type": "error", "reason": "aborted" | "error", "usage": {...}, "errorMessage"?: str, "responseId"?: str,
|
|
110
|
+
#: "providerThinkingLevel"?: str, "rewrite"?: PiMessagesRewriteImpact}``
|
|
111
|
+
PiMessagesEvent: TypeAlias = dict[str, Any]
|
|
112
|
+
|
|
113
|
+
_MAX_DIAGNOSTIC_STRING = 8192
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class PiMessagesResponseError(Exception):
|
|
117
|
+
"""A non-2xx pi-messages response. ``diagnostic_details`` is attached to the error message's diagnostics."""
|
|
118
|
+
|
|
119
|
+
def __init__(self, message: str, code: str | None, diagnostic_details: dict[str, Any]) -> None:
|
|
120
|
+
super().__init__(message)
|
|
121
|
+
self.code = code
|
|
122
|
+
self.diagnostic_details = diagnostic_details
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _parse_error_body(body: str) -> dict[str, Any] | None:
|
|
126
|
+
try:
|
|
127
|
+
parsed = json.loads(body)
|
|
128
|
+
except ValueError:
|
|
129
|
+
return None
|
|
130
|
+
if isinstance(parsed, dict) and isinstance(parsed.get("error"), dict):
|
|
131
|
+
return parsed
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _truncate_diagnostic_string(value: str) -> str:
|
|
136
|
+
return f"{value[:_MAX_DIAGNOSTIC_STRING]}…" if len(value) > _MAX_DIAGNOSTIC_STRING else value
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _create_response_error(model: Model, url: str, status: int, body: str) -> PiMessagesResponseError:
|
|
140
|
+
error_body = _parse_error_body(body)
|
|
141
|
+
error = error_body["error"] if error_body is not None else None
|
|
142
|
+
message = error.get("message") if error is not None else None
|
|
143
|
+
code = error.get("code") if error is not None else None
|
|
144
|
+
message = message if isinstance(message, str) else None
|
|
145
|
+
code = code if isinstance(code, str) else None
|
|
146
|
+
status_text = httpx.codes.get_reason_phrase(status)
|
|
147
|
+
suffix = message if message is not None else body
|
|
148
|
+
code_suffix = f" ({code})" if code else ""
|
|
149
|
+
details: dict[str, Any] = {
|
|
150
|
+
"version": 1,
|
|
151
|
+
"provider": model.provider,
|
|
152
|
+
"model": model.id,
|
|
153
|
+
"url": url,
|
|
154
|
+
"status": status,
|
|
155
|
+
"statusText": status_text,
|
|
156
|
+
}
|
|
157
|
+
if error is not None:
|
|
158
|
+
details["error"] = error
|
|
159
|
+
else:
|
|
160
|
+
details["body"] = _truncate_diagnostic_string(body)
|
|
161
|
+
details["timestampMs"] = now_ms()
|
|
162
|
+
return PiMessagesResponseError(f"{status} {status_text}: {suffix}{code_suffix}", code, details)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _append_rewrite_diagnostic(message: AssistantMessage, rewrite: Any) -> None:
|
|
166
|
+
if not rewrite:
|
|
167
|
+
return
|
|
168
|
+
append_assistant_message_diagnostic(
|
|
169
|
+
message,
|
|
170
|
+
AssistantMessageDiagnostic(type="pi_messages_rewrite", timestamp=now_ms(), details=dict(rewrite)),
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _usage(value: Any) -> Usage:
|
|
175
|
+
return from_dict(Usage, value) if isinstance(value, Mapping) else Usage()
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
#: Wire ``toolCall`` keys that are not attributes of :class:`ToolCall`.
|
|
179
|
+
_TOOL_CALL_SKIP = frozenset({"type"})
|
|
180
|
+
_TOOL_CALL_FIELDS = frozenset(ToolCall.__slots__) # type: ignore[attr-defined]
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class _EventConverter:
|
|
184
|
+
"""Folds wire events into one live partial message and returns the matching pi event."""
|
|
185
|
+
|
|
186
|
+
__slots__ = ("_blocks", "_tool_json", "partial")
|
|
187
|
+
|
|
188
|
+
def __init__(self, model: Model) -> None:
|
|
189
|
+
self.partial = AssistantMessage(
|
|
190
|
+
[],
|
|
191
|
+
api=model.api,
|
|
192
|
+
provider=model.provider,
|
|
193
|
+
model=model.id,
|
|
194
|
+
usage=Usage(),
|
|
195
|
+
stop_reason="pending",
|
|
196
|
+
timestamp=now_ms(),
|
|
197
|
+
)
|
|
198
|
+
# Blocks by wire content index (pi's content array may be addressed sparsely).
|
|
199
|
+
self._blocks: dict[int, Any] = {}
|
|
200
|
+
self._tool_json: dict[int, StreamingJsonParser] = {}
|
|
201
|
+
|
|
202
|
+
def _set_block(self, index: int, block: Any) -> None:
|
|
203
|
+
content = self.partial.content
|
|
204
|
+
if 0 <= index < len(content):
|
|
205
|
+
content[index] = block
|
|
206
|
+
else:
|
|
207
|
+
content.append(block)
|
|
208
|
+
self._blocks[index] = block
|
|
209
|
+
|
|
210
|
+
def _finish(self, event: PiMessagesEvent) -> None:
|
|
211
|
+
partial = self.partial
|
|
212
|
+
partial.stop_reason = event.get("reason") # type: ignore[assignment]
|
|
213
|
+
partial.usage = _usage(event.get("usage"))
|
|
214
|
+
partial.response_id = event.get("responseId")
|
|
215
|
+
provider_thinking_level = event.get("providerThinkingLevel")
|
|
216
|
+
if provider_thinking_level is not None:
|
|
217
|
+
partial.provider_thinking_level = provider_thinking_level
|
|
218
|
+
_append_rewrite_diagnostic(partial, event.get("rewrite"))
|
|
219
|
+
|
|
220
|
+
def convert(self, event: PiMessagesEvent) -> AssistantMessageEvent | None:
|
|
221
|
+
partial = self.partial
|
|
222
|
+
kind = event.get("type")
|
|
223
|
+
if kind == "done":
|
|
224
|
+
self._finish(event)
|
|
225
|
+
return DoneEvent(event.get("reason"), partial) # type: ignore[arg-type]
|
|
226
|
+
if kind == "error":
|
|
227
|
+
self._finish(event)
|
|
228
|
+
partial.error_message = event.get("errorMessage")
|
|
229
|
+
return ErrorEvent(event.get("reason"), partial) # type: ignore[arg-type]
|
|
230
|
+
if kind == "start":
|
|
231
|
+
return StartEvent(partial)
|
|
232
|
+
index = event.get("contentIndex")
|
|
233
|
+
if kind == "text_start":
|
|
234
|
+
self._set_block(index, TextContent(""))
|
|
235
|
+
return TextStartEvent(index, partial)
|
|
236
|
+
if kind == "text_delta":
|
|
237
|
+
delta = event["delta"]
|
|
238
|
+
append_text(self._blocks[index], delta)
|
|
239
|
+
return TextDeltaEvent(index, delta, partial)
|
|
240
|
+
if kind == "text_end":
|
|
241
|
+
block = self._blocks[index]
|
|
242
|
+
block.text = event["content"]
|
|
243
|
+
block.text_signature = event.get("contentSignature")
|
|
244
|
+
return TextEndEvent(index, event["content"], partial)
|
|
245
|
+
if kind == "thinking_start":
|
|
246
|
+
self._set_block(index, ThinkingContent(""))
|
|
247
|
+
return ThinkingStartEvent(index, partial)
|
|
248
|
+
if kind == "thinking_delta":
|
|
249
|
+
delta = event["delta"]
|
|
250
|
+
append_thinking(self._blocks[index], delta)
|
|
251
|
+
return ThinkingDeltaEvent(index, delta, partial)
|
|
252
|
+
if kind == "thinking_end":
|
|
253
|
+
block = self._blocks[index]
|
|
254
|
+
block.thinking = event["content"]
|
|
255
|
+
block.thinking_signature = event.get("contentSignature")
|
|
256
|
+
block.redacted = event.get("redacted")
|
|
257
|
+
return ThinkingEndEvent(index, event["content"], partial)
|
|
258
|
+
if kind == "toolcall_start":
|
|
259
|
+
self._set_block(index, ToolCall(event.get("id"), event.get("toolName"), {})) # type: ignore[arg-type]
|
|
260
|
+
self._tool_json[index] = StreamingJsonParser()
|
|
261
|
+
return ToolCallStartEvent(index, partial)
|
|
262
|
+
if kind == "toolcall_delta":
|
|
263
|
+
delta = event["delta"]
|
|
264
|
+
parser = self._tool_json.get(index)
|
|
265
|
+
if parser is None:
|
|
266
|
+
parser = self._tool_json[index] = StreamingJsonParser()
|
|
267
|
+
parser.feed(delta)
|
|
268
|
+
self._blocks[index].arguments = parser.value()
|
|
269
|
+
return ToolCallDeltaEvent(index, delta, partial)
|
|
270
|
+
if kind == "toolcall_end":
|
|
271
|
+
block = self._blocks[index]
|
|
272
|
+
for key, value in (event.get("toolCall") or {}).items():
|
|
273
|
+
if key in _TOOL_CALL_SKIP:
|
|
274
|
+
continue
|
|
275
|
+
name = snake_case(key)
|
|
276
|
+
if name in _TOOL_CALL_FIELDS:
|
|
277
|
+
setattr(block, name, value)
|
|
278
|
+
self._tool_json.pop(index, None) # pyright: ignore[reportCallIssue]
|
|
279
|
+
return ToolCallEndEvent(index, block, partial)
|
|
280
|
+
# Unknown event types have no pi event to map to.
|
|
281
|
+
return None
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _parse_event(raw: str) -> PiMessagesEvent | None:
|
|
285
|
+
data: str | None = None
|
|
286
|
+
for line in raw.split("\n"):
|
|
287
|
+
if line.startswith("data:"):
|
|
288
|
+
data = line[5:].strip()
|
|
289
|
+
break
|
|
290
|
+
if not data or data == "[DONE]":
|
|
291
|
+
return None
|
|
292
|
+
return fastjson.loads(data)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
async def _read_events(response: httpx.Response) -> AsyncIterator[PiMessagesEvent]:
|
|
296
|
+
buffer = ""
|
|
297
|
+
async for chunk in response.aiter_text():
|
|
298
|
+
buffer = (buffer + chunk).replace("\r\n", "\n")
|
|
299
|
+
split = buffer.find("\n\n")
|
|
300
|
+
while split != -1:
|
|
301
|
+
event = _parse_event(buffer[:split])
|
|
302
|
+
if event is not None:
|
|
303
|
+
yield event
|
|
304
|
+
buffer = buffer[split + 2 :]
|
|
305
|
+
split = buffer.find("\n\n")
|
|
306
|
+
if buffer.strip():
|
|
307
|
+
event = _parse_event(buffer)
|
|
308
|
+
if event is not None:
|
|
309
|
+
yield event
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _create_error_event(model: Model, error: Exception, aborted: bool) -> ErrorEvent:
|
|
313
|
+
reason: Literal["aborted", "error"] = "aborted" if aborted else "error"
|
|
314
|
+
message = AssistantMessage(
|
|
315
|
+
[],
|
|
316
|
+
api=model.api,
|
|
317
|
+
provider=model.provider,
|
|
318
|
+
model=model.id,
|
|
319
|
+
usage=Usage(),
|
|
320
|
+
stop_reason=reason,
|
|
321
|
+
error_message=str(error),
|
|
322
|
+
timestamp=now_ms(),
|
|
323
|
+
)
|
|
324
|
+
if not aborted and isinstance(error, PiMessagesResponseError):
|
|
325
|
+
append_assistant_message_diagnostic(
|
|
326
|
+
message,
|
|
327
|
+
create_assistant_message_diagnostic("pi_messages_response_failure", error, error.diagnostic_details),
|
|
328
|
+
)
|
|
329
|
+
return ErrorEvent(reason, message)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _resolve_cache_retention(
|
|
333
|
+
cache_retention: CacheRetention | None, env: Mapping[str, str] | None
|
|
334
|
+
) -> CacheRetention | None:
|
|
335
|
+
if cache_retention:
|
|
336
|
+
return cache_retention
|
|
337
|
+
# Backend defaults apply when unset; only the legacy env opt-in is mapped.
|
|
338
|
+
return "long" if get_provider_env_value("PI_CACHE_RETENTION", env) == "long" else None
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _messages_url(model: Model, debug: bool) -> str:
|
|
342
|
+
url = f"{model.base_url.rstrip('/')}/messages"
|
|
343
|
+
if debug:
|
|
344
|
+
url = str(httpx.URL(url).copy_set_param("debug", "1"))
|
|
345
|
+
return url
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
async def _notify_response(options: Mapping[str, Any], model: Model, status: int, headers: Any) -> None:
|
|
349
|
+
on_response = options.get("on_response")
|
|
350
|
+
if on_response is not None:
|
|
351
|
+
await maybe_await(on_response(ProviderResponse(status, headers_to_record(headers or {})), model))
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def stream(
|
|
355
|
+
model: Model, context: TranscriptContext, options: Mapping[str, Any] | None = None
|
|
356
|
+
) -> AssistantMessageEventStream:
|
|
357
|
+
"""Stream a response from a pi-messages backend (``POST <base_url>/messages``)."""
|
|
358
|
+
opts: dict[str, Any] = dict(options or {})
|
|
359
|
+
out = AssistantMessageEventStream()
|
|
360
|
+
out.start(_run(model, context, opts, out, _EventConverter(model)))
|
|
361
|
+
return out
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
async def _run(
|
|
365
|
+
model: Model,
|
|
366
|
+
context: TranscriptContext,
|
|
367
|
+
options: dict[str, Any],
|
|
368
|
+
out: AssistantMessageEventStream,
|
|
369
|
+
converter: _EventConverter,
|
|
370
|
+
) -> None:
|
|
371
|
+
signal = options.get("signal")
|
|
372
|
+
try:
|
|
373
|
+
async with abort_scope(signal):
|
|
374
|
+
api_key = options.get("api_key")
|
|
375
|
+
if not api_key:
|
|
376
|
+
raise ValueError(f'No API key provided for provider "{model.provider}"')
|
|
377
|
+
|
|
378
|
+
url = _messages_url(model, bool(options.get("debug")))
|
|
379
|
+
request_options = {
|
|
380
|
+
"temperature": options.get("temperature"),
|
|
381
|
+
"maxTokens": options.get("max_tokens"),
|
|
382
|
+
"reasoning": options.get("reasoning"),
|
|
383
|
+
"cacheRetention": _resolve_cache_retention(options.get("cache_retention"), options.get("env")),
|
|
384
|
+
"sessionId": options.get("session_id"),
|
|
385
|
+
"toolChoice": options.get("tool_choice"),
|
|
386
|
+
}
|
|
387
|
+
payload: Any = {
|
|
388
|
+
"model": model.id,
|
|
389
|
+
"context": to_json_value(context),
|
|
390
|
+
"options": {key: value for key, value in request_options.items() if value is not None},
|
|
391
|
+
}
|
|
392
|
+
on_payload = options.get("on_payload")
|
|
393
|
+
if on_payload is not None:
|
|
394
|
+
next_payload = await maybe_await(on_payload(payload, model))
|
|
395
|
+
if next_payload is not None:
|
|
396
|
+
payload = next_payload
|
|
397
|
+
|
|
398
|
+
headers = build_headers(
|
|
399
|
+
{
|
|
400
|
+
"authorization": f"Bearer {api_key}",
|
|
401
|
+
"accept": "text/event-stream",
|
|
402
|
+
"content-type": "application/json",
|
|
403
|
+
},
|
|
404
|
+
provider_headers_to_record(options.get("headers")),
|
|
405
|
+
)
|
|
406
|
+
try:
|
|
407
|
+
# The backend owns retries: the request is sent once, like upstream's plain fetch.
|
|
408
|
+
response = await send_request(
|
|
409
|
+
"POST",
|
|
410
|
+
url,
|
|
411
|
+
headers=headers,
|
|
412
|
+
body=payload,
|
|
413
|
+
options={**options, "max_retries": 0},
|
|
414
|
+
error_style="raw",
|
|
415
|
+
)
|
|
416
|
+
except ProviderHttpError as error:
|
|
417
|
+
if error.status is None:
|
|
418
|
+
raise
|
|
419
|
+
await _notify_response(options, model, error.status, error.headers)
|
|
420
|
+
raise _create_response_error(model, url, error.status, error.raw_body or "") from error
|
|
421
|
+
try:
|
|
422
|
+
await _notify_response(options, model, response.status_code, response.headers)
|
|
423
|
+
if not response.is_success:
|
|
424
|
+
body = (await response.aread()).decode("utf-8", "replace")
|
|
425
|
+
raise _create_response_error(model, url, response.status_code, body)
|
|
426
|
+
on_event = options.get("on_provider_stream_event")
|
|
427
|
+
async for wire_event in _read_events(response):
|
|
428
|
+
if on_event is not None:
|
|
429
|
+
await maybe_await(on_event(wire_event, model))
|
|
430
|
+
event = converter.convert(wire_event)
|
|
431
|
+
if event is None:
|
|
432
|
+
continue
|
|
433
|
+
out.push(event)
|
|
434
|
+
if event.type == "done" or event.type == "error":
|
|
435
|
+
out.end()
|
|
436
|
+
return
|
|
437
|
+
finally:
|
|
438
|
+
await response.aclose()
|
|
439
|
+
raise RuntimeError(f"{model.provider} stream ended without a terminal event")
|
|
440
|
+
except Exception as error:
|
|
441
|
+
out.push(_create_error_event(model, error, signal is not None and signal.aborted))
|
|
442
|
+
out.end()
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def stream_simple(
|
|
446
|
+
model: Model, context: TranscriptContext, options: Mapping[str, Any] | None = None
|
|
447
|
+
) -> AssistantMessageEventStream:
|
|
448
|
+
"""Simple options pass straight through: ``reasoning``, ``tool_choice`` and ``debug`` are forwarded as is."""
|
|
449
|
+
return stream(model, context, options)
|
pi_ai/api/registry.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Lazy wrappers for the built-in API implementations.
|
|
2
|
+
|
|
3
|
+
Provider factories reference these instead of importing implementation modules,
|
|
4
|
+
so an implementation loads only on the first request to a model of that API
|
|
5
|
+
(the analog of pi's ``api/<id>.lazy.ts`` modules).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from .lazy import LazyApi, LazyOperation
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"API_MODULES",
|
|
14
|
+
"anthropic_messages_api",
|
|
15
|
+
"openai_completions_api",
|
|
16
|
+
"openai_responses_api",
|
|
17
|
+
"azure_openai_responses_api",
|
|
18
|
+
"openai_codex_responses_api",
|
|
19
|
+
"google_generative_ai_api",
|
|
20
|
+
"google_vertex_api",
|
|
21
|
+
"mistral_conversations_api",
|
|
22
|
+
"bedrock_converse_stream_api",
|
|
23
|
+
"pi_messages_api",
|
|
24
|
+
"openrouter_images_api",
|
|
25
|
+
"typesafe_system_one_api",
|
|
26
|
+
"cloudflare_workers_ai_system_one_api",
|
|
27
|
+
"llama_cpp_classify_api",
|
|
28
|
+
"openai_decisions_api",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
#: Chat API id -> implementation module.
|
|
32
|
+
API_MODULES: dict[str, str] = {
|
|
33
|
+
"anthropic-messages": "pi_ai.api.anthropic_messages",
|
|
34
|
+
"openai-completions": "pi_ai.api.openai_completions",
|
|
35
|
+
"openai-responses": "pi_ai.api.openai_responses",
|
|
36
|
+
"azure-openai-responses": "pi_ai.api.azure_openai_responses",
|
|
37
|
+
"openai-codex-responses": "pi_ai.api.openai_codex_responses",
|
|
38
|
+
"google-generative-ai": "pi_ai.api.google_generative_ai",
|
|
39
|
+
"google-vertex": "pi_ai.api.google_vertex",
|
|
40
|
+
"mistral-conversations": "pi_ai.api.mistral_conversations",
|
|
41
|
+
"bedrock-converse-stream": "pi_ai.api.bedrock_converse_stream",
|
|
42
|
+
"pi-messages": "pi_ai.api.pi_messages",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def anthropic_messages_api() -> LazyApi:
|
|
47
|
+
return LazyApi(API_MODULES["anthropic-messages"])
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def openai_completions_api() -> LazyApi:
|
|
51
|
+
return LazyApi(API_MODULES["openai-completions"])
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def openai_responses_api() -> LazyApi:
|
|
55
|
+
return LazyApi(API_MODULES["openai-responses"])
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def azure_openai_responses_api() -> LazyApi:
|
|
59
|
+
return LazyApi(API_MODULES["azure-openai-responses"])
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def openai_codex_responses_api() -> LazyApi:
|
|
63
|
+
return LazyApi(API_MODULES["openai-codex-responses"])
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def google_generative_ai_api() -> LazyApi:
|
|
67
|
+
return LazyApi(API_MODULES["google-generative-ai"])
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def google_vertex_api() -> LazyApi:
|
|
71
|
+
return LazyApi(API_MODULES["google-vertex"])
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def mistral_conversations_api() -> LazyApi:
|
|
75
|
+
return LazyApi(API_MODULES["mistral-conversations"])
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def bedrock_converse_stream_api() -> LazyApi:
|
|
79
|
+
return LazyApi(API_MODULES["bedrock-converse-stream"])
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def pi_messages_api() -> LazyApi:
|
|
83
|
+
return LazyApi(API_MODULES["pi-messages"])
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def openrouter_images_api() -> LazyOperation:
|
|
87
|
+
return LazyOperation("pi_ai.api.openrouter_images", "generate_images")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def typesafe_system_one_api() -> LazyOperation:
|
|
91
|
+
return LazyOperation("pi_ai.api.typesafe_system_one", "classify")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def cloudflare_workers_ai_system_one_api() -> LazyOperation:
|
|
95
|
+
return LazyOperation("pi_ai.api.cloudflare_workers_ai_system_one", "classify")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def llama_cpp_classify_api() -> LazyOperation:
|
|
99
|
+
return LazyOperation("pi_ai.api.llama_cpp_classify", "classify")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def openai_decisions_api() -> LazyOperation:
|
|
103
|
+
return LazyOperation("pi_ai.api.openai_decisions", "classify")
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Shared option handling for ``stream_simple`` implementations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from ..types import Model, TranscriptContext
|
|
9
|
+
from ..utils.estimate import estimate_context_tokens
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"CONTEXT_SAFETY_TOKENS",
|
|
13
|
+
"MIN_ANSWER_TOKENS",
|
|
14
|
+
"DEFAULT_THINKING_BUDGETS",
|
|
15
|
+
"clamp_max_tokens_to_context",
|
|
16
|
+
"resolve_sampling_params",
|
|
17
|
+
"build_base_options",
|
|
18
|
+
"clamp_reasoning",
|
|
19
|
+
"thinking_budget_for_level",
|
|
20
|
+
"clamp_thinking_budget_to_answer_room",
|
|
21
|
+
"adjust_max_tokens_for_thinking",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
CONTEXT_SAFETY_TOKENS = 4096
|
|
25
|
+
_MIN_MAX_TOKENS = 1
|
|
26
|
+
#: Tokens always left for the answer when a thinking budget shares the response ceiling.
|
|
27
|
+
MIN_ANSWER_TOKENS = 1024
|
|
28
|
+
DEFAULT_THINKING_BUDGETS: dict[str, int] = {"minimal": 1024, "low": 2048, "medium": 8192, "high": 16384}
|
|
29
|
+
|
|
30
|
+
_BASE_OPTION_KEYS = (
|
|
31
|
+
"temperature",
|
|
32
|
+
"signal",
|
|
33
|
+
"http_client",
|
|
34
|
+
"transport",
|
|
35
|
+
"cache_retention",
|
|
36
|
+
"session_id",
|
|
37
|
+
"headers",
|
|
38
|
+
"on_payload",
|
|
39
|
+
"on_response",
|
|
40
|
+
"on_provider_stream_event",
|
|
41
|
+
"timeout_ms",
|
|
42
|
+
"websocket_connect_timeout_ms",
|
|
43
|
+
"max_retries",
|
|
44
|
+
"max_retry_delay_ms",
|
|
45
|
+
"metadata",
|
|
46
|
+
"env",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def clamp_max_tokens_to_context(model: Model, context: TranscriptContext, max_tokens: int) -> int:
|
|
51
|
+
if model.context_window <= 0:
|
|
52
|
+
return max(_MIN_MAX_TOKENS, max_tokens)
|
|
53
|
+
available = model.context_window - estimate_context_tokens(context).tokens - CONTEXT_SAFETY_TOKENS
|
|
54
|
+
return min(max_tokens, max(_MIN_MAX_TOKENS, available))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def resolve_sampling_params(
|
|
58
|
+
model: Model, thinking_level: str, request_params: Mapping[str, Any] | None = None
|
|
59
|
+
) -> dict[str, Any] | None:
|
|
60
|
+
from ..models import clamp_thinking_level
|
|
61
|
+
|
|
62
|
+
effective = clamp_thinking_level(model, thinking_level) # type: ignore[arg-type]
|
|
63
|
+
level_params = (model.sampling_params_by_thinking_level or {}).get(effective)
|
|
64
|
+
if not model.sampling_params and not level_params and not request_params:
|
|
65
|
+
return None
|
|
66
|
+
return {**(model.sampling_params or {}), **(level_params or {}), **(request_params or {})}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def build_base_options(
|
|
70
|
+
model: Model, context: TranscriptContext, options: Mapping[str, Any] | None = None, api_key: str | None = None
|
|
71
|
+
) -> dict[str, Any]:
|
|
72
|
+
"""Translate simple options into the API-agnostic stream options."""
|
|
73
|
+
options = options or {}
|
|
74
|
+
base: dict[str, Any] = {key: options[key] for key in _BASE_OPTION_KEYS if options.get(key) is not None}
|
|
75
|
+
sampling = resolve_sampling_params(model, options.get("reasoning") or "off", options.get("sampling_params"))
|
|
76
|
+
if sampling is not None:
|
|
77
|
+
base["sampling_params"] = sampling
|
|
78
|
+
max_tokens = options.get("max_tokens")
|
|
79
|
+
base["max_tokens"] = clamp_max_tokens_to_context(
|
|
80
|
+
model, context, max_tokens if max_tokens is not None else model.max_tokens
|
|
81
|
+
)
|
|
82
|
+
key = api_key or options.get("api_key")
|
|
83
|
+
if key:
|
|
84
|
+
base["api_key"] = key
|
|
85
|
+
return base
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def clamp_reasoning(effort: str | None) -> str | None:
|
|
89
|
+
return "high" if effort in ("xhigh", "max") else effort
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def thinking_budget_for_level(reasoning_level: str, custom_budgets: Mapping[str, int] | None = None) -> int:
|
|
93
|
+
budgets = {**DEFAULT_THINKING_BUDGETS, **(custom_budgets or {})}
|
|
94
|
+
level = clamp_reasoning(reasoning_level) or "medium"
|
|
95
|
+
return budgets[level]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def clamp_thinking_budget_to_answer_room(thinking_budget: int, ceiling: int) -> int:
|
|
99
|
+
"""Cap a thinking budget so at least ``MIN_ANSWER_TOKENS`` remain under a shared ceiling."""
|
|
100
|
+
return min(thinking_budget, max(0, ceiling - MIN_ANSWER_TOKENS))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def adjust_max_tokens_for_thinking(
|
|
104
|
+
base_max_tokens: int | None,
|
|
105
|
+
model_max_tokens: int,
|
|
106
|
+
reasoning_level: str,
|
|
107
|
+
custom_budgets: Mapping[str, int] | None = None,
|
|
108
|
+
) -> tuple[int, int]:
|
|
109
|
+
"""Return ``(max_tokens, thinking_budget)`` with thinking fitted inside the response ceiling.
|
|
110
|
+
|
|
111
|
+
``base_max_tokens=None`` means no explicit caller cap: use the model cap.
|
|
112
|
+
"""
|
|
113
|
+
thinking_budget = thinking_budget_for_level(reasoning_level, custom_budgets)
|
|
114
|
+
max_tokens = (
|
|
115
|
+
model_max_tokens if base_max_tokens is None else min(base_max_tokens + thinking_budget, model_max_tokens)
|
|
116
|
+
)
|
|
117
|
+
if max_tokens <= thinking_budget:
|
|
118
|
+
thinking_budget = clamp_thinking_budget_to_answer_room(thinking_budget, max_tokens)
|
|
119
|
+
return max_tokens, thinking_budget
|