pi-python-core 0.8.1__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_python/__init__.py +160 -0
- pi_python/_version.py +1 -0
- pi_python/agent.py +396 -0
- pi_python/cancellation.py +24 -0
- pi_python/data/models.json +3315 -0
- pi_python/errors.py +49 -0
- pi_python/estimate.py +144 -0
- pi_python/events.py +138 -0
- pi_python/function_tools.py +438 -0
- pi_python/hooks.py +44 -0
- pi_python/limits.py +28 -0
- pi_python/loop.py +431 -0
- pi_python/lowlevel.py +179 -0
- pi_python/mcp.py +187 -0
- pi_python/messages.py +405 -0
- pi_python/models.py +155 -0
- pi_python/provider.py +123 -0
- pi_python/providers/__init__.py +21 -0
- pi_python/providers/anthropic.py +673 -0
- pi_python/providers/common.py +201 -0
- pi_python/providers/completions.py +1149 -0
- pi_python/providers/oauth.py +542 -0
- pi_python/providers/openai.py +681 -0
- pi_python/providers/transport.py +574 -0
- pi_python/proxy.py +304 -0
- pi_python/py.typed +0 -0
- pi_python/queues.py +76 -0
- pi_python/recovery.py +209 -0
- pi_python/run.py +419 -0
- pi_python/stream.py +251 -0
- pi_python/sync.py +78 -0
- pi_python/testing.py +25 -0
- pi_python/tools.py +546 -0
- pi_python/transcript.py +167 -0
- pi_python_core-0.8.1.dist-info/METADATA +119 -0
- pi_python_core-0.8.1.dist-info/RECORD +39 -0
- pi_python_core-0.8.1.dist-info/WHEEL +4 -0
- pi_python_core-0.8.1.dist-info/licenses/LICENSE +21 -0
- pi_python_core-0.8.1.dist-info/licenses/NOTICE +8 -0
|
@@ -0,0 +1,1149 @@
|
|
|
1
|
+
"""OpenAI-compatible Chat Completions, ported from Pi v1.0.0 api/openai-completions.ts.
|
|
2
|
+
|
|
3
|
+
This is the wire protocol of local model servers (vLLM, Ollama, llama.cpp, LM Studio,
|
|
4
|
+
SGLang) and of many hosted services (DeepSeek, Groq, OpenRouter, Together, Qwen, ...).
|
|
5
|
+
Endpoint differences are described by `ModelInfo.compat` flags with Pi's names; flags
|
|
6
|
+
left unset are detected from the provider name and base URL exactly as upstream does.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
import uuid
|
|
14
|
+
from collections.abc import AsyncGenerator
|
|
15
|
+
from copy import deepcopy
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from .._version import __version__
|
|
19
|
+
from ..cancellation import CancelToken
|
|
20
|
+
from ..errors import ConfigurationError, ProviderProtocolError, UnsupportedCapabilityError
|
|
21
|
+
from ..estimate import clamp_max_tokens_to_context, short_hash
|
|
22
|
+
from ..messages import (
|
|
23
|
+
AssistantMessage,
|
|
24
|
+
CustomMessage,
|
|
25
|
+
ImageContent,
|
|
26
|
+
Message,
|
|
27
|
+
SystemMessage,
|
|
28
|
+
TextContent,
|
|
29
|
+
ThinkingContent,
|
|
30
|
+
ToolCall,
|
|
31
|
+
ToolDeclaration,
|
|
32
|
+
ToolResultMessage,
|
|
33
|
+
UserMessage,
|
|
34
|
+
)
|
|
35
|
+
from ..models import LEVELS, ModelCatalog, ModelInfo
|
|
36
|
+
from ..provider import ModelEvent, ModelRequest
|
|
37
|
+
from ..stream import event_contract
|
|
38
|
+
from ..tools import invoke
|
|
39
|
+
from ..transcript import (
|
|
40
|
+
render_system_update,
|
|
41
|
+
resolve_transcript,
|
|
42
|
+
resolve_transcript_tools,
|
|
43
|
+
system_message_text,
|
|
44
|
+
with_request_tools,
|
|
45
|
+
)
|
|
46
|
+
from .common import RemoteProvider, transform_messages
|
|
47
|
+
|
|
48
|
+
API = "openai-completions"
|
|
49
|
+
|
|
50
|
+
# Delta fields that carry visible reasoning, in Pi's lookup order (first non-empty wins).
|
|
51
|
+
_REASONING_FIELDS = ("reasoning_content", "reasoning", "reasoning_text")
|
|
52
|
+
_THINKING_FORMATS = {
|
|
53
|
+
"openai",
|
|
54
|
+
"openrouter",
|
|
55
|
+
"deepseek",
|
|
56
|
+
"together",
|
|
57
|
+
"baseten",
|
|
58
|
+
"zai",
|
|
59
|
+
"qwen",
|
|
60
|
+
"chat-template",
|
|
61
|
+
"qwen-chat-template",
|
|
62
|
+
"string-thinking",
|
|
63
|
+
"ant-ling",
|
|
64
|
+
}
|
|
65
|
+
_BUDGET_FIELDS = {"thinking_token_budget", "thinking_budget", "thinking_budget_tokens"}
|
|
66
|
+
_AFFINITY_FORMATS = {"openai", "openai-nosession", "openrouter"}
|
|
67
|
+
_BOOL_FLAGS = (
|
|
68
|
+
"supportsStore",
|
|
69
|
+
"supportsDeveloperRole",
|
|
70
|
+
"supportsReasoningEffort",
|
|
71
|
+
"supportsUsageInStreaming",
|
|
72
|
+
"supportsFinishReason",
|
|
73
|
+
"requiresToolResultName",
|
|
74
|
+
"requiresAssistantAfterToolResult",
|
|
75
|
+
"requiresThinkingAsText",
|
|
76
|
+
"requiresReasoningContentOnAssistantMessages",
|
|
77
|
+
"zaiToolStream",
|
|
78
|
+
"supportsThinkingTokenBudget",
|
|
79
|
+
"supportsStrictMode",
|
|
80
|
+
"supportsOpenAIGrammarTools",
|
|
81
|
+
"supportsMidConvoSystemMessages",
|
|
82
|
+
"supportsMidConvoToolAdditions",
|
|
83
|
+
"sendSessionAffinityHeaders",
|
|
84
|
+
"supportsLongCacheRetention",
|
|
85
|
+
)
|
|
86
|
+
# Pi DEFAULT_THINKING_BUDGETS and MIN_ANSWER_TOKENS (api/simple-options.ts).
|
|
87
|
+
_DEFAULT_BUDGETS = {"minimal": 1024, "low": 2048, "medium": 8192, "high": 16384}
|
|
88
|
+
_MIN_ANSWER_TOKENS = 1024
|
|
89
|
+
_BRIDGE = "I have processed the tool results."
|
|
90
|
+
# JavaScript String.prototype.trim whitespace.
|
|
91
|
+
_JS_SPACE = " \t\n\v\f\r
"
|
|
92
|
+
_LONE_SURROGATE = re.compile(
|
|
93
|
+
r"[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]"
|
|
94
|
+
)
|
|
95
|
+
_OMIT = object()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _sanitize(text: str) -> str:
|
|
99
|
+
"""Pi sanitizeSurrogates: drop unpaired surrogates, which cannot be encoded."""
|
|
100
|
+
return _LONE_SURROGATE.sub("", text)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _blank(text: str) -> bool:
|
|
104
|
+
return not text.strip(_JS_SPACE)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _truthy(value: Any) -> bool:
|
|
108
|
+
"""JavaScript truthiness: empty lists and objects are true, 0 and "" are false."""
|
|
109
|
+
if isinstance(value, (list, dict)):
|
|
110
|
+
return True
|
|
111
|
+
return bool(value) and value == value
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _number(value: Any) -> bool:
|
|
115
|
+
return type(value) in (int, float)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _first_defined(*values: Any) -> Any:
|
|
119
|
+
"""JavaScript `a ?? b ?? ...`."""
|
|
120
|
+
return next((v for v in values if v is not None), None)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def detect_compat(provider: str, base_url: str, model_id: str) -> dict[str, Any]:
|
|
124
|
+
"""Pi detectCompat: settings inferred from the provider name and base URL."""
|
|
125
|
+
is_zai = (
|
|
126
|
+
provider in {"zai", "zai-coding-cn"}
|
|
127
|
+
or "api.z.ai" in base_url
|
|
128
|
+
or "open.bigmodel.cn" in base_url
|
|
129
|
+
)
|
|
130
|
+
is_together = (
|
|
131
|
+
provider == "together" or "api.together.ai" in base_url or "api.together.xyz" in base_url
|
|
132
|
+
)
|
|
133
|
+
is_moonshot = provider in {"moonshotai", "moonshotai-cn"} or "api.moonshot." in base_url
|
|
134
|
+
is_openrouter = provider == "openrouter" or "openrouter.ai" in base_url
|
|
135
|
+
is_cf_workers = provider == "cloudflare-workers-ai" or "api.cloudflare.com" in base_url
|
|
136
|
+
is_cf_gateway = provider == "cloudflare-ai-gateway" or "gateway.ai.cloudflare.com" in base_url
|
|
137
|
+
is_nvidia = provider == "nvidia" or "integrate.api.nvidia.com" in base_url
|
|
138
|
+
is_ant_ling = provider == "ant-ling" or "api.ant-ling.com" in base_url
|
|
139
|
+
is_cerebras = provider == "cerebras" or "cerebras.ai" in base_url
|
|
140
|
+
is_deepseek = provider == "deepseek" or "deepseek.com" in base_url.lower()
|
|
141
|
+
is_grok = provider == "xai" or "api.x.ai" in base_url
|
|
142
|
+
non_standard = (
|
|
143
|
+
is_nvidia
|
|
144
|
+
or is_cerebras
|
|
145
|
+
or is_grok
|
|
146
|
+
or is_together
|
|
147
|
+
or "chutes.ai" in base_url
|
|
148
|
+
or is_deepseek
|
|
149
|
+
or is_zai
|
|
150
|
+
or is_moonshot
|
|
151
|
+
or provider == "opencode"
|
|
152
|
+
or "opencode.ai" in base_url
|
|
153
|
+
or is_cf_workers
|
|
154
|
+
or is_cf_gateway
|
|
155
|
+
or is_ant_ling
|
|
156
|
+
)
|
|
157
|
+
use_max_tokens = (
|
|
158
|
+
"chutes.ai" in base_url
|
|
159
|
+
or is_deepseek
|
|
160
|
+
or is_moonshot
|
|
161
|
+
or is_cf_gateway
|
|
162
|
+
or is_together
|
|
163
|
+
or is_nvidia
|
|
164
|
+
or is_ant_ling
|
|
165
|
+
or is_zai
|
|
166
|
+
)
|
|
167
|
+
developer_role_model = is_openrouter and model_id.startswith(("anthropic/", "openai/"))
|
|
168
|
+
return {
|
|
169
|
+
"supportsStore": not non_standard,
|
|
170
|
+
"supportsDeveloperRole": developer_role_model or (not non_standard and not is_openrouter),
|
|
171
|
+
"supportsReasoningEffort": not (
|
|
172
|
+
is_grok
|
|
173
|
+
or is_zai
|
|
174
|
+
or is_moonshot
|
|
175
|
+
or is_together
|
|
176
|
+
or is_cf_gateway
|
|
177
|
+
or is_nvidia
|
|
178
|
+
or is_ant_ling
|
|
179
|
+
),
|
|
180
|
+
"supportsUsageInStreaming": True,
|
|
181
|
+
"supportsFinishReason": True,
|
|
182
|
+
"maxTokensField": "max_tokens" if use_max_tokens else "max_completion_tokens",
|
|
183
|
+
"requiresToolResultName": False,
|
|
184
|
+
"requiresAssistantAfterToolResult": False,
|
|
185
|
+
"requiresThinkingAsText": False,
|
|
186
|
+
"requiresReasoningContentOnAssistantMessages": is_deepseek,
|
|
187
|
+
"thinkingFormat": "deepseek"
|
|
188
|
+
if is_deepseek
|
|
189
|
+
else "zai"
|
|
190
|
+
if is_zai
|
|
191
|
+
else "together"
|
|
192
|
+
if is_together
|
|
193
|
+
else "ant-ling"
|
|
194
|
+
if is_ant_ling
|
|
195
|
+
else "openrouter"
|
|
196
|
+
if is_openrouter
|
|
197
|
+
else "openai",
|
|
198
|
+
"openRouterRouting": {},
|
|
199
|
+
"vercelGatewayRouting": {},
|
|
200
|
+
"chatTemplateKwargs": {},
|
|
201
|
+
"chatTemplateArgs": {},
|
|
202
|
+
"zaiToolStream": False,
|
|
203
|
+
"supportsThinkingTokenBudget": False,
|
|
204
|
+
"thinkingTokenBudgetField": None,
|
|
205
|
+
# OpenAI compatibility alone does not imply strict JSON-schema tool support.
|
|
206
|
+
"supportsStrictMode": False,
|
|
207
|
+
"supportsOpenAIGrammarTools": False,
|
|
208
|
+
"supportsMidConvoSystemMessages": False,
|
|
209
|
+
"supportsMidConvoToolAdditions": False,
|
|
210
|
+
"cacheControlFormat": "anthropic"
|
|
211
|
+
if provider == "openrouter" and model_id.startswith("anthropic/")
|
|
212
|
+
else None,
|
|
213
|
+
"sendSessionAffinityHeaders": is_openrouter,
|
|
214
|
+
"sessionAffinityFormat": "openrouter" if is_openrouter else "openai",
|
|
215
|
+
"supportsLongCacheRetention": not (
|
|
216
|
+
is_together or is_cf_workers or is_cf_gateway or is_nvidia or is_ant_ling
|
|
217
|
+
),
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def resolve_compat(model: ModelInfo, base_url: str) -> dict[str, Any]:
|
|
222
|
+
"""Pi getCompat: explicit `model.compat` values over detected ones.
|
|
223
|
+
|
|
224
|
+
Unknown keys are ignored, as for the other providers' compat records, but a known
|
|
225
|
+
flag with a value of the wrong type or outside Pi's set is a configuration error.
|
|
226
|
+
"""
|
|
227
|
+
explicit = model.compat
|
|
228
|
+
for name in _BOOL_FLAGS:
|
|
229
|
+
if explicit.get(name) is not None and type(explicit[name]) is not bool:
|
|
230
|
+
raise ConfigurationError(f"compat.{name} must be a boolean")
|
|
231
|
+
choices: list[tuple[str, set[str]]] = [
|
|
232
|
+
("thinkingFormat", _THINKING_FORMATS),
|
|
233
|
+
("maxTokensField", {"max_completion_tokens", "max_tokens"}),
|
|
234
|
+
("thinkingTokenBudgetField", _BUDGET_FIELDS),
|
|
235
|
+
("sessionAffinityFormat", _AFFINITY_FORMATS),
|
|
236
|
+
("cacheControlFormat", {"anthropic"}),
|
|
237
|
+
]
|
|
238
|
+
for name, allowed in choices:
|
|
239
|
+
if explicit.get(name) is not None and explicit[name] not in allowed:
|
|
240
|
+
raise ConfigurationError(f"compat.{name} must be one of {sorted(allowed)}")
|
|
241
|
+
for name in ("chatTemplateKwargs", "chatTemplateArgs", "openRouterRouting"):
|
|
242
|
+
if explicit.get(name) is not None and not isinstance(explicit[name], dict):
|
|
243
|
+
raise ConfigurationError(f"compat.{name} must be an object")
|
|
244
|
+
if explicit.get("vllmPriority") is not None and not _number(explicit["vllmPriority"]):
|
|
245
|
+
raise ConfigurationError("compat.vllmPriority must be a number")
|
|
246
|
+
detected = detect_compat(model.provider, base_url, model.id)
|
|
247
|
+
resolved = {
|
|
248
|
+
name: explicit[name] if explicit.get(name) is not None else value
|
|
249
|
+
for name, value in detected.items()
|
|
250
|
+
}
|
|
251
|
+
resolved["openRouterRouting"] = _first_defined(explicit.get("openRouterRouting"), {})
|
|
252
|
+
resolved["vllmPriority"] = explicit.get("vllmPriority")
|
|
253
|
+
return resolved
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _is_detail(detail: Any) -> bool:
|
|
257
|
+
"""Pi isOpenAIReasoningDetail."""
|
|
258
|
+
if not isinstance(detail, dict):
|
|
259
|
+
return False
|
|
260
|
+
if not (detail.get("id") is None or isinstance(detail["id"], str)):
|
|
261
|
+
return False
|
|
262
|
+
if "format" in detail and not isinstance(detail["format"], str):
|
|
263
|
+
return False
|
|
264
|
+
if "index" in detail and not _number(detail["index"]):
|
|
265
|
+
return False
|
|
266
|
+
kind = detail.get("type")
|
|
267
|
+
if kind == "reasoning.summary":
|
|
268
|
+
return isinstance(detail.get("summary"), str)
|
|
269
|
+
if kind == "reasoning.encrypted":
|
|
270
|
+
return isinstance(detail.get("data"), str)
|
|
271
|
+
if kind == "reasoning.text":
|
|
272
|
+
return isinstance(detail.get("text"), str) and (
|
|
273
|
+
detail.get("signature") is None or isinstance(detail["signature"], str)
|
|
274
|
+
)
|
|
275
|
+
return False
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _parse_details(signature: str | None) -> list[dict[str, Any]] | None:
|
|
279
|
+
if not signature:
|
|
280
|
+
return None
|
|
281
|
+
try:
|
|
282
|
+
parsed = json.loads(signature)
|
|
283
|
+
except ValueError:
|
|
284
|
+
return None
|
|
285
|
+
if isinstance(parsed, list) and parsed and all(_is_detail(d) for d in parsed):
|
|
286
|
+
return parsed
|
|
287
|
+
return None
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _parse_legacy_detail(signature: str | None) -> dict[str, Any] | None:
|
|
291
|
+
if not signature:
|
|
292
|
+
return None
|
|
293
|
+
try:
|
|
294
|
+
parsed = json.loads(signature)
|
|
295
|
+
except ValueError:
|
|
296
|
+
return None
|
|
297
|
+
if (
|
|
298
|
+
_is_detail(parsed)
|
|
299
|
+
and parsed["type"] == "reasoning.encrypted"
|
|
300
|
+
and isinstance(parsed.get("id"), str)
|
|
301
|
+
and parsed["id"]
|
|
302
|
+
and parsed["data"]
|
|
303
|
+
):
|
|
304
|
+
return dict(parsed)
|
|
305
|
+
return None
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _assign_from(target: dict[str, Any], source: dict[str, Any], key: str) -> None:
|
|
309
|
+
"""JavaScript `target[key] = source[key]`, where an undefined value is not serialized."""
|
|
310
|
+
if key in source:
|
|
311
|
+
target[key] = source[key]
|
|
312
|
+
else:
|
|
313
|
+
target.pop(key, None)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _append_detail(details: list[dict[str, Any]], detail: dict[str, Any]) -> None:
|
|
317
|
+
"""Pi appendOpenAIReasoningDetail: merge streamed text/summary pieces into entries."""
|
|
318
|
+
last = details[-1] if details else None
|
|
319
|
+
if last is not None and detail["type"] == last["type"] in {
|
|
320
|
+
"reasoning.text",
|
|
321
|
+
"reasoning.summary",
|
|
322
|
+
}:
|
|
323
|
+
field = "text" if detail["type"] == "reasoning.text" else "summary"
|
|
324
|
+
last[field] += detail[field]
|
|
325
|
+
if field == "text" and not _truthy(last.get("signature")):
|
|
326
|
+
_assign_from(last, detail, "signature")
|
|
327
|
+
if last.get("id") is None:
|
|
328
|
+
_assign_from(last, detail, "id")
|
|
329
|
+
if not _truthy(last.get("format")):
|
|
330
|
+
_assign_from(last, detail, "format")
|
|
331
|
+
if last.get("index") is None:
|
|
332
|
+
_assign_from(last, detail, "index")
|
|
333
|
+
return
|
|
334
|
+
details.append(dict(detail))
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _usage(raw: Any) -> dict[str, Any]:
|
|
338
|
+
"""Pi parseChunkUsage, without cost (prices live on ModelInfo)."""
|
|
339
|
+
raw = raw if isinstance(raw, dict) else {}
|
|
340
|
+
|
|
341
|
+
def count(value: Any) -> int | float:
|
|
342
|
+
return value if _number(value) and value == value else 0
|
|
343
|
+
|
|
344
|
+
prompt = raw.get("prompt_tokens_details")
|
|
345
|
+
prompt = prompt if isinstance(prompt, dict) else {}
|
|
346
|
+
completion = raw.get("completion_tokens_details")
|
|
347
|
+
completion = completion if isinstance(completion, dict) else {}
|
|
348
|
+
read = count(
|
|
349
|
+
_first_defined(
|
|
350
|
+
prompt.get("cached_tokens"),
|
|
351
|
+
raw.get("prompt_cache_hit_tokens"),
|
|
352
|
+
raw.get("cached_tokens"),
|
|
353
|
+
)
|
|
354
|
+
)
|
|
355
|
+
write = count(prompt.get("cache_write_tokens"))
|
|
356
|
+
input_tokens = max(0, count(raw.get("prompt_tokens")) - read - write)
|
|
357
|
+
output = count(raw.get("completion_tokens"))
|
|
358
|
+
return {
|
|
359
|
+
"input": input_tokens,
|
|
360
|
+
"output": output,
|
|
361
|
+
"cache_read": read,
|
|
362
|
+
"cache_write": write,
|
|
363
|
+
"reasoning": count(completion.get("reasoning_tokens")),
|
|
364
|
+
"total_tokens": input_tokens + output + read + write,
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _stop_reason(reason: Any) -> tuple[str, str | None]:
|
|
369
|
+
"""Pi mapStopReason, with Python stop-reason spelling."""
|
|
370
|
+
if reason in {"stop", "end"}:
|
|
371
|
+
return "stop", None
|
|
372
|
+
if reason == "length":
|
|
373
|
+
return "length", None
|
|
374
|
+
if reason in {"function_call", "tool_calls"}:
|
|
375
|
+
return "tool_use", None
|
|
376
|
+
return "error", f"Provider finish_reason: {reason}"
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _image_part(block: ImageContent) -> dict[str, Any]:
|
|
380
|
+
return {
|
|
381
|
+
"type": "image_url",
|
|
382
|
+
"image_url": {"url": f"data:{block.mime_type};base64,{block.data}"},
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _id_chars(value: str) -> str:
|
|
387
|
+
return re.sub(r"[^a-zA-Z0-9_-]", "_", value)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
class OpenAICompletionsProvider(RemoteProvider):
|
|
391
|
+
"""Any OpenAI-compatible `/chat/completions` endpoint.
|
|
392
|
+
|
|
393
|
+
`base_url` is the API root that precedes `/chat/completions`, used as given (for
|
|
394
|
+
example `http://localhost:11434/v1` for Ollama, `http://localhost:8000/v1` for
|
|
395
|
+
vLLM). `name` is the provider identity recorded on messages; Pi also uses it, with
|
|
396
|
+
the URL, to detect known services. The API key is optional: without one no
|
|
397
|
+
`authorization` header is sent, which keyless local servers accept.
|
|
398
|
+
|
|
399
|
+
Models are never guessed. Pass a `ModelInfo` with `api="openai-completions"` and
|
|
400
|
+
`provider=name`, register one in `catalog`, or call `provider.model(id, ...)`.
|
|
401
|
+
`options` accepts max_tokens, reasoning, thinking_budgets, temperature,
|
|
402
|
+
tool_choice, sampling_params (merged into the body last), cache_retention,
|
|
403
|
+
session_id, headers (a None value removes a header) and transport ("sse").
|
|
404
|
+
"""
|
|
405
|
+
|
|
406
|
+
api = API
|
|
407
|
+
|
|
408
|
+
def __init__(self, *, base_url: str, name: str = "openai-compatible", **kwargs: Any) -> None:
|
|
409
|
+
super().__init__(**kwargs)
|
|
410
|
+
if not isinstance(base_url, str) or not base_url.startswith(("http://", "https://")):
|
|
411
|
+
raise ConfigurationError(
|
|
412
|
+
"base_url must be an http(s) URL, such as http://localhost:11434/v1"
|
|
413
|
+
)
|
|
414
|
+
if not isinstance(name, str) or not name:
|
|
415
|
+
raise ConfigurationError("name must be a non-empty string")
|
|
416
|
+
self.name = name
|
|
417
|
+
self.base_url = base_url.rstrip("/")
|
|
418
|
+
|
|
419
|
+
def model(
|
|
420
|
+
self,
|
|
421
|
+
id: str,
|
|
422
|
+
*,
|
|
423
|
+
context_window: int = 128_000,
|
|
424
|
+
max_tokens: int = 16_384,
|
|
425
|
+
reasoning: bool = False,
|
|
426
|
+
input: tuple[str, ...] | list[str] = ("text",),
|
|
427
|
+
name: str | None = None,
|
|
428
|
+
thinking_level_map: dict[str, str | None] | None = None,
|
|
429
|
+
compat: dict[str, Any] | None = None,
|
|
430
|
+
cost: dict[str, float] | None = None,
|
|
431
|
+
) -> ModelInfo:
|
|
432
|
+
"""Declare a model served by this endpoint.
|
|
433
|
+
|
|
434
|
+
The defaults are Pi's for a custom model declared with only an `id` (coding-agent
|
|
435
|
+
models.json): a text-only, non-reasoning model with a 128000-token context and
|
|
436
|
+
16384 output tokens. State the real limits when you know them; the output limit
|
|
437
|
+
is sent with every request. For a reasoning model on Ollama, vLLM or SGLang, Pi
|
|
438
|
+
suggests `compat={"supportsDeveloperRole": False, "supportsReasoningEffort": False}`.
|
|
439
|
+
"""
|
|
440
|
+
model = ModelInfo(
|
|
441
|
+
id=id,
|
|
442
|
+
provider=self.name,
|
|
443
|
+
api=API,
|
|
444
|
+
name=name or id,
|
|
445
|
+
context_window=context_window,
|
|
446
|
+
max_tokens=max_tokens,
|
|
447
|
+
reasoning=reasoning,
|
|
448
|
+
input=tuple(input),
|
|
449
|
+
thinking_level_map=dict(thinking_level_map or {}),
|
|
450
|
+
cost=dict(cost or {"input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0}),
|
|
451
|
+
compat=deepcopy(compat or {}),
|
|
452
|
+
base_url=self.base_url,
|
|
453
|
+
)
|
|
454
|
+
ModelCatalog([model]) # the catalog's checks: positive limits, JSON compat, levels
|
|
455
|
+
return model
|
|
456
|
+
|
|
457
|
+
def model_info(self, request: ModelRequest) -> ModelInfo:
|
|
458
|
+
model = request.model_info or self.catalog.get(self.name, request.model)
|
|
459
|
+
if model is None:
|
|
460
|
+
raise ConfigurationError(
|
|
461
|
+
f"Unknown model {self.name}/{request.model}. Declare it, for example "
|
|
462
|
+
f"Agent(provider=provider, model=provider.model({request.model!r}, "
|
|
463
|
+
"context_window=..., max_tokens=...)), or register a "
|
|
464
|
+
f"ModelInfo(provider={self.name!r}, api={API!r}) in the provider catalog"
|
|
465
|
+
)
|
|
466
|
+
if model.provider != self.name or model.id != request.model:
|
|
467
|
+
raise ConfigurationError(
|
|
468
|
+
f"Model {model.provider}/{model.id} is not {self.name}/{request.model}"
|
|
469
|
+
)
|
|
470
|
+
if model.api != API:
|
|
471
|
+
raise ConfigurationError(
|
|
472
|
+
f"Model {model.provider}/{model.id} uses api {model.api!r}, not {API!r}; "
|
|
473
|
+
"declare it with provider.model(...) or dataclasses.replace(model, api=...)"
|
|
474
|
+
)
|
|
475
|
+
model.validate_request(request)
|
|
476
|
+
return deepcopy(model)
|
|
477
|
+
|
|
478
|
+
async def _key(self, request: ModelRequest, cancel: CancelToken) -> str | None:
|
|
479
|
+
"""A configured credential, or None for a keyless endpoint (an extension: Pi
|
|
480
|
+
requires a key or an authorization header)."""
|
|
481
|
+
if request.api_key is None and self.api_key is None and self.credentials is None:
|
|
482
|
+
return None
|
|
483
|
+
return await self.credential(request, cancel)
|
|
484
|
+
|
|
485
|
+
def _tools(self, tools: list[ToolDeclaration], compat: dict[str, Any]) -> list[dict[str, Any]]:
|
|
486
|
+
"""Pi convertTools. Tool declarations carry no constrained-sampling settings, so
|
|
487
|
+
`strict` is false whenever the endpoint accepts the field."""
|
|
488
|
+
return [
|
|
489
|
+
{
|
|
490
|
+
"type": "function",
|
|
491
|
+
"function": {
|
|
492
|
+
"name": t.name,
|
|
493
|
+
"description": t.description,
|
|
494
|
+
"parameters": deepcopy(t.input_schema),
|
|
495
|
+
**({"strict": False} if compat["supportsStrictMode"] is not False else {}),
|
|
496
|
+
},
|
|
497
|
+
}
|
|
498
|
+
for t in tools
|
|
499
|
+
]
|
|
500
|
+
|
|
501
|
+
def _messages(
|
|
502
|
+
self, model: ModelInfo, transcript: list[Message], compat: dict[str, Any], anchors: bool
|
|
503
|
+
) -> list[dict[str, Any]]:
|
|
504
|
+
"""Pi convertMessages for a resolved transcript."""
|
|
505
|
+
|
|
506
|
+
def normalize(value: str, _source: AssistantMessage) -> str:
|
|
507
|
+
if "|" in value:
|
|
508
|
+
# Responses IDs are `call|item`; keep item-level uniqueness within 40 chars.
|
|
509
|
+
call, _, item = value.partition("|")
|
|
510
|
+
call, item = _id_chars(call), _id_chars(item)
|
|
511
|
+
combined = f"{call}_{item}" if item else call
|
|
512
|
+
if len(combined) <= 40:
|
|
513
|
+
return combined
|
|
514
|
+
digest = short_hash(value)[:8]
|
|
515
|
+
return f"{call[: max(1, 40 - len(digest) - 1)]}_{digest}"
|
|
516
|
+
if model.provider == "openai":
|
|
517
|
+
return value[:40]
|
|
518
|
+
return value
|
|
519
|
+
|
|
520
|
+
transformed = transform_messages(transcript, self.name, API, model.id, normalize)
|
|
521
|
+
role = "developer" if model.reasoning and compat["supportsDeveloperRole"] else "system"
|
|
522
|
+
bridge = compat["requiresAssistantAfterToolResult"]
|
|
523
|
+
params: list[dict[str, Any]] = []
|
|
524
|
+
last_role: str | None = None
|
|
525
|
+
i = 0
|
|
526
|
+
while i < len(transformed):
|
|
527
|
+
message = transformed[i]
|
|
528
|
+
if bridge and last_role == "toolResult" and isinstance(message, UserMessage):
|
|
529
|
+
params.append({"role": "assistant", "content": _BRIDGE})
|
|
530
|
+
if isinstance(message, SystemMessage):
|
|
531
|
+
added = message.tools_added if i > 0 and anchors else []
|
|
532
|
+
if added:
|
|
533
|
+
# Kimi-style tool addition anchored at its system message.
|
|
534
|
+
params.append({"role": "system", "tools": self._tools(added, compat)})
|
|
535
|
+
text = system_message_text(message) if i == 0 else render_system_update(message)
|
|
536
|
+
if text:
|
|
537
|
+
params.append({"role": role, "content": _sanitize(text)})
|
|
538
|
+
last_role = "system"
|
|
539
|
+
elif isinstance(message, UserMessage):
|
|
540
|
+
if isinstance(message.content, str):
|
|
541
|
+
params.append({"role": "user", "content": _sanitize(message.content)})
|
|
542
|
+
else:
|
|
543
|
+
parts: list[dict[str, Any]] = []
|
|
544
|
+
for b in message.content:
|
|
545
|
+
if isinstance(b, ImageContent):
|
|
546
|
+
parts.append(_image_part(b))
|
|
547
|
+
elif b.text:
|
|
548
|
+
parts.append({"type": "text", "text": _sanitize(b.text)})
|
|
549
|
+
if not parts:
|
|
550
|
+
i += 1
|
|
551
|
+
continue
|
|
552
|
+
params.append({"role": "user", "content": parts})
|
|
553
|
+
last_role = "user"
|
|
554
|
+
elif isinstance(message, AssistantMessage):
|
|
555
|
+
item = self._assistant(model, message, compat)
|
|
556
|
+
if item is None:
|
|
557
|
+
i += 1
|
|
558
|
+
continue
|
|
559
|
+
params.append(item)
|
|
560
|
+
last_role = "assistant"
|
|
561
|
+
elif isinstance(message, ToolResultMessage):
|
|
562
|
+
images: list[dict[str, Any]] = []
|
|
563
|
+
j = i
|
|
564
|
+
while j < len(transformed):
|
|
565
|
+
result = transformed[j]
|
|
566
|
+
if not isinstance(result, ToolResultMessage):
|
|
567
|
+
break
|
|
568
|
+
text = "\n".join(b.text for b in result.content if isinstance(b, TextContent))
|
|
569
|
+
has_images = any(isinstance(b, ImageContent) for b in result.content)
|
|
570
|
+
body = text or ("(see attached image)" if has_images else "(no tool output)")
|
|
571
|
+
entry: dict[str, Any] = {
|
|
572
|
+
"role": "tool",
|
|
573
|
+
"content": _sanitize(body),
|
|
574
|
+
"tool_call_id": result.call_id,
|
|
575
|
+
}
|
|
576
|
+
if compat["requiresToolResultName"] and result.name:
|
|
577
|
+
entry["name"] = result.name
|
|
578
|
+
params.append(entry)
|
|
579
|
+
if has_images and "image" in model.input:
|
|
580
|
+
images += [
|
|
581
|
+
_image_part(b) for b in result.content if isinstance(b, ImageContent)
|
|
582
|
+
]
|
|
583
|
+
j += 1
|
|
584
|
+
i = j
|
|
585
|
+
if images:
|
|
586
|
+
if bridge:
|
|
587
|
+
params.append({"role": "assistant", "content": _BRIDGE})
|
|
588
|
+
params.append(
|
|
589
|
+
{
|
|
590
|
+
"role": "user",
|
|
591
|
+
"content": [
|
|
592
|
+
{"type": "text", "text": "Attached image(s) from tool result:"},
|
|
593
|
+
*images,
|
|
594
|
+
],
|
|
595
|
+
}
|
|
596
|
+
)
|
|
597
|
+
last_role = "user"
|
|
598
|
+
else:
|
|
599
|
+
last_role = "toolResult"
|
|
600
|
+
continue
|
|
601
|
+
elif isinstance(message, CustomMessage):
|
|
602
|
+
raise UnsupportedCapabilityError("Convert custom messages before provider boundary")
|
|
603
|
+
i += 1
|
|
604
|
+
return params
|
|
605
|
+
|
|
606
|
+
def _assistant(
|
|
607
|
+
self, model: ModelInfo, message: AssistantMessage, compat: dict[str, Any]
|
|
608
|
+
) -> dict[str, Any] | None:
|
|
609
|
+
item: dict[str, Any] = {
|
|
610
|
+
"role": "assistant",
|
|
611
|
+
"content": "" if compat["requiresAssistantAfterToolResult"] else None,
|
|
612
|
+
}
|
|
613
|
+
text_parts = [
|
|
614
|
+
{"type": "text", "text": _sanitize(b.text)}
|
|
615
|
+
for b in message.content
|
|
616
|
+
if isinstance(b, TextContent) and not _blank(b.text)
|
|
617
|
+
]
|
|
618
|
+
text = "".join(p["text"] for p in text_parts)
|
|
619
|
+
thinking = [b for b in message.content if isinstance(b, ThinkingContent)]
|
|
620
|
+
calls = message.tool_calls
|
|
621
|
+
signed = next(
|
|
622
|
+
(d for d in (_parse_details(b.thinking_signature) for b in thinking) if d is not None),
|
|
623
|
+
None,
|
|
624
|
+
)
|
|
625
|
+
legacy = [d for d in (_parse_legacy_detail(c.thought_signature) for c in calls) if d]
|
|
626
|
+
preserved = signed if signed is not None else (legacy or None)
|
|
627
|
+
visible = [b for b in thinking if not _blank(b.thinking)]
|
|
628
|
+
if visible:
|
|
629
|
+
if compat["requiresThinkingAsText"]:
|
|
630
|
+
joined = "\n\n".join(_sanitize(b.thinking) for b in visible)
|
|
631
|
+
item["content"] = [{"type": "text", "text": joined}, *text_parts]
|
|
632
|
+
else:
|
|
633
|
+
# Assistant content is always a plain string (some models mirror arrays).
|
|
634
|
+
if text:
|
|
635
|
+
item["content"] = text
|
|
636
|
+
if not preserved:
|
|
637
|
+
field = visible[0].thinking_signature
|
|
638
|
+
if model.provider == "opencode-go" and field == "reasoning":
|
|
639
|
+
field = "reasoning_content"
|
|
640
|
+
if field in _REASONING_FIELDS:
|
|
641
|
+
item[field] = "\n".join(b.thinking for b in visible)
|
|
642
|
+
elif text:
|
|
643
|
+
item["content"] = text
|
|
644
|
+
if calls:
|
|
645
|
+
item["tool_calls"] = [
|
|
646
|
+
{
|
|
647
|
+
"id": c.id,
|
|
648
|
+
"type": "function",
|
|
649
|
+
"function": {
|
|
650
|
+
"name": c.name,
|
|
651
|
+
"arguments": json.dumps(
|
|
652
|
+
c.arguments, ensure_ascii=False, separators=(",", ":")
|
|
653
|
+
),
|
|
654
|
+
},
|
|
655
|
+
}
|
|
656
|
+
for c in calls
|
|
657
|
+
]
|
|
658
|
+
if preserved:
|
|
659
|
+
item["reasoning_details"] = preserved
|
|
660
|
+
if (
|
|
661
|
+
compat["requiresReasoningContentOnAssistantMessages"]
|
|
662
|
+
and model.reasoning
|
|
663
|
+
and "reasoning_content" not in item
|
|
664
|
+
):
|
|
665
|
+
item["reasoning_content"] = ""
|
|
666
|
+
# Providers reject an assistant message with neither content nor tool calls.
|
|
667
|
+
if not item["content"] and not calls:
|
|
668
|
+
return None
|
|
669
|
+
return item
|
|
670
|
+
|
|
671
|
+
def build_request(self, request: ModelRequest) -> dict[str, Any]:
|
|
672
|
+
"""The request body (before `on_payload`), as Pi's streamSimple builds it."""
|
|
673
|
+
model = self.model_info(request)
|
|
674
|
+
return self._build(request, model, resolve_compat(model, self.base_url))
|
|
675
|
+
|
|
676
|
+
def _build(
|
|
677
|
+
self, request: ModelRequest, model: ModelInfo, compat: dict[str, Any]
|
|
678
|
+
) -> dict[str, Any]:
|
|
679
|
+
options = request.options
|
|
680
|
+
retention = options.get("cache_retention", "short")
|
|
681
|
+
if retention not in {"none", "short", "long"}:
|
|
682
|
+
raise ConfigurationError("Invalid cache_retention")
|
|
683
|
+
reasoning = options.get("reasoning")
|
|
684
|
+
if reasoning is not None and reasoning not in LEVELS:
|
|
685
|
+
raise ConfigurationError("Unsupported reasoning level")
|
|
686
|
+
# streamSimple: clamp the level to the model; "off" sends no effort.
|
|
687
|
+
effort = model.clamp_thinking_level(reasoning) if reasoning else None
|
|
688
|
+
if effort == "off":
|
|
689
|
+
effort = None
|
|
690
|
+
source = with_request_tools(request.messages, request.tools)
|
|
691
|
+
transcript = resolve_transcript(source, compat["supportsMidConvoSystemMessages"] is True)
|
|
692
|
+
tools, anchors = resolve_transcript_tools(
|
|
693
|
+
transcript,
|
|
694
|
+
compat["supportsMidConvoSystemMessages"] is True
|
|
695
|
+
and compat["supportsMidConvoToolAdditions"] is True,
|
|
696
|
+
)
|
|
697
|
+
messages = self._messages(model, transcript, compat, anchors)
|
|
698
|
+
long_cache = retention == "long" and compat["supportsLongCacheRetention"]
|
|
699
|
+
body: dict[str, Any] = {"model": model.id, "messages": messages, "stream": True}
|
|
700
|
+
session = options.get("session_id")
|
|
701
|
+
if session is not None and (
|
|
702
|
+
("api.openai.com" in self.base_url and retention != "none") or long_cache
|
|
703
|
+
):
|
|
704
|
+
body["prompt_cache_key"] = session[:64]
|
|
705
|
+
if long_cache:
|
|
706
|
+
body["prompt_cache_retention"] = "24h"
|
|
707
|
+
if compat["supportsUsageInStreaming"] is not False:
|
|
708
|
+
body["stream_options"] = {"include_usage": True}
|
|
709
|
+
if compat["supportsStore"]:
|
|
710
|
+
body["store"] = False
|
|
711
|
+
max_tokens = clamp_max_tokens_to_context(
|
|
712
|
+
model.context_window, source, options.get("max_tokens", model.max_tokens)
|
|
713
|
+
)
|
|
714
|
+
if max_tokens:
|
|
715
|
+
body[compat["maxTokensField"]] = max_tokens
|
|
716
|
+
if options.get("temperature") is not None:
|
|
717
|
+
body["temperature"] = options["temperature"]
|
|
718
|
+
if tools:
|
|
719
|
+
body["tools"] = self._tools(tools, compat)
|
|
720
|
+
if compat["zaiToolStream"]:
|
|
721
|
+
body["tool_stream"] = True
|
|
722
|
+
elif any(
|
|
723
|
+
isinstance(m, ToolResultMessage) or (isinstance(m, AssistantMessage) and m.tool_calls)
|
|
724
|
+
for m in transcript
|
|
725
|
+
):
|
|
726
|
+
# Anthropic behind LiteLLM-style proxies needs `tools` once tools were used.
|
|
727
|
+
body["tools"] = []
|
|
728
|
+
if compat["cacheControlFormat"] == "anthropic" and retention != "none":
|
|
729
|
+
cache = {"type": "ephemeral", **({"ttl": "1h"} if long_cache else {})}
|
|
730
|
+
_apply_cache_control(messages, body.get("tools"), cache)
|
|
731
|
+
if _truthy(options.get("tool_choice")):
|
|
732
|
+
body["tool_choice"] = deepcopy(options["tool_choice"])
|
|
733
|
+
if compat["vllmPriority"] is not None:
|
|
734
|
+
body["priority"] = compat["vllmPriority"]
|
|
735
|
+
budget = self._thinking_budget(model, effort, options, body)
|
|
736
|
+
self._thinking(body, model, compat, effort, budget)
|
|
737
|
+
field = compat["thinkingTokenBudgetField"] or (
|
|
738
|
+
"thinking_token_budget" if compat["supportsThinkingTokenBudget"] else None
|
|
739
|
+
)
|
|
740
|
+
if field and budget is not None:
|
|
741
|
+
body[field] = budget
|
|
742
|
+
routing = model.compat.get("openRouterRouting")
|
|
743
|
+
if _truthy(routing):
|
|
744
|
+
body["provider"] = deepcopy(routing)
|
|
745
|
+
gateway = model.compat.get("vercelGatewayRouting")
|
|
746
|
+
if _truthy(gateway) and isinstance(gateway, dict):
|
|
747
|
+
selected = {
|
|
748
|
+
k: deepcopy(gateway[k]) for k in ("only", "order") if _truthy(gateway.get(k))
|
|
749
|
+
}
|
|
750
|
+
if selected:
|
|
751
|
+
body["providerOptions"] = {"gateway": selected}
|
|
752
|
+
# Last, so explicit sampling keys override the named fields (Pi samplingParams).
|
|
753
|
+
sampling = options.get("sampling_params")
|
|
754
|
+
if sampling is not None:
|
|
755
|
+
if not isinstance(sampling, dict):
|
|
756
|
+
raise ConfigurationError("sampling_params must be an object")
|
|
757
|
+
body.update(deepcopy(sampling))
|
|
758
|
+
return body
|
|
759
|
+
|
|
760
|
+
@staticmethod
|
|
761
|
+
def _thinking_budget(
|
|
762
|
+
model: ModelInfo, effort: str | None, options: dict[str, Any], body: dict[str, Any]
|
|
763
|
+
) -> int | None:
|
|
764
|
+
"""Pi resolveClampedThinkingBudget: leave room for an answer under the ceiling."""
|
|
765
|
+
if not effort or not model.reasoning:
|
|
766
|
+
return None
|
|
767
|
+
ceiling = _first_defined(
|
|
768
|
+
body.get("max_tokens"), body.get("max_completion_tokens"), model.max_tokens
|
|
769
|
+
)
|
|
770
|
+
budgets = {**_DEFAULT_BUDGETS, **(options.get("thinking_budgets") or {})}
|
|
771
|
+
level = "high" if effort in {"xhigh", "max"} else effort
|
|
772
|
+
budget = budgets[level]
|
|
773
|
+
if type(budget) is not int or budget < 0:
|
|
774
|
+
raise ConfigurationError("Invalid thinking budget")
|
|
775
|
+
budget = min(budget, max(0, ceiling - _MIN_ANSWER_TOKENS))
|
|
776
|
+
return budget if budget > 0 else None
|
|
777
|
+
|
|
778
|
+
@staticmethod
|
|
779
|
+
def _thinking(
|
|
780
|
+
body: dict[str, Any],
|
|
781
|
+
model: ModelInfo,
|
|
782
|
+
compat: dict[str, Any],
|
|
783
|
+
effort: str | None,
|
|
784
|
+
budget: int | None,
|
|
785
|
+
) -> None:
|
|
786
|
+
"""Pi's thinkingFormat branches, in upstream order."""
|
|
787
|
+
level_map = model.thinking_level_map
|
|
788
|
+
kind = compat["thinkingFormat"]
|
|
789
|
+
effort_ok = compat["supportsReasoningEffort"]
|
|
790
|
+
off_allowed = not ("off" in level_map and level_map["off"] is None)
|
|
791
|
+
|
|
792
|
+
def mapped(level: str) -> str:
|
|
793
|
+
# `thinkingLevelMap[level] ?? level`
|
|
794
|
+
value = level_map.get(level)
|
|
795
|
+
return value if value is not None else level
|
|
796
|
+
|
|
797
|
+
def strict(level: str | None) -> Any:
|
|
798
|
+
# `map[level] === undefined ? level : map[level]`, kept only when a string.
|
|
799
|
+
key = level if level else "off"
|
|
800
|
+
value = level_map[key] if key in level_map else level
|
|
801
|
+
return value if isinstance(value, str) else _OMIT
|
|
802
|
+
|
|
803
|
+
if kind == "zai" and model.reasoning:
|
|
804
|
+
body["thinking"] = (
|
|
805
|
+
{"type": "enabled", "clear_thinking": False} if effort else {"type": "disabled"}
|
|
806
|
+
)
|
|
807
|
+
if effort and effort_ok and (value := strict(effort)) is not _OMIT:
|
|
808
|
+
body["reasoning_effort"] = value
|
|
809
|
+
elif kind == "qwen" and model.reasoning:
|
|
810
|
+
body["enable_thinking"] = bool(effort)
|
|
811
|
+
if effort and effort_ok:
|
|
812
|
+
body["reasoning_effort"] = mapped(effort)
|
|
813
|
+
elif kind == "qwen-chat-template" and model.reasoning:
|
|
814
|
+
body["chat_template_kwargs"] = {
|
|
815
|
+
"enable_thinking": bool(effort),
|
|
816
|
+
"preserve_thinking": True,
|
|
817
|
+
}
|
|
818
|
+
elif kind == "chat-template" and model.reasoning:
|
|
819
|
+
values = _template_values(model, effort, compat["chatTemplateKwargs"], budget)
|
|
820
|
+
if values:
|
|
821
|
+
body["chat_template_kwargs"] = values
|
|
822
|
+
elif kind == "baseten" and model.reasoning:
|
|
823
|
+
values = _template_values(model, effort, compat["chatTemplateArgs"], budget)
|
|
824
|
+
if values:
|
|
825
|
+
body["chat_template_args"] = values
|
|
826
|
+
if effort_ok and (value := strict(effort)) is not _OMIT:
|
|
827
|
+
body["reasoning_effort"] = value
|
|
828
|
+
elif kind == "deepseek" and model.reasoning:
|
|
829
|
+
if effort:
|
|
830
|
+
body["thinking"] = {"type": "enabled"}
|
|
831
|
+
elif off_allowed:
|
|
832
|
+
body["thinking"] = {"type": "disabled"}
|
|
833
|
+
if effort and effort_ok:
|
|
834
|
+
body["reasoning_effort"] = mapped(effort)
|
|
835
|
+
elif kind == "openrouter" and model.reasoning:
|
|
836
|
+
if effort:
|
|
837
|
+
body["reasoning"] = {"effort": mapped(effort)}
|
|
838
|
+
elif off_allowed:
|
|
839
|
+
body["reasoning"] = {"effort": _first_defined(level_map.get("off"), "none")}
|
|
840
|
+
elif kind == "ant-ling" and model.reasoning and effort:
|
|
841
|
+
if isinstance(level_map.get(effort), str):
|
|
842
|
+
body["reasoning"] = {"effort": level_map[effort]}
|
|
843
|
+
elif kind == "together" and model.reasoning:
|
|
844
|
+
body["reasoning"] = {"enabled": bool(effort)}
|
|
845
|
+
if effort and effort_ok:
|
|
846
|
+
body["reasoning_effort"] = mapped(effort)
|
|
847
|
+
elif kind == "string-thinking" and model.reasoning:
|
|
848
|
+
if effort:
|
|
849
|
+
body["thinking"] = mapped(effort)
|
|
850
|
+
elif off_allowed:
|
|
851
|
+
body["thinking"] = _first_defined(level_map.get("off"), "none")
|
|
852
|
+
elif effort and model.reasoning and effort_ok:
|
|
853
|
+
body["reasoning_effort"] = mapped(effort)
|
|
854
|
+
elif not effort and model.reasoning and effort_ok:
|
|
855
|
+
if isinstance(level_map.get("off"), str):
|
|
856
|
+
body["reasoning_effort"] = level_map["off"]
|
|
857
|
+
|
|
858
|
+
def _headers(
|
|
859
|
+
self, request: ModelRequest, key: str | None, compat: dict[str, Any]
|
|
860
|
+
) -> dict[str, str]:
|
|
861
|
+
options = request.options
|
|
862
|
+
headers: dict[str, str] = {
|
|
863
|
+
"user-agent": f"pi-python/{__version__}",
|
|
864
|
+
"content-type": "application/json",
|
|
865
|
+
"accept": "text/event-stream",
|
|
866
|
+
}
|
|
867
|
+
if key is not None:
|
|
868
|
+
headers["authorization"] = f"Bearer {key}"
|
|
869
|
+
session = options.get("session_id")
|
|
870
|
+
if (
|
|
871
|
+
session
|
|
872
|
+
and options.get("cache_retention", "short") != "none"
|
|
873
|
+
and compat["sendSessionAffinityHeaders"]
|
|
874
|
+
):
|
|
875
|
+
if compat["sessionAffinityFormat"] == "openrouter":
|
|
876
|
+
headers["x-session-id"] = session
|
|
877
|
+
else:
|
|
878
|
+
if compat["sessionAffinityFormat"] == "openai":
|
|
879
|
+
headers["session_id"] = session
|
|
880
|
+
headers["x-client-request-id"] = session
|
|
881
|
+
headers["x-session-affinity"] = session
|
|
882
|
+
# Request headers override generated ones, credentials included (as in Pi).
|
|
883
|
+
for name, value in (options.get("headers") or {}).items():
|
|
884
|
+
headers.pop(name.lower(), None)
|
|
885
|
+
if value is not None:
|
|
886
|
+
headers[name.lower()] = value
|
|
887
|
+
return headers
|
|
888
|
+
|
|
889
|
+
@event_contract
|
|
890
|
+
async def stream(
|
|
891
|
+
self, request: ModelRequest, cancel: CancelToken
|
|
892
|
+
) -> AsyncGenerator[ModelEvent, None]:
|
|
893
|
+
if request.options.get("transport", "sse") not in {"sse", "auto"}:
|
|
894
|
+
raise UnsupportedCapabilityError("Chat Completions supports SSE transport")
|
|
895
|
+
key = await self._key(request, cancel)
|
|
896
|
+
model = self.model_info(request)
|
|
897
|
+
compat = resolve_compat(model, self.base_url)
|
|
898
|
+
body = await self.payload(request, self._build(request, model, compat))
|
|
899
|
+
events = self.transport.stream(
|
|
900
|
+
self.base_url + "/chat/completions",
|
|
901
|
+
body,
|
|
902
|
+
self._headers(request, key, compat),
|
|
903
|
+
cancel,
|
|
904
|
+
on_response=request.on_response,
|
|
905
|
+
)
|
|
906
|
+
content: list[TextContent | ThinkingContent | ToolCall] = []
|
|
907
|
+
# Pi streams one text and one reasoning block per response, in first-seen order.
|
|
908
|
+
text: TextContent | None = None
|
|
909
|
+
text_at = thinking_at = -1
|
|
910
|
+
thinking: ThinkingContent | None = None
|
|
911
|
+
# Tool calls are matched by stream index, else by ID, as in Pi.
|
|
912
|
+
by_index: dict[int, int] = {}
|
|
913
|
+
by_id: dict[str, int] = {}
|
|
914
|
+
stream_indexes: dict[int, int | None] = {}
|
|
915
|
+
arguments: dict[int, str] = {}
|
|
916
|
+
details: list[dict[str, Any]] | None = None
|
|
917
|
+
stop = "pending"
|
|
918
|
+
error: str | None = None
|
|
919
|
+
raw_stop: str | None = None
|
|
920
|
+
finished = False
|
|
921
|
+
usage = _usage({})
|
|
922
|
+
response_id: str | None = None
|
|
923
|
+
response_model: str | None = None
|
|
924
|
+
announced = ended = False
|
|
925
|
+
try:
|
|
926
|
+
async for chunk in events:
|
|
927
|
+
if not announced:
|
|
928
|
+
announced = True
|
|
929
|
+
yield ModelEvent("start")
|
|
930
|
+
if ended:
|
|
931
|
+
continue # the OpenAI SDK ignores data after [DONE]
|
|
932
|
+
if chunk.get("type") == "transport_done":
|
|
933
|
+
ended = True
|
|
934
|
+
continue
|
|
935
|
+
if _truthy(chunk.get("error")):
|
|
936
|
+
raise ProviderProtocolError(
|
|
937
|
+
"Provider stream error: " + json.dumps(chunk["error"], ensure_ascii=False)
|
|
938
|
+
)
|
|
939
|
+
await invoke(request.on_provider_stream_event, deepcopy(chunk))
|
|
940
|
+
if not response_id and isinstance(chunk.get("id"), str):
|
|
941
|
+
response_id = chunk["id"] or None
|
|
942
|
+
served = chunk.get("model")
|
|
943
|
+
if isinstance(served, str) and served and served != model.id:
|
|
944
|
+
response_model = response_model or served
|
|
945
|
+
if _truthy(chunk.get("usage")):
|
|
946
|
+
usage = _usage(chunk["usage"])
|
|
947
|
+
choices = chunk.get("choices")
|
|
948
|
+
choice = choices[0] if isinstance(choices, list) and choices else None
|
|
949
|
+
if not isinstance(choice, dict):
|
|
950
|
+
continue
|
|
951
|
+
# Some providers (e.g. Moonshot) report usage on the choice instead.
|
|
952
|
+
if not _truthy(chunk.get("usage")) and _truthy(choice.get("usage")):
|
|
953
|
+
usage = _usage(choice["usage"])
|
|
954
|
+
if _truthy(choice.get("finish_reason")):
|
|
955
|
+
raw_stop = str(choice["finish_reason"])
|
|
956
|
+
stop, message = _stop_reason(choice["finish_reason"])
|
|
957
|
+
error = message or error
|
|
958
|
+
finished = True
|
|
959
|
+
delta = choice.get("delta")
|
|
960
|
+
if not isinstance(delta, dict):
|
|
961
|
+
continue
|
|
962
|
+
piece = delta.get("content")
|
|
963
|
+
if piece is not None and not isinstance(piece, str):
|
|
964
|
+
raise UnsupportedCapabilityError("Chat Completions delta content must be text")
|
|
965
|
+
if piece:
|
|
966
|
+
if text is None:
|
|
967
|
+
text, text_at = TextContent(""), len(content)
|
|
968
|
+
content.append(text)
|
|
969
|
+
yield ModelEvent.boundary("start", text_at, TextContent(""))
|
|
970
|
+
text.text += piece
|
|
971
|
+
yield ModelEvent.text(piece, text_at)
|
|
972
|
+
# llama.cpp uses reasoning_content, others reasoning; the first wins.
|
|
973
|
+
field = next(
|
|
974
|
+
(f for f in _REASONING_FIELDS if isinstance(delta.get(f), str) and delta[f]),
|
|
975
|
+
None,
|
|
976
|
+
)
|
|
977
|
+
if field is not None:
|
|
978
|
+
if thinking is None:
|
|
979
|
+
signature = (
|
|
980
|
+
"reasoning_content"
|
|
981
|
+
if model.provider == "opencode-go" and field == "reasoning"
|
|
982
|
+
else field
|
|
983
|
+
)
|
|
984
|
+
thinking, thinking_at = ThinkingContent("", signature), len(content)
|
|
985
|
+
content.append(thinking)
|
|
986
|
+
yield ModelEvent.boundary("start", thinking_at, ThinkingContent(""))
|
|
987
|
+
thinking.thinking += delta[field]
|
|
988
|
+
yield ModelEvent.thinking(delta[field], thinking_at)
|
|
989
|
+
calls = delta.get("tool_calls")
|
|
990
|
+
if _truthy(calls):
|
|
991
|
+
if not isinstance(calls, list):
|
|
992
|
+
raise ProviderProtocolError("tool_calls delta must be a list")
|
|
993
|
+
for call in calls:
|
|
994
|
+
if not isinstance(call, dict):
|
|
995
|
+
raise ProviderProtocolError("tool call delta must be an object")
|
|
996
|
+
function = call.get("function")
|
|
997
|
+
if not isinstance(function, dict):
|
|
998
|
+
if call.get("custom") is not None:
|
|
999
|
+
raise UnsupportedCapabilityError(
|
|
1000
|
+
"Grammar (custom) tool calls are not supported"
|
|
1001
|
+
)
|
|
1002
|
+
function = {}
|
|
1003
|
+
stream_index = call.get("index") if _number(call.get("index")) else None
|
|
1004
|
+
call_id = call.get("id") if isinstance(call.get("id"), str) else ""
|
|
1005
|
+
position = by_index.get(stream_index) if stream_index is not None else None
|
|
1006
|
+
if position is None and call_id:
|
|
1007
|
+
position = by_id.get(call_id)
|
|
1008
|
+
if position is None:
|
|
1009
|
+
name = function.get("name")
|
|
1010
|
+
if not isinstance(name, str) or not name:
|
|
1011
|
+
raise ProviderProtocolError(
|
|
1012
|
+
"Tool call delta without a function name"
|
|
1013
|
+
)
|
|
1014
|
+
# A call without an ID still needs one for its result (Pi keeps "").
|
|
1015
|
+
block = ToolCall(call_id or f"call_{uuid.uuid4().hex[:24]}", name, {})
|
|
1016
|
+
position = len(content)
|
|
1017
|
+
content.append(block)
|
|
1018
|
+
arguments[position] = ""
|
|
1019
|
+
stream_indexes[position] = stream_index
|
|
1020
|
+
if stream_index is not None:
|
|
1021
|
+
by_index[stream_index] = position
|
|
1022
|
+
yield ModelEvent.boundary("start", position, deepcopy(block))
|
|
1023
|
+
if stream_index is not None and stream_indexes[position] is None:
|
|
1024
|
+
stream_indexes[position] = stream_index
|
|
1025
|
+
by_index[stream_index] = position
|
|
1026
|
+
if call_id:
|
|
1027
|
+
by_id[call_id] = position
|
|
1028
|
+
piece = function.get("arguments")
|
|
1029
|
+
if _truthy(piece):
|
|
1030
|
+
if not isinstance(piece, str):
|
|
1031
|
+
raise ProviderProtocolError("Tool call arguments must be a string")
|
|
1032
|
+
arguments[position] += piece
|
|
1033
|
+
else:
|
|
1034
|
+
piece = ""
|
|
1035
|
+
yield ModelEvent.toolcall(piece, position)
|
|
1036
|
+
found = delta.get("reasoning_details")
|
|
1037
|
+
if isinstance(found, list):
|
|
1038
|
+
for detail in found:
|
|
1039
|
+
if not _is_detail(detail):
|
|
1040
|
+
continue
|
|
1041
|
+
if thinking is None:
|
|
1042
|
+
thinking, thinking_at = ThinkingContent("", ""), len(content)
|
|
1043
|
+
content.append(thinking)
|
|
1044
|
+
yield ModelEvent.boundary("start", thinking_at, ThinkingContent(""))
|
|
1045
|
+
# Replay metadata, not visible deltas: merged and kept as the signature.
|
|
1046
|
+
details = details if details is not None else []
|
|
1047
|
+
_append_detail(details, detail)
|
|
1048
|
+
finally:
|
|
1049
|
+
await events.aclose()
|
|
1050
|
+
for position, item in enumerate(content):
|
|
1051
|
+
if isinstance(item, ThinkingContent) and details is not None:
|
|
1052
|
+
item.thinking_signature = json.dumps(
|
|
1053
|
+
details, ensure_ascii=False, separators=(",", ":")
|
|
1054
|
+
)
|
|
1055
|
+
elif isinstance(item, ToolCall):
|
|
1056
|
+
raw = arguments[position]
|
|
1057
|
+
if _blank(raw):
|
|
1058
|
+
# A call without arguments; Pi parses "" as {}.
|
|
1059
|
+
yield ModelEvent.toolcall("{}", position)
|
|
1060
|
+
raw = "{}"
|
|
1061
|
+
try:
|
|
1062
|
+
parsed = json.loads(raw)
|
|
1063
|
+
except ValueError as exc:
|
|
1064
|
+
raise ProviderProtocolError(
|
|
1065
|
+
f"Tool call {item.name} has incomplete or invalid JSON arguments"
|
|
1066
|
+
) from exc
|
|
1067
|
+
if not isinstance(parsed, dict):
|
|
1068
|
+
raise ProviderProtocolError("Tool arguments must be an object")
|
|
1069
|
+
item.arguments = parsed
|
|
1070
|
+
yield ModelEvent.boundary("end", position, deepcopy(item))
|
|
1071
|
+
cancel.raise_if_cancelled()
|
|
1072
|
+
if not finished and not compat["supportsFinishReason"]:
|
|
1073
|
+
stop = "tool_use" if any(isinstance(b, ToolCall) for b in content) else "stop"
|
|
1074
|
+
if stop == "error":
|
|
1075
|
+
raise ProviderProtocolError(error or "Provider returned an error stop reason")
|
|
1076
|
+
if (compat["supportsFinishReason"] and not finished) or stop == "pending":
|
|
1077
|
+
raise ProviderProtocolError("Stream ended without finish_reason")
|
|
1078
|
+
yield ModelEvent.done(
|
|
1079
|
+
AssistantMessage(
|
|
1080
|
+
content,
|
|
1081
|
+
stop,
|
|
1082
|
+
self.name,
|
|
1083
|
+
request.model,
|
|
1084
|
+
usage,
|
|
1085
|
+
api=API,
|
|
1086
|
+
thinking_level=request.options.get("reasoning"),
|
|
1087
|
+
response_id=response_id,
|
|
1088
|
+
response_model=response_model,
|
|
1089
|
+
raw_stop_reason=raw_stop,
|
|
1090
|
+
)
|
|
1091
|
+
)
|
|
1092
|
+
|
|
1093
|
+
|
|
1094
|
+
def _template_values(
|
|
1095
|
+
model: ModelInfo, effort: str | None, values: dict[str, Any], budget: int | None
|
|
1096
|
+
) -> dict[str, Any] | None:
|
|
1097
|
+
"""Pi buildChatTemplateValues: literal values, or `{"$var": ...}` thinking values."""
|
|
1098
|
+
result: dict[str, Any] = {}
|
|
1099
|
+
level_map = model.thinking_level_map
|
|
1100
|
+
for key, value in values.items():
|
|
1101
|
+
if not isinstance(value, dict):
|
|
1102
|
+
result[key] = value
|
|
1103
|
+
continue
|
|
1104
|
+
if not effort and value.get("omitWhenOff"):
|
|
1105
|
+
continue
|
|
1106
|
+
variable = value.get("$var")
|
|
1107
|
+
if variable == "thinking.enabled":
|
|
1108
|
+
result[key] = bool(effort)
|
|
1109
|
+
elif variable == "thinking.budget":
|
|
1110
|
+
if budget is not None:
|
|
1111
|
+
result[key] = budget
|
|
1112
|
+
else:
|
|
1113
|
+
level = effort if effort else "off"
|
|
1114
|
+
resolved = level_map[level] if level in level_map else effort
|
|
1115
|
+
if isinstance(resolved, str):
|
|
1116
|
+
result[key] = resolved
|
|
1117
|
+
return result or None
|
|
1118
|
+
|
|
1119
|
+
|
|
1120
|
+
def _cache_text(message: dict[str, Any], cache: dict[str, Any]) -> bool:
|
|
1121
|
+
"""Pi addCacheControlToTextContent."""
|
|
1122
|
+
content = message.get("content")
|
|
1123
|
+
if isinstance(content, str):
|
|
1124
|
+
if not content:
|
|
1125
|
+
return False
|
|
1126
|
+
message["content"] = [{"type": "text", "text": content, "cache_control": deepcopy(cache)}]
|
|
1127
|
+
return True
|
|
1128
|
+
if not isinstance(content, list):
|
|
1129
|
+
return False
|
|
1130
|
+
for part in reversed(content):
|
|
1131
|
+
if isinstance(part, dict) and part.get("type") == "text":
|
|
1132
|
+
part["cache_control"] = deepcopy(cache)
|
|
1133
|
+
return True
|
|
1134
|
+
return False
|
|
1135
|
+
|
|
1136
|
+
|
|
1137
|
+
def _apply_cache_control(
|
|
1138
|
+
messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None, cache: dict[str, Any]
|
|
1139
|
+
) -> None:
|
|
1140
|
+
"""Pi applyAnthropicCacheControl: system prompt, last tool, last conversation text."""
|
|
1141
|
+
for message in messages:
|
|
1142
|
+
if message["role"] in {"system", "developer"}:
|
|
1143
|
+
_cache_text(message, cache)
|
|
1144
|
+
break
|
|
1145
|
+
if tools:
|
|
1146
|
+
tools[-1]["cache_control"] = deepcopy(cache)
|
|
1147
|
+
for message in reversed(messages):
|
|
1148
|
+
if message["role"] in {"user", "assistant", "tool"} and _cache_text(message, cache):
|
|
1149
|
+
break
|