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.
@@ -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