codex-as-api 0.2.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.
- codex_as_api/__init__.py +3 -0
- codex_as_api/anthropic_adapter.py +447 -0
- codex_as_api/auth.py +239 -0
- codex_as_api/messages.py +116 -0
- codex_as_api/protocol.py +98 -0
- codex_as_api/provider.py +701 -0
- codex_as_api/server.py +580 -0
- codex_as_api-0.2.1.dist-info/METADATA +543 -0
- codex_as_api-0.2.1.dist-info/RECORD +11 -0
- codex_as_api-0.2.1.dist-info/WHEEL +4 -0
- codex_as_api-0.2.1.dist-info/entry_points.txt +2 -0
codex_as_api/provider.py
ADDED
|
@@ -0,0 +1,701 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import threading
|
|
5
|
+
import uuid
|
|
6
|
+
import urllib.error
|
|
7
|
+
import urllib.request
|
|
8
|
+
from typing import Any, Iterator, Sequence
|
|
9
|
+
|
|
10
|
+
from .messages import AssistantResponse, Message, MessageRole, ToolCall, ToolSchema, Usage
|
|
11
|
+
from .auth import (
|
|
12
|
+
ChatGPTOAuthError,
|
|
13
|
+
load_token_data,
|
|
14
|
+
redact_text,
|
|
15
|
+
refresh_token,
|
|
16
|
+
register_token_secrets,
|
|
17
|
+
)
|
|
18
|
+
from .protocol import (
|
|
19
|
+
reasoning_from_response_items,
|
|
20
|
+
response_failure_message,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
CHATGPT_OAUTH_DEFAULT_BASE_URL = "https://chatgpt.com/backend-api/codex"
|
|
24
|
+
CHATGPT_OAUTH_DEFAULT_MODEL = "gpt-5.5"
|
|
25
|
+
REMOTE_COMPACTION_MARKER = "[Remote Responses compacted history]"
|
|
26
|
+
REASONING_EFFORT_VALUES = frozenset({"none", "minimal", "low", "medium", "high", "xhigh"})
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ChatGPTOAuthProvider:
|
|
30
|
+
name: str = "chatgpt_oauth"
|
|
31
|
+
provider_namespace: str = "agent.provider.chatgpt_oauth"
|
|
32
|
+
supports_prompt_cache_key: bool = True
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
*,
|
|
37
|
+
model: str = CHATGPT_OAUTH_DEFAULT_MODEL,
|
|
38
|
+
base_url: str = CHATGPT_OAUTH_DEFAULT_BASE_URL,
|
|
39
|
+
auth_json_path: str | None = None,
|
|
40
|
+
timeout: float | None = None,
|
|
41
|
+
) -> None:
|
|
42
|
+
self.model = model
|
|
43
|
+
self.base_url = base_url.rstrip("/")
|
|
44
|
+
self.auth_json_path = auth_json_path
|
|
45
|
+
# ``None`` is intentional: Codex Responses can run for minutes while it
|
|
46
|
+
# streams thinking/tool progress. A client-side read timeout aborts a
|
|
47
|
+
# still-healthy turn and leaves workflow state half-transitioned.
|
|
48
|
+
self.timeout = timeout
|
|
49
|
+
self.api_key = None
|
|
50
|
+
self._active_response_lock = threading.Lock()
|
|
51
|
+
self._active_responses: set[Any] = set()
|
|
52
|
+
|
|
53
|
+
def cancel_current_requests(self) -> None:
|
|
54
|
+
with self._active_response_lock:
|
|
55
|
+
responses = list(self._active_responses)
|
|
56
|
+
for response in responses:
|
|
57
|
+
try:
|
|
58
|
+
response.close()
|
|
59
|
+
except Exception:
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
def chat(
|
|
63
|
+
self,
|
|
64
|
+
messages: Sequence[Message],
|
|
65
|
+
*,
|
|
66
|
+
model: str | None = None,
|
|
67
|
+
tools: Sequence[ToolSchema] | None = None,
|
|
68
|
+
tool_choice: str | dict | None = None,
|
|
69
|
+
temperature: float | None = None,
|
|
70
|
+
reasoning_effort: str | None = None,
|
|
71
|
+
max_tokens: int | None = None,
|
|
72
|
+
stop: Sequence[str] | None = None,
|
|
73
|
+
prompt_cache_key: str | None = None,
|
|
74
|
+
subagent: str | None = None,
|
|
75
|
+
memgen_request: bool | None = None,
|
|
76
|
+
previous_response_id: str | None = None,
|
|
77
|
+
service_tier: str | None = None,
|
|
78
|
+
text: dict | None = None,
|
|
79
|
+
client_metadata: dict[str, str] | None = None,
|
|
80
|
+
) -> AssistantResponse:
|
|
81
|
+
content_parts: list[str] = []
|
|
82
|
+
reasoning_parts: list[str] = []
|
|
83
|
+
tool_calls: list[ToolCall] = []
|
|
84
|
+
finish_reason = "stop"
|
|
85
|
+
raw_events: list[dict[str, Any]] = []
|
|
86
|
+
usage: Usage | None = None
|
|
87
|
+
for event in self.chat_stream(
|
|
88
|
+
messages,
|
|
89
|
+
model=model,
|
|
90
|
+
tools=tools,
|
|
91
|
+
tool_choice=tool_choice,
|
|
92
|
+
temperature=temperature,
|
|
93
|
+
reasoning_effort=reasoning_effort,
|
|
94
|
+
max_tokens=max_tokens,
|
|
95
|
+
stop=stop,
|
|
96
|
+
prompt_cache_key=prompt_cache_key,
|
|
97
|
+
subagent=subagent,
|
|
98
|
+
memgen_request=memgen_request,
|
|
99
|
+
previous_response_id=previous_response_id,
|
|
100
|
+
service_tier=service_tier,
|
|
101
|
+
text=text,
|
|
102
|
+
client_metadata=client_metadata,
|
|
103
|
+
):
|
|
104
|
+
raw_events.append(dict(event))
|
|
105
|
+
if event.get("type") == "content":
|
|
106
|
+
content_parts.append(str(event.get("text", "")))
|
|
107
|
+
elif event.get("type") in {"reasoning_delta", "reasoning_raw_delta"}:
|
|
108
|
+
reasoning_parts.append(str(event.get("text", "")))
|
|
109
|
+
elif event.get("type") == "tool_call":
|
|
110
|
+
tool_calls.append(ToolCall(id=str(event["id"]), name=str(event["name"]), arguments=dict(event.get("arguments") or {})))
|
|
111
|
+
elif event.get("type") == "finish":
|
|
112
|
+
finish_reason = str(event.get("finish_reason") or finish_reason)
|
|
113
|
+
if isinstance(event.get("reasoning_content"), str):
|
|
114
|
+
reasoning_parts = [str(event["reasoning_content"])]
|
|
115
|
+
usage = _usage_from_response(event.get("usage")) or usage
|
|
116
|
+
return AssistantResponse(
|
|
117
|
+
content="".join(content_parts),
|
|
118
|
+
tool_calls=tuple(tool_calls),
|
|
119
|
+
finish_reason=finish_reason,
|
|
120
|
+
usage=usage,
|
|
121
|
+
reasoning_content="".join(reasoning_parts) or None,
|
|
122
|
+
raw={"events": raw_events[-20:]},
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
def chat_stream(
|
|
126
|
+
self,
|
|
127
|
+
messages: Sequence[Message],
|
|
128
|
+
*,
|
|
129
|
+
model: str | None = None,
|
|
130
|
+
tools: Sequence[ToolSchema] | None = None,
|
|
131
|
+
tool_choice: str | dict | None = None,
|
|
132
|
+
temperature: float | None = None,
|
|
133
|
+
reasoning_effort: str | None = None,
|
|
134
|
+
max_tokens: int | None = None,
|
|
135
|
+
stop: Sequence[str] | None = None,
|
|
136
|
+
prompt_cache_key: str | None = None,
|
|
137
|
+
subagent: str | None = None,
|
|
138
|
+
memgen_request: bool | None = None,
|
|
139
|
+
previous_response_id: str | None = None,
|
|
140
|
+
service_tier: str | None = None,
|
|
141
|
+
text: dict | None = None,
|
|
142
|
+
client_metadata: dict[str, str] | None = None,
|
|
143
|
+
) -> Iterator[dict[str, Any]]:
|
|
144
|
+
del max_tokens # ChatGPT Codex backend rejects max_output_tokens for this endpoint.
|
|
145
|
+
payload = self._responses_payload(
|
|
146
|
+
messages,
|
|
147
|
+
model=model,
|
|
148
|
+
tools=tools,
|
|
149
|
+
tool_choice=tool_choice,
|
|
150
|
+
temperature=temperature,
|
|
151
|
+
reasoning_effort=reasoning_effort,
|
|
152
|
+
stop=stop,
|
|
153
|
+
prompt_cache_key=prompt_cache_key,
|
|
154
|
+
previous_response_id=previous_response_id,
|
|
155
|
+
service_tier=service_tier,
|
|
156
|
+
text=text,
|
|
157
|
+
client_metadata=client_metadata,
|
|
158
|
+
)
|
|
159
|
+
extra_headers: dict[str, str] = {}
|
|
160
|
+
if subagent is not None:
|
|
161
|
+
extra_headers["x-openai-subagent"] = subagent
|
|
162
|
+
if memgen_request is not None:
|
|
163
|
+
extra_headers["x-openai-memgen-request"] = "true" if memgen_request else "false"
|
|
164
|
+
stream = self._post_sse("/responses", payload, extra_headers=extra_headers)
|
|
165
|
+
final_output: list[dict[str, Any]] = []
|
|
166
|
+
reasoning_parts: list[str] = []
|
|
167
|
+
saw_text_delta = False
|
|
168
|
+
saw_reasoning_delta = False
|
|
169
|
+
for event in stream:
|
|
170
|
+
typ = event.get("type")
|
|
171
|
+
if typ == "response.output_text.delta":
|
|
172
|
+
delta = event.get("delta")
|
|
173
|
+
if isinstance(delta, str) and delta:
|
|
174
|
+
saw_text_delta = True
|
|
175
|
+
yield {"type": "content", "text": delta}
|
|
176
|
+
elif typ == "response.output_item.done":
|
|
177
|
+
item = event.get("item")
|
|
178
|
+
if isinstance(item, dict):
|
|
179
|
+
final_output.append(item)
|
|
180
|
+
tool = _tool_call_from_response_item(item)
|
|
181
|
+
if tool is not None:
|
|
182
|
+
yield {"type": "tool_call", "id": tool.id, "name": tool.name, "arguments": tool.arguments}
|
|
183
|
+
elif typ == "response.reasoning_summary_part.added":
|
|
184
|
+
yield {
|
|
185
|
+
"type": "reasoning_section_break",
|
|
186
|
+
"summary_index": event.get("summary_index"),
|
|
187
|
+
"part_index": event.get("part_index"),
|
|
188
|
+
}
|
|
189
|
+
elif typ == "response.reasoning_summary_text.delta":
|
|
190
|
+
delta = event.get("delta")
|
|
191
|
+
if isinstance(delta, str) and delta:
|
|
192
|
+
saw_reasoning_delta = True
|
|
193
|
+
reasoning_parts.append(delta)
|
|
194
|
+
yield {
|
|
195
|
+
"type": "reasoning_delta",
|
|
196
|
+
"text": delta,
|
|
197
|
+
"summary_index": event.get("summary_index"),
|
|
198
|
+
}
|
|
199
|
+
elif typ == "response.reasoning_text.delta":
|
|
200
|
+
delta = event.get("delta")
|
|
201
|
+
if isinstance(delta, str) and delta:
|
|
202
|
+
saw_reasoning_delta = True
|
|
203
|
+
reasoning_parts.append(delta)
|
|
204
|
+
yield {
|
|
205
|
+
"type": "reasoning_raw_delta",
|
|
206
|
+
"text": delta,
|
|
207
|
+
"summary_index": event.get("summary_index"),
|
|
208
|
+
}
|
|
209
|
+
elif typ == "response.failed":
|
|
210
|
+
raise ChatGPTOAuthError(response_failure_message(event, "failed"))
|
|
211
|
+
elif typ == "response.incomplete":
|
|
212
|
+
raise ChatGPTOAuthError(response_failure_message(event, "incomplete"))
|
|
213
|
+
elif typ == "response.completed":
|
|
214
|
+
response = event.get("response")
|
|
215
|
+
if isinstance(response, dict):
|
|
216
|
+
usage = response.get("usage")
|
|
217
|
+
if not final_output and isinstance(response.get("output"), list):
|
|
218
|
+
final_output.extend(item for item in response["output"] if isinstance(item, dict))
|
|
219
|
+
if not saw_text_delta:
|
|
220
|
+
final_text = _text_from_response_items(final_output)
|
|
221
|
+
if final_text:
|
|
222
|
+
saw_text_delta = True
|
|
223
|
+
yield {"type": "content", "text": final_text}
|
|
224
|
+
if not saw_reasoning_delta:
|
|
225
|
+
completed_reasoning = reasoning_from_response_items(final_output)
|
|
226
|
+
if completed_reasoning:
|
|
227
|
+
saw_reasoning_delta = True
|
|
228
|
+
reasoning_parts.append(completed_reasoning)
|
|
229
|
+
yield {"type": "reasoning_delta", "text": completed_reasoning}
|
|
230
|
+
yield {
|
|
231
|
+
"type": "finish",
|
|
232
|
+
"finish_reason": "stop",
|
|
233
|
+
"usage": usage if isinstance(response, dict) else None,
|
|
234
|
+
"reasoning_content": "".join(reasoning_parts) or None,
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
def generate_image(
|
|
238
|
+
self,
|
|
239
|
+
prompt: str,
|
|
240
|
+
*,
|
|
241
|
+
model: str | None = None,
|
|
242
|
+
reference_images: Sequence[dict[str, str]] = (),
|
|
243
|
+
size: str | None = None,
|
|
244
|
+
reasoning_effort: str | None = None,
|
|
245
|
+
) -> list[dict[str, Any]]:
|
|
246
|
+
if not isinstance(prompt, str) or prompt.strip() == "":
|
|
247
|
+
raise ChatGPTOAuthError("image generation prompt is required")
|
|
248
|
+
content: list[dict[str, Any]] = [{"type": "input_text", "text": prompt}]
|
|
249
|
+
content.extend(_validate_image_content_items(reference_images))
|
|
250
|
+
if size and size != "auto":
|
|
251
|
+
content[0]["text"] = f"{prompt}\n\nRequested output size/aspect: {size}"
|
|
252
|
+
payload = {
|
|
253
|
+
"model": model or self.model,
|
|
254
|
+
"instructions": (
|
|
255
|
+
"Use the image_generation tool to create the requested image. "
|
|
256
|
+
"Return the generated image through an image_generation_call result."
|
|
257
|
+
),
|
|
258
|
+
"input": [{"type": "message", "role": "user", "content": content}],
|
|
259
|
+
"tools": [{"type": "image_generation", "output_format": "png"}],
|
|
260
|
+
"tool_choice": "auto",
|
|
261
|
+
"parallel_tool_calls": False,
|
|
262
|
+
"stream": True,
|
|
263
|
+
"store": False,
|
|
264
|
+
"include": [],
|
|
265
|
+
"prompt_cache_key": str(uuid.uuid4()),
|
|
266
|
+
}
|
|
267
|
+
_set_reasoning_payload(payload, reasoning_effort)
|
|
268
|
+
output_items = self._collect_response_output_items(payload)
|
|
269
|
+
images = [_image_generation_from_item(item) for item in output_items]
|
|
270
|
+
generated = [image for image in images if image is not None]
|
|
271
|
+
if not generated:
|
|
272
|
+
raise ChatGPTOAuthError("image generation response returned no image_generation_call")
|
|
273
|
+
return generated
|
|
274
|
+
|
|
275
|
+
def inspect_images(
|
|
276
|
+
self,
|
|
277
|
+
prompt: str,
|
|
278
|
+
*,
|
|
279
|
+
model: str | None = None,
|
|
280
|
+
images: Sequence[dict[str, str]],
|
|
281
|
+
reasoning_effort: str | None = None,
|
|
282
|
+
) -> str:
|
|
283
|
+
if not isinstance(prompt, str) or prompt.strip() == "":
|
|
284
|
+
raise ChatGPTOAuthError("image inspection prompt is required")
|
|
285
|
+
content: list[dict[str, Any]] = [{"type": "input_text", "text": prompt}]
|
|
286
|
+
content.extend(_validate_image_content_items(images))
|
|
287
|
+
payload = {
|
|
288
|
+
"model": model or self.model,
|
|
289
|
+
"instructions": "Inspect the attached image(s) and answer the user's review prompt directly.",
|
|
290
|
+
"input": [{"type": "message", "role": "user", "content": content}],
|
|
291
|
+
"tools": [],
|
|
292
|
+
"parallel_tool_calls": False,
|
|
293
|
+
"stream": True,
|
|
294
|
+
"store": False,
|
|
295
|
+
"include": [],
|
|
296
|
+
"prompt_cache_key": str(uuid.uuid4()),
|
|
297
|
+
}
|
|
298
|
+
_set_reasoning_payload(payload, reasoning_effort)
|
|
299
|
+
output_items = self._collect_response_output_items(payload)
|
|
300
|
+
text = _text_from_response_items(output_items).strip()
|
|
301
|
+
if text == "":
|
|
302
|
+
raise ChatGPTOAuthError("image inspection response returned empty content")
|
|
303
|
+
return text
|
|
304
|
+
|
|
305
|
+
def _collect_response_output_items(self, payload: dict[str, Any]) -> list[dict[str, Any]]:
|
|
306
|
+
output_items: list[dict[str, Any]] = []
|
|
307
|
+
completed_output_seen = False
|
|
308
|
+
seen_keys: set[str] = set()
|
|
309
|
+
|
|
310
|
+
def append_item(item: dict[str, Any]) -> None:
|
|
311
|
+
key_parts = [str(item.get("type") or "")]
|
|
312
|
+
for field in ("id", "call_id"):
|
|
313
|
+
if isinstance(item.get(field), str) and item[field]:
|
|
314
|
+
key_parts.append(str(item[field]))
|
|
315
|
+
break
|
|
316
|
+
else:
|
|
317
|
+
key_parts.append(json.dumps(item, sort_keys=True, ensure_ascii=False, default=str))
|
|
318
|
+
key = "\x1f".join(key_parts)
|
|
319
|
+
if key in seen_keys:
|
|
320
|
+
return
|
|
321
|
+
seen_keys.add(key)
|
|
322
|
+
output_items.append(item)
|
|
323
|
+
|
|
324
|
+
for event in self._post_sse("/responses", payload):
|
|
325
|
+
typ = event.get("type")
|
|
326
|
+
if typ == "response.output_item.done":
|
|
327
|
+
item = event.get("item")
|
|
328
|
+
if isinstance(item, dict):
|
|
329
|
+
append_item(item)
|
|
330
|
+
elif typ == "response.failed":
|
|
331
|
+
raise ChatGPTOAuthError(response_failure_message(event, "failed"))
|
|
332
|
+
elif typ == "response.incomplete":
|
|
333
|
+
raise ChatGPTOAuthError(response_failure_message(event, "incomplete"))
|
|
334
|
+
elif typ == "response.completed":
|
|
335
|
+
response = event.get("response")
|
|
336
|
+
if isinstance(response, dict) and isinstance(response.get("output"), list):
|
|
337
|
+
completed_output_seen = True
|
|
338
|
+
for item in response["output"]:
|
|
339
|
+
if isinstance(item, dict):
|
|
340
|
+
append_item(item)
|
|
341
|
+
if not output_items and not completed_output_seen:
|
|
342
|
+
raise ChatGPTOAuthError("ChatGPT OAuth response returned no output items")
|
|
343
|
+
return output_items
|
|
344
|
+
|
|
345
|
+
def compact_messages(
|
|
346
|
+
self,
|
|
347
|
+
messages: Sequence[Message],
|
|
348
|
+
*,
|
|
349
|
+
model: str | None = None,
|
|
350
|
+
reasoning_effort: str | None = None,
|
|
351
|
+
) -> str:
|
|
352
|
+
payload = {
|
|
353
|
+
"model": model or self.model,
|
|
354
|
+
"input": _messages_to_response_items(messages),
|
|
355
|
+
"instructions": "Create a compact checkpoint of this conversation for continuation.",
|
|
356
|
+
"tools": [],
|
|
357
|
+
"parallel_tool_calls": False,
|
|
358
|
+
}
|
|
359
|
+
_set_reasoning_payload(payload, reasoning_effort)
|
|
360
|
+
data = self._post_json("/responses/compact", payload)
|
|
361
|
+
output = data.get("output")
|
|
362
|
+
if not isinstance(output, list):
|
|
363
|
+
raise ChatGPTOAuthError("remote compact response missing output array")
|
|
364
|
+
# Preserve the raw response items for the ChatGPT OAuth provider. The marker is deliberately
|
|
365
|
+
# not a fallback summary; it is expanded back into Response items on subsequent requests.
|
|
366
|
+
return REMOTE_COMPACTION_MARKER + "\n" + json.dumps(output, ensure_ascii=False, separators=(",", ":"))
|
|
367
|
+
|
|
368
|
+
def _responses_payload(
|
|
369
|
+
self,
|
|
370
|
+
messages: Sequence[Message],
|
|
371
|
+
*,
|
|
372
|
+
model: str | None = None,
|
|
373
|
+
tools: Sequence[ToolSchema] | None,
|
|
374
|
+
tool_choice: str | dict | None = None,
|
|
375
|
+
temperature: float | None,
|
|
376
|
+
reasoning_effort: str | None,
|
|
377
|
+
stop: Sequence[str] | None,
|
|
378
|
+
prompt_cache_key: str | None,
|
|
379
|
+
previous_response_id: str | None = None,
|
|
380
|
+
service_tier: str | None = None,
|
|
381
|
+
text: dict | None = None,
|
|
382
|
+
client_metadata: dict[str, str] | None = None,
|
|
383
|
+
) -> dict[str, Any]:
|
|
384
|
+
del temperature # ChatGPT Codex backend rejects explicit temperature for this endpoint.
|
|
385
|
+
instructions, input_items = _split_instructions_and_input(messages)
|
|
386
|
+
if instructions == "":
|
|
387
|
+
raise ChatGPTOAuthError("ChatGPT OAuth Responses request requires system instructions")
|
|
388
|
+
payload: dict[str, Any] = {
|
|
389
|
+
"model": model or self.model,
|
|
390
|
+
"instructions": instructions,
|
|
391
|
+
"input": input_items,
|
|
392
|
+
"tools": [] if tools is None else [_tool_schema_to_response_dict(tool) for tool in tools],
|
|
393
|
+
"tool_choice": tool_choice or "auto",
|
|
394
|
+
"parallel_tool_calls": False,
|
|
395
|
+
"stream": True,
|
|
396
|
+
"store": False,
|
|
397
|
+
"include": [],
|
|
398
|
+
}
|
|
399
|
+
if prompt_cache_key:
|
|
400
|
+
payload["prompt_cache_key"] = prompt_cache_key
|
|
401
|
+
if stop is not None:
|
|
402
|
+
payload["stop"] = list(stop)
|
|
403
|
+
if previous_response_id is not None:
|
|
404
|
+
payload["previous_response_id"] = previous_response_id
|
|
405
|
+
if service_tier is not None:
|
|
406
|
+
payload["service_tier"] = service_tier
|
|
407
|
+
if text is not None:
|
|
408
|
+
payload["text"] = text
|
|
409
|
+
if client_metadata is not None:
|
|
410
|
+
payload["client_metadata"] = client_metadata
|
|
411
|
+
_set_reasoning_payload(payload, reasoning_effort)
|
|
412
|
+
return payload
|
|
413
|
+
|
|
414
|
+
def _headers(self) -> dict[str, str]:
|
|
415
|
+
token = load_token_data(self.auth_json_path)
|
|
416
|
+
register_token_secrets(token.access_token, token.refresh_token, token.id_token, token.account_id)
|
|
417
|
+
headers = {
|
|
418
|
+
"Authorization": f"Bearer {token.access_token}",
|
|
419
|
+
"ChatGPT-Account-Id": token.account_id,
|
|
420
|
+
"Content-Type": "application/json",
|
|
421
|
+
}
|
|
422
|
+
if token.fedramp:
|
|
423
|
+
headers["X-OpenAI-Fedramp"] = "true"
|
|
424
|
+
return headers
|
|
425
|
+
|
|
426
|
+
def _post_json(self, path: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
427
|
+
raw = self._request_json(path, payload)
|
|
428
|
+
data = json.loads(raw.decode("utf-8"))
|
|
429
|
+
if not isinstance(data, dict):
|
|
430
|
+
raise ChatGPTOAuthError("ChatGPT OAuth response must be a JSON object")
|
|
431
|
+
return data
|
|
432
|
+
|
|
433
|
+
def _post_sse(self, path: str, payload: dict[str, Any], extra_headers: dict[str, str] | None = None) -> Iterator[dict[str, Any]]:
|
|
434
|
+
yield from self._request_sse(path, payload, extra_headers=extra_headers)
|
|
435
|
+
|
|
436
|
+
def _request_sse(self, path: str, payload: dict[str, Any], extra_headers: dict[str, str] | None = None) -> Iterator[dict[str, Any]]:
|
|
437
|
+
token_values: tuple[str | None, ...] = (None,)
|
|
438
|
+
for attempt in range(2):
|
|
439
|
+
headers = self._headers()
|
|
440
|
+
headers["Accept"] = "text/event-stream"
|
|
441
|
+
if extra_headers:
|
|
442
|
+
headers.update(extra_headers)
|
|
443
|
+
token = load_token_data(self.auth_json_path)
|
|
444
|
+
token_values = (token.access_token, token.refresh_token, token.id_token, token.account_id)
|
|
445
|
+
req = urllib.request.Request(
|
|
446
|
+
self.base_url + path,
|
|
447
|
+
data=json.dumps(payload).encode("utf-8"),
|
|
448
|
+
headers=headers,
|
|
449
|
+
method="POST",
|
|
450
|
+
)
|
|
451
|
+
try:
|
|
452
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as response:
|
|
453
|
+
with self._active_response_lock:
|
|
454
|
+
self._active_responses.add(response)
|
|
455
|
+
block: list[str] = []
|
|
456
|
+
try:
|
|
457
|
+
while True:
|
|
458
|
+
raw_line = response.readline()
|
|
459
|
+
if raw_line == b"":
|
|
460
|
+
if block:
|
|
461
|
+
event = _decode_sse_block(block)
|
|
462
|
+
if event is not None:
|
|
463
|
+
yield event
|
|
464
|
+
return
|
|
465
|
+
line = raw_line.decode("utf-8", "replace").rstrip("\r\n")
|
|
466
|
+
if line == "":
|
|
467
|
+
event = _decode_sse_block(block)
|
|
468
|
+
block = []
|
|
469
|
+
if event is not None:
|
|
470
|
+
yield event
|
|
471
|
+
continue
|
|
472
|
+
block.append(line)
|
|
473
|
+
finally:
|
|
474
|
+
with self._active_response_lock:
|
|
475
|
+
self._active_responses.discard(response)
|
|
476
|
+
except urllib.error.HTTPError as exc:
|
|
477
|
+
body = exc.read().decode("utf-8", "replace")
|
|
478
|
+
redacted = redact_text(body, *token_values)
|
|
479
|
+
if exc.code == 401 and attempt == 0:
|
|
480
|
+
refresh_token(self.auth_json_path)
|
|
481
|
+
continue
|
|
482
|
+
raise ChatGPTOAuthError(f"ChatGPT OAuth request failed: HTTP {exc.code}: {redacted}") from exc
|
|
483
|
+
except Exception as exc: # noqa: BLE001
|
|
484
|
+
raise ChatGPTOAuthError(f"ChatGPT OAuth request failed: {redact_text(str(exc), *token_values)}") from exc
|
|
485
|
+
return
|
|
486
|
+
|
|
487
|
+
def _request_json(self, path: str, payload: dict[str, Any]) -> bytes:
|
|
488
|
+
token_values: tuple[str | None, ...] = (None,)
|
|
489
|
+
for attempt in range(2):
|
|
490
|
+
headers = self._headers()
|
|
491
|
+
token = load_token_data(self.auth_json_path)
|
|
492
|
+
token_values = (token.access_token, token.refresh_token, token.id_token, token.account_id)
|
|
493
|
+
req = urllib.request.Request(
|
|
494
|
+
self.base_url + path,
|
|
495
|
+
data=json.dumps(payload).encode("utf-8"),
|
|
496
|
+
headers=headers,
|
|
497
|
+
method="POST",
|
|
498
|
+
)
|
|
499
|
+
try:
|
|
500
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as response:
|
|
501
|
+
return response.read()
|
|
502
|
+
except urllib.error.HTTPError as exc:
|
|
503
|
+
body = exc.read().decode("utf-8", "replace")
|
|
504
|
+
redacted = redact_text(body, *token_values)
|
|
505
|
+
if exc.code == 401 and attempt == 0:
|
|
506
|
+
refresh_token(self.auth_json_path)
|
|
507
|
+
continue
|
|
508
|
+
raise ChatGPTOAuthError(f"ChatGPT OAuth request failed: HTTP {exc.code}: {redacted}") from exc
|
|
509
|
+
except Exception as exc: # noqa: BLE001
|
|
510
|
+
raise ChatGPTOAuthError(f"ChatGPT OAuth request failed: {redact_text(str(exc), *token_values)}") from exc
|
|
511
|
+
raise AssertionError("unreachable ChatGPT OAuth request retry state")
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def _validate_image_content_items(images: Sequence[dict[str, str]]) -> list[dict[str, str]]:
|
|
515
|
+
items: list[dict[str, str]] = []
|
|
516
|
+
for index, image in enumerate(images):
|
|
517
|
+
if not isinstance(image, dict):
|
|
518
|
+
raise ChatGPTOAuthError(f"image reference {index} must be an object")
|
|
519
|
+
image_url = image.get("image_url")
|
|
520
|
+
if not isinstance(image_url, str) or image_url.strip() == "":
|
|
521
|
+
raise ChatGPTOAuthError(f"image reference {index} requires image_url")
|
|
522
|
+
if not image_url.startswith("data:image/"):
|
|
523
|
+
raise ChatGPTOAuthError(f"image reference {index} must be a data:image URL")
|
|
524
|
+
items.append({"type": "input_image", "image_url": image_url})
|
|
525
|
+
return items
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _image_generation_from_item(item: dict[str, Any]) -> dict[str, Any] | None:
|
|
529
|
+
if item.get("type") != "image_generation_call":
|
|
530
|
+
return None
|
|
531
|
+
result = item.get("result")
|
|
532
|
+
if not isinstance(result, str) or result.strip() == "":
|
|
533
|
+
raise ChatGPTOAuthError("image_generation_call returned empty result")
|
|
534
|
+
return {
|
|
535
|
+
"id": str(item.get("id") or uuid.uuid4().hex),
|
|
536
|
+
"status": str(item.get("status") or "completed"),
|
|
537
|
+
"revised_prompt": item.get("revised_prompt") if isinstance(item.get("revised_prompt"), str) else None,
|
|
538
|
+
"result": result,
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _decode_sse_block(lines: list[str]) -> dict[str, Any] | None:
|
|
543
|
+
data_lines = [line[5:].strip() for line in lines if line.startswith("data:")]
|
|
544
|
+
if not data_lines:
|
|
545
|
+
return None
|
|
546
|
+
joined = "\n".join(data_lines)
|
|
547
|
+
if joined == "[DONE]":
|
|
548
|
+
return None
|
|
549
|
+
try:
|
|
550
|
+
event = json.loads(joined)
|
|
551
|
+
except json.JSONDecodeError as exc:
|
|
552
|
+
raise ChatGPTOAuthError(f"invalid SSE event JSON: {joined[:80]}") from exc
|
|
553
|
+
return event if isinstance(event, dict) else None
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def _split_instructions_and_input(messages: Sequence[Message]) -> tuple[str, list[dict[str, Any]]]:
|
|
557
|
+
instructions: list[str] = []
|
|
558
|
+
input_messages: list[Message] = []
|
|
559
|
+
for msg in messages:
|
|
560
|
+
if msg.role is MessageRole.SYSTEM and not msg.content.startswith(REMOTE_COMPACTION_MARKER):
|
|
561
|
+
instructions.append(msg.content)
|
|
562
|
+
else:
|
|
563
|
+
input_messages.append(msg)
|
|
564
|
+
return "\n\n".join(instructions), _messages_to_response_items(input_messages)
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def _messages_to_response_items(messages: Sequence[Message]) -> list[dict[str, Any]]:
|
|
568
|
+
items: list[dict[str, Any]] = []
|
|
569
|
+
for message in messages:
|
|
570
|
+
if message.role is MessageRole.SYSTEM and message.content.startswith(REMOTE_COMPACTION_MARKER):
|
|
571
|
+
raw = message.content[len(REMOTE_COMPACTION_MARKER):].strip()
|
|
572
|
+
parsed = json.loads(raw)
|
|
573
|
+
if not isinstance(parsed, list):
|
|
574
|
+
raise ChatGPTOAuthError("remote compaction marker must contain a response item array")
|
|
575
|
+
for index, item in enumerate(parsed):
|
|
576
|
+
if not isinstance(item, dict):
|
|
577
|
+
raise ChatGPTOAuthError(
|
|
578
|
+
f"remote compaction marker item {index} must be an object"
|
|
579
|
+
)
|
|
580
|
+
items.append(item)
|
|
581
|
+
continue
|
|
582
|
+
if message.role is MessageRole.TOOL:
|
|
583
|
+
items.append({
|
|
584
|
+
"type": "function_call_output",
|
|
585
|
+
"call_id": message.tool_call_id or message.name or "tool-call",
|
|
586
|
+
"output": message.content,
|
|
587
|
+
})
|
|
588
|
+
continue
|
|
589
|
+
if message.role is MessageRole.ASSISTANT and message.tool_calls:
|
|
590
|
+
if message.content:
|
|
591
|
+
items.append(_message_item("assistant", message.content))
|
|
592
|
+
for tool_call in message.tool_calls:
|
|
593
|
+
items.append({
|
|
594
|
+
"type": "function_call",
|
|
595
|
+
"call_id": tool_call.id,
|
|
596
|
+
"name": tool_call.name,
|
|
597
|
+
"arguments": json.dumps(tool_call.arguments, ensure_ascii=False),
|
|
598
|
+
})
|
|
599
|
+
continue
|
|
600
|
+
role = "assistant" if message.role is MessageRole.ASSISTANT else "user"
|
|
601
|
+
items.append(_message_item(role, message.content, message.images))
|
|
602
|
+
return items
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def _message_item(role: str, content: str, images: tuple[str, ...] = ()) -> dict[str, Any]:
|
|
606
|
+
typ = "output_text" if role == "assistant" else "input_text"
|
|
607
|
+
content_items: list[dict[str, Any]] = [{"type": typ, "text": content or ""}]
|
|
608
|
+
for image_url in images:
|
|
609
|
+
content_items.append({"type": "input_image", "image_url": image_url})
|
|
610
|
+
return {"type": "message", "role": role, "content": content_items}
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def _tool_schema_to_response_dict(tool: ToolSchema) -> dict[str, Any]:
|
|
614
|
+
return {"type": "function", "name": tool.name, "description": tool.description, "parameters": tool.parameters, "strict": False}
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def _set_reasoning_payload(payload: dict[str, Any], reasoning_effort: str | None) -> None:
|
|
618
|
+
if reasoning_effort is None:
|
|
619
|
+
return
|
|
620
|
+
if not isinstance(reasoning_effort, str) or reasoning_effort == "":
|
|
621
|
+
raise ChatGPTOAuthError("reasoning_effort must be a non-empty string when provided")
|
|
622
|
+
effort = reasoning_effort.lower()
|
|
623
|
+
if effort not in REASONING_EFFORT_VALUES:
|
|
624
|
+
raise ChatGPTOAuthError(
|
|
625
|
+
"reasoning_effort must be one of: " + ", ".join(sorted(REASONING_EFFORT_VALUES))
|
|
626
|
+
)
|
|
627
|
+
payload["reasoning"] = {"effort": effort}
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def _tool_call_from_response_item(item: dict[str, Any]) -> ToolCall | None:
|
|
631
|
+
if item.get("type") not in {"function_call", "custom_tool_call"}:
|
|
632
|
+
return None
|
|
633
|
+
name = item.get("name")
|
|
634
|
+
if not isinstance(name, str) or name == "":
|
|
635
|
+
return None
|
|
636
|
+
raw_args = item.get("arguments") or item.get("input") or "{}"
|
|
637
|
+
if isinstance(raw_args, str):
|
|
638
|
+
try:
|
|
639
|
+
args = json.loads(raw_args) if raw_args else {}
|
|
640
|
+
except json.JSONDecodeError:
|
|
641
|
+
args = {"input": raw_args}
|
|
642
|
+
elif isinstance(raw_args, dict):
|
|
643
|
+
args = raw_args
|
|
644
|
+
else:
|
|
645
|
+
args = {}
|
|
646
|
+
call_id = item.get("call_id") or item.get("id") or uuid.uuid4().hex
|
|
647
|
+
return ToolCall(id=str(call_id), name=name, arguments=args)
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def _text_from_response_items(items: Sequence[dict[str, Any]]) -> str:
|
|
651
|
+
parts: list[str] = []
|
|
652
|
+
for item in items:
|
|
653
|
+
item_type = item.get("type")
|
|
654
|
+
if item_type in {"output_text", "text"}:
|
|
655
|
+
text = item.get("text")
|
|
656
|
+
if isinstance(text, str) and text:
|
|
657
|
+
parts.append(text)
|
|
658
|
+
continue
|
|
659
|
+
if item_type != "message":
|
|
660
|
+
continue
|
|
661
|
+
content = item.get("content")
|
|
662
|
+
if not isinstance(content, list):
|
|
663
|
+
continue
|
|
664
|
+
for part in content:
|
|
665
|
+
if isinstance(part, str):
|
|
666
|
+
if part:
|
|
667
|
+
parts.append(part)
|
|
668
|
+
continue
|
|
669
|
+
if not isinstance(part, dict):
|
|
670
|
+
continue
|
|
671
|
+
part_type = part.get("type")
|
|
672
|
+
if part_type not in {"output_text", "text"}:
|
|
673
|
+
continue
|
|
674
|
+
text = part.get("text")
|
|
675
|
+
if isinstance(text, str) and text:
|
|
676
|
+
parts.append(text)
|
|
677
|
+
return "".join(parts)
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
def _usage_from_response(value: Any) -> Usage | None:
|
|
681
|
+
if not isinstance(value, dict):
|
|
682
|
+
return None
|
|
683
|
+
prompt = value.get("input_tokens", value.get("prompt_tokens"))
|
|
684
|
+
completion = value.get("output_tokens", value.get("completion_tokens"))
|
|
685
|
+
total = value.get("total_tokens")
|
|
686
|
+
if not isinstance(prompt, int) or not isinstance(completion, int):
|
|
687
|
+
return None
|
|
688
|
+
token_details = value.get("input_tokens_details", value.get("prompt_tokens_details"))
|
|
689
|
+
cached_tokens = 0
|
|
690
|
+
if isinstance(token_details, dict) and isinstance(token_details.get("cached_tokens"), int):
|
|
691
|
+
cached_tokens = int(token_details["cached_tokens"])
|
|
692
|
+
elif isinstance(value.get("cached_input_tokens"), int):
|
|
693
|
+
cached_tokens = int(value["cached_input_tokens"])
|
|
694
|
+
elif isinstance(value.get("cache_read_input_tokens"), int):
|
|
695
|
+
cached_tokens = int(value["cache_read_input_tokens"])
|
|
696
|
+
return Usage(
|
|
697
|
+
prompt_tokens=prompt,
|
|
698
|
+
completion_tokens=completion,
|
|
699
|
+
total_tokens=total if isinstance(total, int) else None,
|
|
700
|
+
cached_tokens=cached_tokens,
|
|
701
|
+
)
|