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