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,681 @@
|
|
|
1
|
+
"""OpenAI Responses API and Codex subscription transport, ported from Pi v1.0.0."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
from collections.abc import AsyncGenerator
|
|
5
|
+
from ..cancellation import CancelToken
|
|
6
|
+
from ..provider import ModelRequest
|
|
7
|
+
from ..messages import Message, ToolDeclaration
|
|
8
|
+
from ..models import ModelInfo
|
|
9
|
+
from typing import Any
|
|
10
|
+
import base64
|
|
11
|
+
import json
|
|
12
|
+
from copy import deepcopy
|
|
13
|
+
from ..errors import ConfigurationError, ProviderProtocolError, UnsupportedCapabilityError
|
|
14
|
+
from ..messages import (
|
|
15
|
+
AssistantMessage,
|
|
16
|
+
UserMessage,
|
|
17
|
+
ToolResultMessage,
|
|
18
|
+
TextContent,
|
|
19
|
+
ImageContent,
|
|
20
|
+
ThinkingContent,
|
|
21
|
+
ToolCall,
|
|
22
|
+
CustomMessage,
|
|
23
|
+
)
|
|
24
|
+
from ..provider import ModelEvent
|
|
25
|
+
import re
|
|
26
|
+
from ..messages import SystemMessage
|
|
27
|
+
from ..estimate import clamp_max_tokens_to_context, short_hash
|
|
28
|
+
from ..transcript import (
|
|
29
|
+
current_system_prompt,
|
|
30
|
+
initial_system_message,
|
|
31
|
+
render_system_update,
|
|
32
|
+
resolve_transcript,
|
|
33
|
+
resolve_transcript_tools,
|
|
34
|
+
system_message_text,
|
|
35
|
+
with_request_tools,
|
|
36
|
+
)
|
|
37
|
+
from ..tools import invoke
|
|
38
|
+
from ..stream import event_contract
|
|
39
|
+
from .common import RemoteProvider, normalize_usage, transform_messages
|
|
40
|
+
from .transport import WebSocketLink
|
|
41
|
+
from .._version import __version__
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def input_content(content: str | list[TextContent | ImageContent]) -> list[dict[str, Any]]:
|
|
45
|
+
if isinstance(content, str):
|
|
46
|
+
return [{"type": "input_text", "text": content}]
|
|
47
|
+
return [
|
|
48
|
+
(
|
|
49
|
+
{"type": "input_text", "text": b.text}
|
|
50
|
+
if isinstance(b, TextContent)
|
|
51
|
+
else {
|
|
52
|
+
"type": "input_image",
|
|
53
|
+
"image_url": f"data:{b.mime_type};base64,{b.data}",
|
|
54
|
+
"detail": "auto",
|
|
55
|
+
}
|
|
56
|
+
)
|
|
57
|
+
for b in content
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def account_id(token: str) -> str:
|
|
62
|
+
"""Read an account routing hint, not an identity or signature verification."""
|
|
63
|
+
try:
|
|
64
|
+
part = token.split(".")[1]
|
|
65
|
+
payload = json.loads(base64.urlsafe_b64decode(part + "=" * (-len(part) % 4)))
|
|
66
|
+
value = payload["https://api.openai.com/auth"]["chatgpt_account_id"]
|
|
67
|
+
if not isinstance(value, str) or not value:
|
|
68
|
+
raise ValueError
|
|
69
|
+
return value
|
|
70
|
+
except (ValueError, KeyError, IndexError, TypeError) as exc:
|
|
71
|
+
raise ConfigurationError("Codex OAuth token lacks an account routing ID") from exc
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def response_block(item: dict[str, Any]) -> TextContent | ThinkingContent | ToolCall:
|
|
75
|
+
blocks: list[TextContent | ThinkingContent | ToolCall] = []
|
|
76
|
+
kind = item["type"]
|
|
77
|
+
if kind == "message":
|
|
78
|
+
text = "".join(c.get("text", c.get("refusal", "")) for c in item.get("content", []))
|
|
79
|
+
signature = {"v": 1, "id": item["id"]}
|
|
80
|
+
if item.get("phase"):
|
|
81
|
+
signature["phase"] = item["phase"]
|
|
82
|
+
blocks.append(TextContent(text, json.dumps(signature, separators=(",", ":"))))
|
|
83
|
+
elif kind == "reasoning":
|
|
84
|
+
text = "\n\n".join(c["text"] for c in (item.get("summary") or item.get("content") or []))
|
|
85
|
+
blocks.append(
|
|
86
|
+
ThinkingContent(text, json.dumps(item, separators=(",", ":"), ensure_ascii=False))
|
|
87
|
+
)
|
|
88
|
+
elif kind == "function_call":
|
|
89
|
+
arguments = json.loads(item["arguments"])
|
|
90
|
+
if not isinstance(arguments, dict):
|
|
91
|
+
raise ProviderProtocolError("Tool arguments must be an object")
|
|
92
|
+
blocks.append(
|
|
93
|
+
ToolCall(
|
|
94
|
+
item["call_id"] + "|" + item["id"],
|
|
95
|
+
item["name"],
|
|
96
|
+
arguments,
|
|
97
|
+
namespace=item.get("namespace"),
|
|
98
|
+
)
|
|
99
|
+
)
|
|
100
|
+
else:
|
|
101
|
+
raise UnsupportedCapabilityError(f"Unsupported OpenAI output item: {kind}")
|
|
102
|
+
return blocks[0]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
_TOOL_CALL_PROVIDERS = {"openai", "openai-codex", "opencode"}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _id_part(value: str) -> str:
|
|
109
|
+
return re.sub(r"[^a-zA-Z0-9_-]", "_", value)[:64].rstrip("_")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _text_signature(signature: str | None) -> dict[str, str] | None:
|
|
113
|
+
"""Pi parseTextSignature: a TextSignatureV1 JSON object or a legacy item ID."""
|
|
114
|
+
if not signature:
|
|
115
|
+
return None
|
|
116
|
+
if signature.startswith("{"):
|
|
117
|
+
try:
|
|
118
|
+
parsed = json.loads(signature)
|
|
119
|
+
except ValueError:
|
|
120
|
+
parsed = None
|
|
121
|
+
if isinstance(parsed, dict) and parsed.get("v") == 1 and isinstance(parsed.get("id"), str):
|
|
122
|
+
if parsed.get("phase") in {"commentary", "final_answer"}:
|
|
123
|
+
return {"id": parsed["id"], "phase": parsed["phase"]}
|
|
124
|
+
return {"id": parsed["id"]}
|
|
125
|
+
return {"id": signature}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _tool_output(
|
|
129
|
+
content: list[TextContent | ImageContent], images_allowed: bool
|
|
130
|
+
) -> str | list[dict[str, Any]]:
|
|
131
|
+
text = "\n".join(b.text for b in content if isinstance(b, TextContent))
|
|
132
|
+
images = [b for b in content if isinstance(b, ImageContent)]
|
|
133
|
+
if not images or not images_allowed:
|
|
134
|
+
return text or ("(see attached image)" if images else "(no tool output)")
|
|
135
|
+
output: list[dict[str, Any]] = [{"type": "input_text", "text": text}] if text else []
|
|
136
|
+
return output + [
|
|
137
|
+
{
|
|
138
|
+
"type": "input_image",
|
|
139
|
+
"detail": "auto",
|
|
140
|
+
"image_url": f"data:{b.mime_type};base64,{b.data}",
|
|
141
|
+
}
|
|
142
|
+
for b in images
|
|
143
|
+
]
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class OpenAIProvider(RemoteProvider):
|
|
147
|
+
name = "openai"
|
|
148
|
+
api = "openai-responses"
|
|
149
|
+
|
|
150
|
+
def __init__(self, *, base_url: str = "https://api.openai.com/v1", **kwargs: Any) -> None:
|
|
151
|
+
super().__init__(**kwargs)
|
|
152
|
+
self.base_url = base_url.rstrip("/")
|
|
153
|
+
|
|
154
|
+
def _tools(
|
|
155
|
+
self, tools: list[ToolDeclaration], compat: dict[str, Any], strict: bool | None
|
|
156
|
+
) -> list[dict[str, Any]]:
|
|
157
|
+
supports_strict = compat.get("supportsStrictMode", self.name == "openai-codex")
|
|
158
|
+
return [
|
|
159
|
+
{
|
|
160
|
+
"type": "function",
|
|
161
|
+
"name": t.name,
|
|
162
|
+
"description": t.description,
|
|
163
|
+
"parameters": deepcopy(t.input_schema),
|
|
164
|
+
**({"strict": strict} if supports_strict else {}),
|
|
165
|
+
}
|
|
166
|
+
for t in tools
|
|
167
|
+
]
|
|
168
|
+
|
|
169
|
+
def _input(
|
|
170
|
+
self,
|
|
171
|
+
request: ModelRequest,
|
|
172
|
+
model: ModelInfo,
|
|
173
|
+
transcript: list[Message],
|
|
174
|
+
compat: dict[str, Any],
|
|
175
|
+
*,
|
|
176
|
+
include_system: bool,
|
|
177
|
+
strict: bool | None,
|
|
178
|
+
) -> tuple[list[dict[str, Any]], list[Message]]:
|
|
179
|
+
"""Pi convertResponsesMessages for this provider's model."""
|
|
180
|
+
mid = compat.get("supportsMidConvoSystemMessages") is True
|
|
181
|
+
transcript = resolve_transcript(transcript, mid)
|
|
182
|
+
allowed = self.name in _TOOL_CALL_PROVIDERS
|
|
183
|
+
|
|
184
|
+
def normalize(value: str, source: AssistantMessage) -> str:
|
|
185
|
+
if not allowed or "|" not in value:
|
|
186
|
+
return _id_part(value)
|
|
187
|
+
call, item = value.split("|")[:2]
|
|
188
|
+
foreign = source.provider != self.name or source.api != self.api
|
|
189
|
+
item = ("fc_" + short_hash(item))[:64] if foreign else _id_part(item)
|
|
190
|
+
if not item.startswith("fc_"):
|
|
191
|
+
item = _id_part("fc_" + item)
|
|
192
|
+
return f"{_id_part(call)}|{item}"
|
|
193
|
+
|
|
194
|
+
transformed = transform_messages(transcript, self.name, self.api, request.model, normalize)
|
|
195
|
+
additional = compat.get("supportsAdditionalTools") is True
|
|
196
|
+
search = compat.get("supportsToolSearch") is True
|
|
197
|
+
_, anchors = resolve_transcript_tools(transcript, additional or search)
|
|
198
|
+
role = (
|
|
199
|
+
"developer"
|
|
200
|
+
if model.reasoning and compat.get("supportsDeveloperRole") is not False
|
|
201
|
+
else "system"
|
|
202
|
+
)
|
|
203
|
+
images_allowed = "image" in model.input
|
|
204
|
+
items: list[dict[str, Any]] = []
|
|
205
|
+
position = 0
|
|
206
|
+
for source, message in enumerate(transformed):
|
|
207
|
+
leading = source == 0 and isinstance(message, SystemMessage)
|
|
208
|
+
if isinstance(message, SystemMessage):
|
|
209
|
+
if not leading and anchors and message.tools_added:
|
|
210
|
+
tools = message.tools_added
|
|
211
|
+
if additional:
|
|
212
|
+
items.append(
|
|
213
|
+
{
|
|
214
|
+
"type": "additional_tools",
|
|
215
|
+
"role": "developer",
|
|
216
|
+
"tools": self._tools(tools, compat, strict),
|
|
217
|
+
}
|
|
218
|
+
)
|
|
219
|
+
elif search:
|
|
220
|
+
# Client-executed tool search loads late tools where they appear.
|
|
221
|
+
names = [t.name for t in tools]
|
|
222
|
+
call = "pi_tool_load_" + short_hash(f"system:{position}:{','.join(names)}")
|
|
223
|
+
items.append(
|
|
224
|
+
{
|
|
225
|
+
"type": "tool_search_call",
|
|
226
|
+
"call_id": call,
|
|
227
|
+
"execution": "client",
|
|
228
|
+
"status": "completed",
|
|
229
|
+
"arguments": {"query": " ".join(names), "limit": len(names)},
|
|
230
|
+
}
|
|
231
|
+
)
|
|
232
|
+
items.append(
|
|
233
|
+
{
|
|
234
|
+
"type": "tool_search_output",
|
|
235
|
+
"call_id": call,
|
|
236
|
+
"execution": "client",
|
|
237
|
+
"status": "completed",
|
|
238
|
+
"tools": [
|
|
239
|
+
{**t, "defer_loading": True}
|
|
240
|
+
for t in self._tools(tools, compat, strict)
|
|
241
|
+
],
|
|
242
|
+
}
|
|
243
|
+
)
|
|
244
|
+
if not leading or include_system:
|
|
245
|
+
text = (
|
|
246
|
+
system_message_text(message) if leading else render_system_update(message)
|
|
247
|
+
)
|
|
248
|
+
if text:
|
|
249
|
+
items.append({"role": role, "content": text})
|
|
250
|
+
elif isinstance(message, UserMessage):
|
|
251
|
+
content = input_content(message.content)
|
|
252
|
+
if not content:
|
|
253
|
+
continue
|
|
254
|
+
items.append({"role": "user", "content": content})
|
|
255
|
+
elif isinstance(message, AssistantMessage):
|
|
256
|
+
output: list[dict[str, Any]] = []
|
|
257
|
+
same_api = message.provider == self.name and message.api == self.api
|
|
258
|
+
same = same_api and message.model == request.model
|
|
259
|
+
text_index = 0
|
|
260
|
+
for b in message.content:
|
|
261
|
+
if isinstance(b, ThinkingContent):
|
|
262
|
+
if b.thinking_signature:
|
|
263
|
+
item = json.loads(b.thinking_signature)
|
|
264
|
+
if not isinstance(item, dict) or item.get("type") != "reasoning":
|
|
265
|
+
raise ProviderProtocolError("Invalid reasoning replay item")
|
|
266
|
+
output.append(item)
|
|
267
|
+
elif isinstance(b, TextContent):
|
|
268
|
+
signature = _text_signature(b.text_signature)
|
|
269
|
+
fallback = (
|
|
270
|
+
f"msg_pi_{position}"
|
|
271
|
+
if text_index == 0
|
|
272
|
+
else f"msg_pi_{position}_{text_index}"
|
|
273
|
+
)
|
|
274
|
+
text_index += 1
|
|
275
|
+
identifier = signature["id"] if signature else fallback
|
|
276
|
+
if len(identifier) > 64:
|
|
277
|
+
identifier = "msg_" + short_hash(identifier)
|
|
278
|
+
item = {
|
|
279
|
+
"type": "message",
|
|
280
|
+
"role": "assistant",
|
|
281
|
+
"content": [{"type": "output_text", "text": b.text, "annotations": []}],
|
|
282
|
+
"status": "completed",
|
|
283
|
+
"id": identifier,
|
|
284
|
+
}
|
|
285
|
+
if signature and signature.get("phase"):
|
|
286
|
+
item["phase"] = signature["phase"]
|
|
287
|
+
output.append(item)
|
|
288
|
+
elif isinstance(b, ToolCall):
|
|
289
|
+
call, _, item_id = b.id.partition("|")
|
|
290
|
+
item = {
|
|
291
|
+
"type": "function_call",
|
|
292
|
+
"call_id": call,
|
|
293
|
+
"name": b.name,
|
|
294
|
+
"arguments": json.dumps(
|
|
295
|
+
b.arguments, ensure_ascii=False, separators=(",", ":")
|
|
296
|
+
),
|
|
297
|
+
}
|
|
298
|
+
# A different model's item ID would trip reasoning pairing validation.
|
|
299
|
+
if not (same_api and not same) and item_id.startswith("fc_"):
|
|
300
|
+
item = {"type": "function_call", "id": item_id, **item}
|
|
301
|
+
if same and b.namespace is not None:
|
|
302
|
+
item["namespace"] = b.namespace
|
|
303
|
+
output.append(item)
|
|
304
|
+
if not output:
|
|
305
|
+
continue
|
|
306
|
+
items += output
|
|
307
|
+
elif isinstance(message, ToolResultMessage):
|
|
308
|
+
items.append(
|
|
309
|
+
{
|
|
310
|
+
"type": "function_call_output",
|
|
311
|
+
"call_id": message.call_id.split("|")[0],
|
|
312
|
+
"output": _tool_output(message.content, images_allowed),
|
|
313
|
+
}
|
|
314
|
+
)
|
|
315
|
+
elif isinstance(message, CustomMessage):
|
|
316
|
+
raise UnsupportedCapabilityError("Convert custom messages before provider boundary")
|
|
317
|
+
if not leading:
|
|
318
|
+
position += 1
|
|
319
|
+
return items, transcript
|
|
320
|
+
|
|
321
|
+
def build_request(self, request: ModelRequest, chatgpt_sign_in: bool = False) -> dict[str, Any]:
|
|
322
|
+
model = self.model_info(request)
|
|
323
|
+
compat = model.compat
|
|
324
|
+
options = request.options
|
|
325
|
+
source = with_request_tools(request.messages, request.tools)
|
|
326
|
+
codex = self.name == "openai-codex"
|
|
327
|
+
strict = None if codex else False
|
|
328
|
+
items, transcript = self._input(
|
|
329
|
+
request, model, source, compat, include_system=not codex, strict=strict
|
|
330
|
+
)
|
|
331
|
+
additional = compat.get("supportsAdditionalTools") is True
|
|
332
|
+
search = compat.get("supportsToolSearch") is True
|
|
333
|
+
tools, _ = resolve_transcript_tools(transcript, additional or search)
|
|
334
|
+
retention = options.get("cache_retention", "short")
|
|
335
|
+
if retention not in {"none", "short", "long"}:
|
|
336
|
+
raise ConfigurationError("Invalid cache_retention")
|
|
337
|
+
key = options.get("session_id") if retention != "none" else None
|
|
338
|
+
if isinstance(key, str) and len(key) > 64:
|
|
339
|
+
key = key[:64]
|
|
340
|
+
body: dict[str, Any] = {
|
|
341
|
+
"model": request.model,
|
|
342
|
+
"input": items,
|
|
343
|
+
"stream": True,
|
|
344
|
+
"store": False,
|
|
345
|
+
}
|
|
346
|
+
if key:
|
|
347
|
+
body["prompt_cache_key"] = key
|
|
348
|
+
reasoning = options.get("reasoning")
|
|
349
|
+
if reasoning == "off":
|
|
350
|
+
reasoning = None
|
|
351
|
+
if reasoning is not None:
|
|
352
|
+
if reasoning not in {"minimal", "low", "medium", "high", "xhigh", "max"}:
|
|
353
|
+
raise ConfigurationError("Unsupported reasoning effort")
|
|
354
|
+
reasoning = model.clamp_thinking_level(reasoning)
|
|
355
|
+
if reasoning == "off":
|
|
356
|
+
reasoning = None
|
|
357
|
+
level_map = model.thinking_level_map
|
|
358
|
+
if codex:
|
|
359
|
+
initial = initial_system_message(transcript)
|
|
360
|
+
body["instructions"] = (
|
|
361
|
+
system_message_text(initial) if initial else ""
|
|
362
|
+
) or "You are a helpful assistant."
|
|
363
|
+
body["text"] = deepcopy(options.get("text", {"verbosity": "low"}))
|
|
364
|
+
body["include"] = ["reasoning.encrypted_content"]
|
|
365
|
+
body["tool_choice"] = deepcopy(options.get("tool_choice", "auto"))
|
|
366
|
+
body["parallel_tool_calls"] = options.get("parallel_tool_calls", True)
|
|
367
|
+
if reasoning is not None:
|
|
368
|
+
effort = level_map.get(reasoning) or reasoning
|
|
369
|
+
body["reasoning"] = {
|
|
370
|
+
"effort": effort,
|
|
371
|
+
"summary": options.get("reasoning_summary", "auto"),
|
|
372
|
+
}
|
|
373
|
+
elif model.reasoning and level_map.get("off", "") is not None:
|
|
374
|
+
body["reasoning"] = {"effort": level_map.get("off") or "none"}
|
|
375
|
+
else:
|
|
376
|
+
explicit = compat.get("supportsExplicitPromptCacheMode") is True
|
|
377
|
+
long_ok = compat.get("supportsLongCacheRetention", True)
|
|
378
|
+
if not chatgpt_sign_in:
|
|
379
|
+
if retention == "long" and long_ok and not explicit:
|
|
380
|
+
body["prompt_cache_retention"] = "24h"
|
|
381
|
+
if explicit and retention == "none":
|
|
382
|
+
body["prompt_cache_options"] = {"mode": "explicit"}
|
|
383
|
+
elif explicit and retention == "long" and long_ok:
|
|
384
|
+
body["prompt_cache_options"] = {"ttl": "30m"}
|
|
385
|
+
max_tokens = clamp_max_tokens_to_context(
|
|
386
|
+
model.context_window, source, options.get("max_tokens", model.max_tokens)
|
|
387
|
+
)
|
|
388
|
+
if max_tokens and compat.get("supportsMaxOutputTokens", True) and not chatgpt_sign_in:
|
|
389
|
+
body["max_output_tokens"] = max(max_tokens, 16)
|
|
390
|
+
if model.reasoning:
|
|
391
|
+
if reasoning is not None or options.get("reasoning_summary"):
|
|
392
|
+
effort = (level_map.get(reasoning) or reasoning) if reasoning else "medium"
|
|
393
|
+
body["reasoning"] = {
|
|
394
|
+
"effort": effort,
|
|
395
|
+
"summary": options.get("reasoning_summary") or "auto",
|
|
396
|
+
}
|
|
397
|
+
body["include"] = ["reasoning.encrypted_content"]
|
|
398
|
+
elif level_map.get("off", "") is not None:
|
|
399
|
+
body["reasoning"] = {"effort": level_map.get("off") or "none"}
|
|
400
|
+
if tools:
|
|
401
|
+
body["tools"] = self._tools(tools, compat, strict)
|
|
402
|
+
for key_name in (
|
|
403
|
+
"temperature",
|
|
404
|
+
"top_p",
|
|
405
|
+
"tool_choice",
|
|
406
|
+
"parallel_tool_calls",
|
|
407
|
+
"metadata",
|
|
408
|
+
"service_tier",
|
|
409
|
+
"text",
|
|
410
|
+
"include",
|
|
411
|
+
):
|
|
412
|
+
if key_name in options and not (chatgpt_sign_in and key_name == "temperature"):
|
|
413
|
+
body[key_name] = deepcopy(options[key_name])
|
|
414
|
+
return body
|
|
415
|
+
|
|
416
|
+
@event_contract
|
|
417
|
+
async def stream(
|
|
418
|
+
self, request: ModelRequest, cancel: CancelToken
|
|
419
|
+
) -> AsyncGenerator[ModelEvent, None]:
|
|
420
|
+
key = await self.credential(request, cancel)
|
|
421
|
+
headers = {
|
|
422
|
+
**request.options.get("headers", {}),
|
|
423
|
+
"authorization": f"Bearer {key}",
|
|
424
|
+
"content-type": "application/json",
|
|
425
|
+
"accept": "text/event-stream",
|
|
426
|
+
}
|
|
427
|
+
transport = request.options.get("transport", "sse")
|
|
428
|
+
if transport not in {"sse", "websocket", "websocket-cached", "auto"}:
|
|
429
|
+
raise ConfigurationError("transport must be sse, websocket, websocket-cached or auto")
|
|
430
|
+
if self.name == "openai-codex":
|
|
431
|
+
headers.update(
|
|
432
|
+
{
|
|
433
|
+
"chatgpt-account-id": account_id(key),
|
|
434
|
+
"originator": "pi",
|
|
435
|
+
"user-agent": f"pi-python/{__version__}",
|
|
436
|
+
"OpenAI-Beta": "responses=experimental",
|
|
437
|
+
}
|
|
438
|
+
)
|
|
439
|
+
if (
|
|
440
|
+
request.options.get("session_id")
|
|
441
|
+
and request.options.get("cache_retention") != "none"
|
|
442
|
+
):
|
|
443
|
+
headers["session-id"] = headers["x-client-request-id"] = request.options[
|
|
444
|
+
"session_id"
|
|
445
|
+
][:64]
|
|
446
|
+
if (
|
|
447
|
+
self.name == "openai"
|
|
448
|
+
and request.options.get("session_id")
|
|
449
|
+
and request.options.get("cache_retention") != "none"
|
|
450
|
+
):
|
|
451
|
+
headers["x-client-request-id"] = request.options["session_id"]
|
|
452
|
+
# Sign in with ChatGPT rejects some request fields that API keys accept.
|
|
453
|
+
chatgpt = (
|
|
454
|
+
self.name == "openai"
|
|
455
|
+
and self.base_url == "https://api.openai.com/v1"
|
|
456
|
+
and not key.startswith("sk-")
|
|
457
|
+
)
|
|
458
|
+
body = await self.payload(request, self.build_request(request, chatgpt))
|
|
459
|
+
if transport == "websocket-cached" and not request.options.get("session_id"):
|
|
460
|
+
raise ConfigurationError("websocket-cached requires session_id")
|
|
461
|
+
# Pi caches a connection per session unless caching is disabled for the request.
|
|
462
|
+
cached = (
|
|
463
|
+
request.options.get("session_id")
|
|
464
|
+
if request.options.get("cache_retention") != "none"
|
|
465
|
+
else None
|
|
466
|
+
)
|
|
467
|
+
# Codex keeps connection-scoped response state, so a cached socket can continue
|
|
468
|
+
# from the previous response instead of resending the whole context.
|
|
469
|
+
link = (
|
|
470
|
+
WebSocketLink()
|
|
471
|
+
if self.name == "openai-codex" and cached and transport in {"websocket-cached", "auto"}
|
|
472
|
+
else None
|
|
473
|
+
)
|
|
474
|
+
events = self.transport.responses(
|
|
475
|
+
self.base_url + "/responses",
|
|
476
|
+
body,
|
|
477
|
+
headers,
|
|
478
|
+
cancel,
|
|
479
|
+
mode=transport,
|
|
480
|
+
session_id=cached,
|
|
481
|
+
on_response=request.on_response,
|
|
482
|
+
**({"link": link} if link is not None else {}),
|
|
483
|
+
)
|
|
484
|
+
slots: dict[int, Any] = {}
|
|
485
|
+
final_response = None
|
|
486
|
+
announced = False
|
|
487
|
+
try:
|
|
488
|
+
async for event in events:
|
|
489
|
+
if not announced:
|
|
490
|
+
announced = True
|
|
491
|
+
yield ModelEvent("start")
|
|
492
|
+
await invoke(request.on_provider_stream_event, deepcopy(event))
|
|
493
|
+
kind = event.get("type", "")
|
|
494
|
+
if kind == "transport_done":
|
|
495
|
+
continue
|
|
496
|
+
if final_response is not None:
|
|
497
|
+
raise ProviderProtocolError("OpenAI event after completion")
|
|
498
|
+
if kind in {"error", "response.failed"}:
|
|
499
|
+
raise ProviderProtocolError("OpenAI stream reported an error")
|
|
500
|
+
raw_index = event.get("output_index")
|
|
501
|
+
index = raw_index if type(raw_index) is int else -1
|
|
502
|
+
if index < 0 and (
|
|
503
|
+
kind.startswith(("response.output_item.", "response.reasoning_summary_part."))
|
|
504
|
+
or kind.endswith(".delta")
|
|
505
|
+
):
|
|
506
|
+
raise ProviderProtocolError(f"OpenAI {kind} without output_index")
|
|
507
|
+
if kind == "response.output_item.added":
|
|
508
|
+
item = event["item"]
|
|
509
|
+
if index in slots:
|
|
510
|
+
raise ProviderProtocolError("Duplicate OpenAI output item")
|
|
511
|
+
slots[index] = {
|
|
512
|
+
"item": item,
|
|
513
|
+
"index": len(slots),
|
|
514
|
+
"text": "",
|
|
515
|
+
"thinking": "",
|
|
516
|
+
"arguments": item.get("arguments", ""),
|
|
517
|
+
}
|
|
518
|
+
initial = (
|
|
519
|
+
response_block({**item, "arguments": "{}"})
|
|
520
|
+
if item["type"] == "function_call"
|
|
521
|
+
else TextContent("")
|
|
522
|
+
if item["type"] == "message"
|
|
523
|
+
else ThinkingContent("")
|
|
524
|
+
if item["type"] == "reasoning"
|
|
525
|
+
else None
|
|
526
|
+
)
|
|
527
|
+
if initial is None:
|
|
528
|
+
raise UnsupportedCapabilityError(
|
|
529
|
+
f"Unsupported OpenAI output item: {item['type']}"
|
|
530
|
+
)
|
|
531
|
+
yield ModelEvent.boundary("start", slots[index]["index"], initial)
|
|
532
|
+
if item.get("arguments") and item.get("type") == "function_call":
|
|
533
|
+
yield ModelEvent.toolcall(item["arguments"], slots[index]["index"])
|
|
534
|
+
elif kind in {
|
|
535
|
+
"response.output_text.delta",
|
|
536
|
+
"response.refusal.delta",
|
|
537
|
+
"response.reasoning_summary_text.delta",
|
|
538
|
+
"response.reasoning_text.delta",
|
|
539
|
+
"response.function_call_arguments.delta",
|
|
540
|
+
}:
|
|
541
|
+
if index not in slots:
|
|
542
|
+
raise ProviderProtocolError("Delta without output item")
|
|
543
|
+
slot = slots[index]
|
|
544
|
+
delta = event["delta"]
|
|
545
|
+
if kind in {"response.output_text.delta", "response.refusal.delta"}:
|
|
546
|
+
slot["text"] += delta
|
|
547
|
+
yield ModelEvent.text(delta, slot["index"])
|
|
548
|
+
elif kind == "response.function_call_arguments.delta":
|
|
549
|
+
slot["arguments"] += delta
|
|
550
|
+
yield ModelEvent.toolcall(delta, slot["index"])
|
|
551
|
+
else:
|
|
552
|
+
slot["thinking"] += delta
|
|
553
|
+
yield ModelEvent.thinking(delta, slot["index"])
|
|
554
|
+
elif (
|
|
555
|
+
kind == "response.reasoning_summary_part.added"
|
|
556
|
+
and event.get("summary_index", 0) > 0
|
|
557
|
+
):
|
|
558
|
+
slot = slots[index]
|
|
559
|
+
slot["thinking"] += "\n\n"
|
|
560
|
+
yield ModelEvent.thinking("\n\n", slot["index"])
|
|
561
|
+
elif kind == "response.output_item.done":
|
|
562
|
+
if index not in slots:
|
|
563
|
+
slots[index] = {
|
|
564
|
+
"index": len(slots),
|
|
565
|
+
"text": "",
|
|
566
|
+
"thinking": "",
|
|
567
|
+
"arguments": "",
|
|
568
|
+
}
|
|
569
|
+
slots[index]["item"] = event["item"]
|
|
570
|
+
yield ModelEvent.boundary(
|
|
571
|
+
"end", slots[index]["index"], response_block(event["item"])
|
|
572
|
+
)
|
|
573
|
+
elif kind in {"response.completed", "response.incomplete", "response.done"}:
|
|
574
|
+
final_response = event["response"]
|
|
575
|
+
finally:
|
|
576
|
+
await events.aclose()
|
|
577
|
+
if final_response is None:
|
|
578
|
+
raise ProviderProtocolError("OpenAI stream ended without completion")
|
|
579
|
+
output = final_response.get("output") or [s["item"] for s in slots.values()]
|
|
580
|
+
blocks: list[TextContent | ThinkingContent | ToolCall] = []
|
|
581
|
+
for item in output:
|
|
582
|
+
block = response_block(item)
|
|
583
|
+
if isinstance(block, ThinkingContent) and item.get("encrypted_content"):
|
|
584
|
+
old = next(
|
|
585
|
+
(
|
|
586
|
+
slot["item"]
|
|
587
|
+
for slot in slots.values()
|
|
588
|
+
if slot["item"].get("id") == item.get("id")
|
|
589
|
+
),
|
|
590
|
+
None,
|
|
591
|
+
)
|
|
592
|
+
if old is not None and not old.get("encrypted_content"):
|
|
593
|
+
block.thinking_signature = json.dumps(
|
|
594
|
+
{**old, "encrypted_content": item["encrypted_content"]},
|
|
595
|
+
separators=(",", ":"),
|
|
596
|
+
ensure_ascii=False,
|
|
597
|
+
)
|
|
598
|
+
blocks.append(block)
|
|
599
|
+
status = final_response.get("status", "completed")
|
|
600
|
+
if status not in {"completed", "incomplete"}:
|
|
601
|
+
raise ProviderProtocolError(f"OpenAI response status: {status}")
|
|
602
|
+
if (
|
|
603
|
+
status == "incomplete"
|
|
604
|
+
and final_response.get("incomplete_details", {}).get("reason") != "max_output_tokens"
|
|
605
|
+
):
|
|
606
|
+
raise ProviderProtocolError("OpenAI response incomplete")
|
|
607
|
+
reason = (
|
|
608
|
+
"length"
|
|
609
|
+
if status == "incomplete"
|
|
610
|
+
else ("tool_use" if any(isinstance(b, ToolCall) for b in blocks) else "stop")
|
|
611
|
+
)
|
|
612
|
+
message = AssistantMessage(
|
|
613
|
+
blocks,
|
|
614
|
+
reason,
|
|
615
|
+
self.name,
|
|
616
|
+
request.model,
|
|
617
|
+
normalize_usage(final_response.get("usage", {}), self.name),
|
|
618
|
+
api=self.api,
|
|
619
|
+
thinking_level=request.options.get("reasoning"),
|
|
620
|
+
response_id=final_response.get("id"),
|
|
621
|
+
response_model=final_response.get("model")
|
|
622
|
+
if final_response.get("model") != request.model
|
|
623
|
+
else None,
|
|
624
|
+
raw_stop_reason=status + ".max_output_tokens" if status == "incomplete" else status,
|
|
625
|
+
)
|
|
626
|
+
if link is not None and link.entry is not None and final_response.get("id"):
|
|
627
|
+
model = self.model_info(request)
|
|
628
|
+
items, _ = self._input(
|
|
629
|
+
request,
|
|
630
|
+
model,
|
|
631
|
+
[message],
|
|
632
|
+
model.compat,
|
|
633
|
+
include_system=False,
|
|
634
|
+
strict=None,
|
|
635
|
+
)
|
|
636
|
+
link.entry.continuation = {
|
|
637
|
+
"body": body,
|
|
638
|
+
"response_id": final_response["id"],
|
|
639
|
+
"items": [i for i in items if i.get("type") != "function_call_output"],
|
|
640
|
+
}
|
|
641
|
+
yield ModelEvent.done(message)
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
class OpenAICodexProvider(OpenAIProvider):
|
|
645
|
+
name = "openai-codex"
|
|
646
|
+
api = "openai-codex-responses"
|
|
647
|
+
|
|
648
|
+
def __init__(
|
|
649
|
+
self, *, base_url: str = "https://chatgpt.com/backend-api/codex", **kwargs: Any
|
|
650
|
+
) -> None:
|
|
651
|
+
super().__init__(base_url=base_url, **kwargs)
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
class DeepSeekProvider(OpenAIProvider):
|
|
655
|
+
"""DeepSeek's documented stateless Responses API compatibility profile."""
|
|
656
|
+
|
|
657
|
+
name = "deepseek"
|
|
658
|
+
|
|
659
|
+
def __init__(self, *, base_url: str = "https://api.deepseek.com", **kwargs: Any) -> None:
|
|
660
|
+
super().__init__(base_url=base_url, **kwargs)
|
|
661
|
+
|
|
662
|
+
def build_request(self, request: ModelRequest, chatgpt_sign_in: bool = False) -> dict[str, Any]:
|
|
663
|
+
body = super().build_request(request)
|
|
664
|
+
system = current_system_prompt(request.messages)
|
|
665
|
+
# The replayed prompt travels in instructions; the instruction role is not used.
|
|
666
|
+
body["input"] = [
|
|
667
|
+
item for item in body["input"] if item.get("role") not in {"developer", "system"}
|
|
668
|
+
]
|
|
669
|
+
if system:
|
|
670
|
+
body["instructions"] = system
|
|
671
|
+
level = request.options.get("reasoning", "off")
|
|
672
|
+
body["reasoning"] = {
|
|
673
|
+
"effort": {"off": "none", "minimal": "low", "medium": "high", "xhigh": "high"}.get(
|
|
674
|
+
level, level
|
|
675
|
+
)
|
|
676
|
+
}
|
|
677
|
+
for key in ("include", "metadata", "service_tier", "prompt_cache_key"):
|
|
678
|
+
body.pop(key, None)
|
|
679
|
+
if "max_tokens" not in request.options:
|
|
680
|
+
body.pop("max_output_tokens", None)
|
|
681
|
+
return body
|