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,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