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/__init__.py
ADDED
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import uuid
|
|
5
|
+
from typing import Any, Iterator, Sequence
|
|
6
|
+
|
|
7
|
+
from .messages import Message, MessageRole, ToolCall, ToolSchema, Usage, AssistantResponse
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# ---------------------------------------------------------------------------
|
|
11
|
+
# Request conversion: Anthropic → internal
|
|
12
|
+
# ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
def anthropic_request_to_internal(
|
|
15
|
+
*,
|
|
16
|
+
model: str,
|
|
17
|
+
messages: list[dict[str, Any]],
|
|
18
|
+
system: str | list[dict[str, Any]] | None = None,
|
|
19
|
+
max_tokens: int = 4096,
|
|
20
|
+
tools: list[dict[str, Any]] | None = None,
|
|
21
|
+
tool_choice: dict[str, Any] | None = None,
|
|
22
|
+
stop_sequences: list[str] | None = None,
|
|
23
|
+
thinking: dict[str, Any] | None = None,
|
|
24
|
+
) -> tuple[list[Message], list[ToolSchema] | None, str | dict | None, list[str] | None, str | None]:
|
|
25
|
+
"""Convert Anthropic Messages request fields to internal types.
|
|
26
|
+
|
|
27
|
+
Returns (messages, tools, tool_choice, stop, reasoning_effort).
|
|
28
|
+
"""
|
|
29
|
+
internal_messages: list[Message] = []
|
|
30
|
+
|
|
31
|
+
# System prompt → SYSTEM message
|
|
32
|
+
if system is not None:
|
|
33
|
+
sys_text = _extract_system_text(system)
|
|
34
|
+
if sys_text:
|
|
35
|
+
internal_messages.append(Message(role=MessageRole.SYSTEM, content=sys_text))
|
|
36
|
+
|
|
37
|
+
# Convert messages
|
|
38
|
+
for msg in messages:
|
|
39
|
+
role = msg.get("role", "user")
|
|
40
|
+
content = msg.get("content", "")
|
|
41
|
+
if role == "user":
|
|
42
|
+
_convert_user_message(content, internal_messages)
|
|
43
|
+
elif role == "assistant":
|
|
44
|
+
_convert_assistant_message(content, internal_messages)
|
|
45
|
+
|
|
46
|
+
# Convert tools
|
|
47
|
+
internal_tools = _convert_tools(tools) if tools else None
|
|
48
|
+
|
|
49
|
+
# Convert tool_choice
|
|
50
|
+
internal_tool_choice = _convert_tool_choice(tool_choice)
|
|
51
|
+
|
|
52
|
+
# Convert thinking → reasoning_effort
|
|
53
|
+
reasoning_effort = _convert_thinking(thinking)
|
|
54
|
+
|
|
55
|
+
return internal_messages, internal_tools, internal_tool_choice, stop_sequences, reasoning_effort
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _extract_system_text(system: str | list[dict[str, Any]]) -> str:
|
|
59
|
+
if isinstance(system, str):
|
|
60
|
+
return system
|
|
61
|
+
parts: list[str] = []
|
|
62
|
+
for block in system:
|
|
63
|
+
if isinstance(block, dict) and block.get("type") == "text":
|
|
64
|
+
text = block.get("text")
|
|
65
|
+
if isinstance(text, str) and text:
|
|
66
|
+
parts.append(text)
|
|
67
|
+
return "\n\n".join(parts)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _convert_user_message(content: str | list[dict[str, Any]], out: list[Message]) -> None:
|
|
71
|
+
if isinstance(content, str):
|
|
72
|
+
out.append(Message(role=MessageRole.USER, content=content))
|
|
73
|
+
return
|
|
74
|
+
text_parts: list[str] = []
|
|
75
|
+
image_urls: list[str] = []
|
|
76
|
+
for block in content:
|
|
77
|
+
if not isinstance(block, dict):
|
|
78
|
+
continue
|
|
79
|
+
block_type = block.get("type")
|
|
80
|
+
if block_type == "text":
|
|
81
|
+
text = block.get("text")
|
|
82
|
+
if isinstance(text, str):
|
|
83
|
+
text_parts.append(text)
|
|
84
|
+
elif block_type == "tool_result":
|
|
85
|
+
if text_parts or image_urls:
|
|
86
|
+
out.append(Message(
|
|
87
|
+
role=MessageRole.USER,
|
|
88
|
+
content="".join(text_parts),
|
|
89
|
+
images=tuple(image_urls),
|
|
90
|
+
))
|
|
91
|
+
text_parts = []
|
|
92
|
+
image_urls = []
|
|
93
|
+
tool_use_id = block.get("tool_use_id") or "tool-call"
|
|
94
|
+
result_content = block.get("content", "")
|
|
95
|
+
tool_result_images: list[str] = []
|
|
96
|
+
if isinstance(result_content, list):
|
|
97
|
+
text_pieces: list[str] = []
|
|
98
|
+
for p in result_content:
|
|
99
|
+
if not isinstance(p, dict):
|
|
100
|
+
continue
|
|
101
|
+
if p.get("type") == "text":
|
|
102
|
+
text_pieces.append(p.get("text", ""))
|
|
103
|
+
elif p.get("type") == "image":
|
|
104
|
+
source = p.get("source", {})
|
|
105
|
+
if isinstance(source, dict) and source.get("type") == "base64":
|
|
106
|
+
media_type = source.get("media_type", "image/png")
|
|
107
|
+
data = source.get("data", "")
|
|
108
|
+
tool_result_images.append(f"data:{media_type};base64,{data}")
|
|
109
|
+
result_content = "".join(text_pieces)
|
|
110
|
+
elif not isinstance(result_content, str):
|
|
111
|
+
result_content = str(result_content) if result_content else ""
|
|
112
|
+
out.append(Message(
|
|
113
|
+
role=MessageRole.TOOL,
|
|
114
|
+
content=result_content,
|
|
115
|
+
tool_call_id=tool_use_id,
|
|
116
|
+
name=tool_use_id,
|
|
117
|
+
))
|
|
118
|
+
if tool_result_images:
|
|
119
|
+
out.append(Message(
|
|
120
|
+
role=MessageRole.USER,
|
|
121
|
+
content="",
|
|
122
|
+
images=tuple(tool_result_images),
|
|
123
|
+
))
|
|
124
|
+
elif block_type == "image":
|
|
125
|
+
source = block.get("source", {})
|
|
126
|
+
if isinstance(source, dict) and source.get("type") == "base64":
|
|
127
|
+
media_type = source.get("media_type", "image/png")
|
|
128
|
+
data = source.get("data", "")
|
|
129
|
+
image_urls.append(f"data:{media_type};base64,{data}")
|
|
130
|
+
if text_parts or image_urls:
|
|
131
|
+
out.append(Message(
|
|
132
|
+
role=MessageRole.USER,
|
|
133
|
+
content="".join(text_parts),
|
|
134
|
+
images=tuple(image_urls),
|
|
135
|
+
))
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _convert_assistant_message(content: str | list[dict[str, Any]], out: list[Message]) -> None:
|
|
139
|
+
if isinstance(content, str):
|
|
140
|
+
out.append(Message(role=MessageRole.ASSISTANT, content=content))
|
|
141
|
+
return
|
|
142
|
+
text_parts: list[str] = []
|
|
143
|
+
tool_calls: list[ToolCall] = []
|
|
144
|
+
reasoning_content: str | None = None
|
|
145
|
+
for block in content:
|
|
146
|
+
if not isinstance(block, dict):
|
|
147
|
+
continue
|
|
148
|
+
block_type = block.get("type")
|
|
149
|
+
if block_type == "text":
|
|
150
|
+
text = block.get("text")
|
|
151
|
+
if isinstance(text, str):
|
|
152
|
+
text_parts.append(text)
|
|
153
|
+
elif block_type == "tool_use":
|
|
154
|
+
tool_calls.append(ToolCall(
|
|
155
|
+
id=block.get("id") or uuid.uuid4().hex,
|
|
156
|
+
name=block.get("name") or "",
|
|
157
|
+
arguments=block.get("input") or {},
|
|
158
|
+
))
|
|
159
|
+
elif block_type == "thinking":
|
|
160
|
+
thinking_text = block.get("thinking")
|
|
161
|
+
if isinstance(thinking_text, str) and thinking_text:
|
|
162
|
+
reasoning_content = thinking_text
|
|
163
|
+
out.append(Message(
|
|
164
|
+
role=MessageRole.ASSISTANT,
|
|
165
|
+
content="".join(text_parts),
|
|
166
|
+
tool_calls=tuple(tool_calls) if tool_calls else (),
|
|
167
|
+
reasoning_content=reasoning_content,
|
|
168
|
+
))
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _convert_tools(tools: list[dict[str, Any]]) -> list[ToolSchema]:
|
|
172
|
+
result: list[ToolSchema] = []
|
|
173
|
+
for tool in tools:
|
|
174
|
+
if not isinstance(tool, dict):
|
|
175
|
+
continue
|
|
176
|
+
name = tool.get("name")
|
|
177
|
+
if not name:
|
|
178
|
+
continue
|
|
179
|
+
result.append(ToolSchema(
|
|
180
|
+
name=str(name),
|
|
181
|
+
description=str(tool.get("description") or ""),
|
|
182
|
+
parameters=tool.get("input_schema") or {},
|
|
183
|
+
))
|
|
184
|
+
return result
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _convert_tool_choice(tc: dict[str, Any] | None) -> str | dict | None:
|
|
188
|
+
if tc is None:
|
|
189
|
+
return None
|
|
190
|
+
tc_type = tc.get("type")
|
|
191
|
+
if tc_type == "auto":
|
|
192
|
+
return "auto"
|
|
193
|
+
if tc_type == "any":
|
|
194
|
+
return "required"
|
|
195
|
+
if tc_type == "tool":
|
|
196
|
+
return {"type": "function", "function": {"name": tc.get("name")}}
|
|
197
|
+
if tc_type == "none":
|
|
198
|
+
return "none"
|
|
199
|
+
return "auto"
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _convert_thinking(thinking: dict[str, Any] | None) -> str | None:
|
|
203
|
+
if thinking is None:
|
|
204
|
+
return None
|
|
205
|
+
if thinking.get("type") == "enabled":
|
|
206
|
+
return "high"
|
|
207
|
+
if thinking.get("type") == "adaptive":
|
|
208
|
+
return "medium"
|
|
209
|
+
return None
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
# ---------------------------------------------------------------------------
|
|
213
|
+
# Non-streaming response: internal → Anthropic
|
|
214
|
+
# ---------------------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
def internal_response_to_anthropic(
|
|
217
|
+
response: AssistantResponse,
|
|
218
|
+
model: str,
|
|
219
|
+
request_id: str,
|
|
220
|
+
) -> dict[str, Any]:
|
|
221
|
+
content: list[dict[str, Any]] = []
|
|
222
|
+
|
|
223
|
+
if response.reasoning_content:
|
|
224
|
+
content.append({
|
|
225
|
+
"type": "thinking",
|
|
226
|
+
"thinking": response.reasoning_content,
|
|
227
|
+
"signature": "sig-placeholder",
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
if response.content:
|
|
231
|
+
content.append({"type": "text", "text": response.content})
|
|
232
|
+
|
|
233
|
+
for tc in response.tool_calls:
|
|
234
|
+
content.append({
|
|
235
|
+
"type": "tool_use",
|
|
236
|
+
"id": tc.id,
|
|
237
|
+
"name": tc.name,
|
|
238
|
+
"input": tc.arguments,
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
stop_reason = _map_stop_reason(response.finish_reason, bool(response.tool_calls))
|
|
242
|
+
|
|
243
|
+
usage_dict: dict[str, Any] = {"input_tokens": 0, "output_tokens": 0}
|
|
244
|
+
if response.usage:
|
|
245
|
+
usage_dict = {
|
|
246
|
+
"input_tokens": response.usage.prompt_tokens,
|
|
247
|
+
"output_tokens": response.usage.completion_tokens,
|
|
248
|
+
"cache_creation_input_tokens": 0,
|
|
249
|
+
"cache_read_input_tokens": response.usage.cached_tokens,
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if not content:
|
|
253
|
+
content.append({"type": "text", "text": ""})
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
"id": request_id,
|
|
257
|
+
"type": "message",
|
|
258
|
+
"role": "assistant",
|
|
259
|
+
"model": model,
|
|
260
|
+
"content": content,
|
|
261
|
+
"stop_reason": stop_reason,
|
|
262
|
+
"stop_sequence": None,
|
|
263
|
+
"usage": usage_dict,
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _map_stop_reason(finish_reason: str, has_tool_calls: bool) -> str:
|
|
268
|
+
if has_tool_calls:
|
|
269
|
+
return "tool_use"
|
|
270
|
+
mapping = {
|
|
271
|
+
"stop": "end_turn",
|
|
272
|
+
"length": "max_tokens",
|
|
273
|
+
"max_tokens": "max_tokens",
|
|
274
|
+
"tool_calls": "tool_use",
|
|
275
|
+
"tool_use": "tool_use",
|
|
276
|
+
"stop_sequence": "stop_sequence",
|
|
277
|
+
}
|
|
278
|
+
return mapping.get(finish_reason, "end_turn")
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
# ---------------------------------------------------------------------------
|
|
282
|
+
# Streaming adapter: provider events → Anthropic SSE
|
|
283
|
+
# ---------------------------------------------------------------------------
|
|
284
|
+
|
|
285
|
+
def anthropic_stream_adapter(
|
|
286
|
+
event_stream: Iterator[dict[str, Any]],
|
|
287
|
+
model: str,
|
|
288
|
+
request_id: str,
|
|
289
|
+
) -> Iterator[str]:
|
|
290
|
+
"""Convert provider chat_stream events into Anthropic SSE strings."""
|
|
291
|
+
# Emit message_start
|
|
292
|
+
yield _sse("message_start", {
|
|
293
|
+
"type": "message_start",
|
|
294
|
+
"message": {
|
|
295
|
+
"id": request_id,
|
|
296
|
+
"type": "message",
|
|
297
|
+
"role": "assistant",
|
|
298
|
+
"model": model,
|
|
299
|
+
"content": [],
|
|
300
|
+
"stop_reason": None,
|
|
301
|
+
"stop_sequence": None,
|
|
302
|
+
"usage": {"input_tokens": 0, "output_tokens": 0},
|
|
303
|
+
},
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
block_index = 0
|
|
307
|
+
current_block: str | None = None # "thinking", "text", "tool_use"
|
|
308
|
+
has_any_content = False
|
|
309
|
+
output_tokens = 0
|
|
310
|
+
|
|
311
|
+
for event in event_stream:
|
|
312
|
+
typ = event.get("type")
|
|
313
|
+
|
|
314
|
+
if typ in ("reasoning_delta", "reasoning_raw_delta"):
|
|
315
|
+
has_any_content = True
|
|
316
|
+
text = str(event.get("text", ""))
|
|
317
|
+
if current_block != "thinking":
|
|
318
|
+
if current_block is not None:
|
|
319
|
+
yield _sse("content_block_stop", {"type": "content_block_stop", "index": block_index})
|
|
320
|
+
block_index += 1
|
|
321
|
+
yield _sse("content_block_start", {
|
|
322
|
+
"type": "content_block_start",
|
|
323
|
+
"index": block_index,
|
|
324
|
+
"content_block": {"type": "thinking", "thinking": "", "signature": ""},
|
|
325
|
+
})
|
|
326
|
+
current_block = "thinking"
|
|
327
|
+
yield _sse("content_block_delta", {
|
|
328
|
+
"type": "content_block_delta",
|
|
329
|
+
"index": block_index,
|
|
330
|
+
"delta": {"type": "thinking_delta", "thinking": text},
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
elif typ == "content":
|
|
334
|
+
has_any_content = True
|
|
335
|
+
text = str(event.get("text", ""))
|
|
336
|
+
if current_block == "thinking":
|
|
337
|
+
# Close thinking block, emit signature
|
|
338
|
+
yield _sse("content_block_delta", {
|
|
339
|
+
"type": "content_block_delta",
|
|
340
|
+
"index": block_index,
|
|
341
|
+
"delta": {"type": "signature_delta", "signature": "sig-placeholder"},
|
|
342
|
+
})
|
|
343
|
+
yield _sse("content_block_stop", {"type": "content_block_stop", "index": block_index})
|
|
344
|
+
block_index += 1
|
|
345
|
+
current_block = None
|
|
346
|
+
if current_block != "text":
|
|
347
|
+
if current_block is not None:
|
|
348
|
+
yield _sse("content_block_stop", {"type": "content_block_stop", "index": block_index})
|
|
349
|
+
block_index += 1
|
|
350
|
+
yield _sse("content_block_start", {
|
|
351
|
+
"type": "content_block_start",
|
|
352
|
+
"index": block_index,
|
|
353
|
+
"content_block": {"type": "text", "text": ""},
|
|
354
|
+
})
|
|
355
|
+
current_block = "text"
|
|
356
|
+
yield _sse("content_block_delta", {
|
|
357
|
+
"type": "content_block_delta",
|
|
358
|
+
"index": block_index,
|
|
359
|
+
"delta": {"type": "text_delta", "text": text},
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
elif typ == "tool_call":
|
|
363
|
+
has_any_content = True
|
|
364
|
+
if current_block is not None:
|
|
365
|
+
if current_block == "thinking":
|
|
366
|
+
yield _sse("content_block_delta", {
|
|
367
|
+
"type": "content_block_delta",
|
|
368
|
+
"index": block_index,
|
|
369
|
+
"delta": {"type": "signature_delta", "signature": "sig-placeholder"},
|
|
370
|
+
})
|
|
371
|
+
yield _sse("content_block_stop", {"type": "content_block_stop", "index": block_index})
|
|
372
|
+
block_index += 1
|
|
373
|
+
tool_id = str(event.get("id", ""))
|
|
374
|
+
tool_name = str(event.get("name", ""))
|
|
375
|
+
tool_args = event.get("arguments") or {}
|
|
376
|
+
yield _sse("content_block_start", {
|
|
377
|
+
"type": "content_block_start",
|
|
378
|
+
"index": block_index,
|
|
379
|
+
"content_block": {"type": "tool_use", "id": tool_id, "name": tool_name, "input": {}},
|
|
380
|
+
})
|
|
381
|
+
yield _sse("content_block_delta", {
|
|
382
|
+
"type": "content_block_delta",
|
|
383
|
+
"index": block_index,
|
|
384
|
+
"delta": {"type": "input_json_delta", "partial_json": json.dumps(tool_args, ensure_ascii=False)},
|
|
385
|
+
})
|
|
386
|
+
yield _sse("content_block_stop", {"type": "content_block_stop", "index": block_index})
|
|
387
|
+
block_index += 1
|
|
388
|
+
current_block = None
|
|
389
|
+
|
|
390
|
+
elif typ == "finish":
|
|
391
|
+
if current_block is not None:
|
|
392
|
+
if current_block == "thinking":
|
|
393
|
+
yield _sse("content_block_delta", {
|
|
394
|
+
"type": "content_block_delta",
|
|
395
|
+
"index": block_index,
|
|
396
|
+
"delta": {"type": "signature_delta", "signature": "sig-placeholder"},
|
|
397
|
+
})
|
|
398
|
+
yield _sse("content_block_stop", {"type": "content_block_stop", "index": block_index})
|
|
399
|
+
current_block = None
|
|
400
|
+
|
|
401
|
+
if not has_any_content:
|
|
402
|
+
yield _sse("content_block_start", {
|
|
403
|
+
"type": "content_block_start",
|
|
404
|
+
"index": block_index,
|
|
405
|
+
"content_block": {"type": "text", "text": ""},
|
|
406
|
+
})
|
|
407
|
+
yield _sse("content_block_stop", {"type": "content_block_stop", "index": block_index})
|
|
408
|
+
|
|
409
|
+
finish_reason = str(event.get("finish_reason") or "stop")
|
|
410
|
+
stop_reason = _map_stop_reason(finish_reason, False)
|
|
411
|
+
usage = event.get("usage")
|
|
412
|
+
if isinstance(usage, dict):
|
|
413
|
+
output_tokens = usage.get("output_tokens", usage.get("completion_tokens", 0))
|
|
414
|
+
|
|
415
|
+
yield _sse("message_delta", {
|
|
416
|
+
"type": "message_delta",
|
|
417
|
+
"delta": {"stop_reason": stop_reason, "stop_sequence": None},
|
|
418
|
+
"usage": {"output_tokens": output_tokens},
|
|
419
|
+
})
|
|
420
|
+
yield _sse("message_stop", {"type": "message_stop"})
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _sse(event_type: str, data: dict[str, Any]) -> str:
|
|
424
|
+
return f"event: {event_type}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
# ---------------------------------------------------------------------------
|
|
428
|
+
# Error formatting
|
|
429
|
+
# ---------------------------------------------------------------------------
|
|
430
|
+
|
|
431
|
+
def format_anthropic_error(status: int, message: str) -> dict[str, Any]:
|
|
432
|
+
type_map = {
|
|
433
|
+
400: "invalid_request_error",
|
|
434
|
+
401: "authentication_error",
|
|
435
|
+
403: "permission_error",
|
|
436
|
+
404: "not_found_error",
|
|
437
|
+
429: "rate_limit_error",
|
|
438
|
+
500: "api_error",
|
|
439
|
+
529: "overloaded_error",
|
|
440
|
+
}
|
|
441
|
+
return {
|
|
442
|
+
"type": "error",
|
|
443
|
+
"error": {
|
|
444
|
+
"type": type_map.get(status, "api_error"),
|
|
445
|
+
"message": message,
|
|
446
|
+
},
|
|
447
|
+
}
|
codex_as_api/auth.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import dataclasses
|
|
5
|
+
import datetime as _dt
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import pathlib
|
|
9
|
+
import threading
|
|
10
|
+
import urllib.error
|
|
11
|
+
import urllib.request
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
CHATGPT_OAUTH_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
|
15
|
+
DEFAULT_AUTH_PATH = "~/.codex/auth.json"
|
|
16
|
+
DEFAULT_REFRESH_URL = "https://auth.openai.com/oauth/token"
|
|
17
|
+
REFRESH_URL_OVERRIDE_ENV = "CODEX_REFRESH_TOKEN_URL_OVERRIDE"
|
|
18
|
+
|
|
19
|
+
_SECRET_KEYS = (
|
|
20
|
+
"access_token",
|
|
21
|
+
"refresh_token",
|
|
22
|
+
"id_token",
|
|
23
|
+
"Authorization",
|
|
24
|
+
"authorization",
|
|
25
|
+
"ChatGPT-Account-Id",
|
|
26
|
+
"chatgpt-account-id",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
_REFRESH_LOCKS: dict[pathlib.Path, threading.Lock] = {}
|
|
30
|
+
_REFRESH_LOCKS_GUARD = threading.Lock()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ChatGPTOAuthError(RuntimeError):
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ChatGPTOAuthMissingError(ChatGPTOAuthError):
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ChatGPTOAuthRefreshError(ChatGPTOAuthError):
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclasses.dataclass(frozen=True, slots=True)
|
|
46
|
+
class ChatGPTTokenData:
|
|
47
|
+
auth_path: pathlib.Path
|
|
48
|
+
access_token: str
|
|
49
|
+
refresh_token: str
|
|
50
|
+
id_token: str
|
|
51
|
+
account_id: str
|
|
52
|
+
plan_type: str | None
|
|
53
|
+
user_id: str | None
|
|
54
|
+
fedramp: bool
|
|
55
|
+
access_expires_at: _dt.datetime | None
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def expired(self) -> bool:
|
|
59
|
+
return self.access_expires_at is not None and self.access_expires_at <= _dt.datetime.now(_dt.UTC)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def resolve_auth_path(raw: str | None = None) -> pathlib.Path:
|
|
63
|
+
value = raw or os.getenv("CODEX_HOME")
|
|
64
|
+
if value and raw is None:
|
|
65
|
+
return pathlib.Path(value).expanduser() / "auth.json"
|
|
66
|
+
return pathlib.Path(raw or DEFAULT_AUTH_PATH).expanduser()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _jwt_claims(jwt: str) -> dict[str, Any]:
|
|
70
|
+
parts = jwt.split(".")
|
|
71
|
+
if len(parts) < 2 or not parts[1]:
|
|
72
|
+
return {}
|
|
73
|
+
payload = parts[1] + "=" * ((4 - len(parts[1]) % 4) % 4)
|
|
74
|
+
try:
|
|
75
|
+
decoded = base64.urlsafe_b64decode(payload.encode())
|
|
76
|
+
value = json.loads(decoded)
|
|
77
|
+
except Exception as exc: # noqa: BLE001 - invalid JWT is auth-data invalid
|
|
78
|
+
raise ChatGPTOAuthError("invalid ChatGPT OAuth JWT payload") from exc
|
|
79
|
+
if not isinstance(value, dict):
|
|
80
|
+
raise ChatGPTOAuthError("invalid ChatGPT OAuth JWT claims")
|
|
81
|
+
return value
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _expiration(jwt: str) -> _dt.datetime | None:
|
|
85
|
+
claims = _jwt_claims(jwt)
|
|
86
|
+
exp = claims.get("exp")
|
|
87
|
+
if not isinstance(exp, int):
|
|
88
|
+
return None
|
|
89
|
+
return _dt.datetime.fromtimestamp(exp, _dt.UTC)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _auth_claims(jwt: str) -> dict[str, Any]:
|
|
93
|
+
claims = _jwt_claims(jwt)
|
|
94
|
+
value = claims.get("https://api.openai.com/auth")
|
|
95
|
+
return value if isinstance(value, dict) else {}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def register_token_secrets(*values: str | None) -> None:
|
|
99
|
+
"""No-op in standalone mode; secrets are redacted inline via redact_text()."""
|
|
100
|
+
pass
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def redact_text(text: str, *values: str | None) -> str:
|
|
104
|
+
redacted = str(text)
|
|
105
|
+
for value in sorted([v for v in values if v], key=len, reverse=True):
|
|
106
|
+
redacted = redacted.replace(value, "***")
|
|
107
|
+
return redacted
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def load_token_data(auth_json_path: str | pathlib.Path | None = None) -> ChatGPTTokenData:
|
|
111
|
+
path = resolve_auth_path(str(auth_json_path) if auth_json_path is not None else None)
|
|
112
|
+
try:
|
|
113
|
+
raw = path.read_text(encoding="utf-8")
|
|
114
|
+
except FileNotFoundError as exc:
|
|
115
|
+
raise ChatGPTOAuthMissingError(f"ChatGPT OAuth auth file not found: {path}") from exc
|
|
116
|
+
try:
|
|
117
|
+
data = json.loads(raw)
|
|
118
|
+
except json.JSONDecodeError as exc:
|
|
119
|
+
raise ChatGPTOAuthError(f"ChatGPT OAuth auth file is invalid JSON: {path}") from exc
|
|
120
|
+
if not isinstance(data, dict):
|
|
121
|
+
raise ChatGPTOAuthError("ChatGPT OAuth auth file root must be an object")
|
|
122
|
+
mode = data.get("auth_mode")
|
|
123
|
+
if mode not in {"chatgpt", "Chatgpt", "chatgpt_auth_tokens", "ChatgptAuthTokens", None}:
|
|
124
|
+
raise ChatGPTOAuthError(f"ChatGPT OAuth auth_mode required, got {mode!r}")
|
|
125
|
+
tokens = data.get("tokens")
|
|
126
|
+
if not isinstance(tokens, dict):
|
|
127
|
+
raise ChatGPTOAuthError("ChatGPT OAuth token data is not available")
|
|
128
|
+
access_token = tokens.get("access_token")
|
|
129
|
+
refresh_token = tokens.get("refresh_token")
|
|
130
|
+
id_token = tokens.get("id_token")
|
|
131
|
+
for name, value in (("access_token", access_token), ("refresh_token", refresh_token), ("id_token", id_token)):
|
|
132
|
+
if not isinstance(value, str) or value == "":
|
|
133
|
+
raise ChatGPTOAuthError(f"ChatGPT OAuth {name} is missing")
|
|
134
|
+
register_token_secrets(access_token, refresh_token, id_token)
|
|
135
|
+
id_auth = _auth_claims(id_token)
|
|
136
|
+
access_auth = _auth_claims(access_token)
|
|
137
|
+
account_id = tokens.get("account_id") or id_auth.get("chatgpt_account_id") or access_auth.get("chatgpt_account_id")
|
|
138
|
+
if not isinstance(account_id, str) or account_id == "":
|
|
139
|
+
raise ChatGPTOAuthError("ChatGPT OAuth account id not available; rerun codex login")
|
|
140
|
+
register_token_secrets(account_id)
|
|
141
|
+
plan = id_auth.get("chatgpt_plan_type") or access_auth.get("chatgpt_plan_type")
|
|
142
|
+
user = id_auth.get("chatgpt_user_id") or id_auth.get("user_id") or access_auth.get("chatgpt_user_id") or access_auth.get("user_id")
|
|
143
|
+
fedramp = bool(id_auth.get("chatgpt_account_is_fedramp") or access_auth.get("chatgpt_account_is_fedramp"))
|
|
144
|
+
return ChatGPTTokenData(
|
|
145
|
+
auth_path=path,
|
|
146
|
+
access_token=access_token,
|
|
147
|
+
refresh_token=refresh_token,
|
|
148
|
+
id_token=id_token,
|
|
149
|
+
account_id=account_id,
|
|
150
|
+
plan_type=plan if isinstance(plan, str) else None,
|
|
151
|
+
user_id=user if isinstance(user, str) else None,
|
|
152
|
+
fedramp=fedramp,
|
|
153
|
+
access_expires_at=_expiration(access_token),
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def is_auth_locally_available(auth_json_path: str | pathlib.Path | None = None) -> bool:
|
|
158
|
+
try:
|
|
159
|
+
data = load_token_data(auth_json_path)
|
|
160
|
+
except ChatGPTOAuthError:
|
|
161
|
+
return False
|
|
162
|
+
return bool(data.access_token and data.account_id)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _refresh_lock(path: pathlib.Path) -> threading.Lock:
|
|
166
|
+
resolved = path.expanduser()
|
|
167
|
+
with _REFRESH_LOCKS_GUARD:
|
|
168
|
+
lock = _REFRESH_LOCKS.get(resolved)
|
|
169
|
+
if lock is None:
|
|
170
|
+
lock = threading.Lock()
|
|
171
|
+
_REFRESH_LOCKS[resolved] = lock
|
|
172
|
+
return lock
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _write_auth_json(path: pathlib.Path, data: dict[str, Any]) -> None:
|
|
176
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
177
|
+
tmp = path.with_name(f".{path.name}.tmp-{os.getpid()}-{threading.get_ident()}")
|
|
178
|
+
payload = json.dumps(data, ensure_ascii=False, indent=2) + "\n"
|
|
179
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
|
|
180
|
+
fd = os.open(tmp, flags, 0o600)
|
|
181
|
+
try:
|
|
182
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
183
|
+
f.write(payload)
|
|
184
|
+
f.flush()
|
|
185
|
+
os.fsync(f.fileno())
|
|
186
|
+
tmp.replace(path)
|
|
187
|
+
dir_fd = os.open(path.parent, os.O_RDONLY)
|
|
188
|
+
try:
|
|
189
|
+
os.fsync(dir_fd)
|
|
190
|
+
finally:
|
|
191
|
+
os.close(dir_fd)
|
|
192
|
+
finally:
|
|
193
|
+
try:
|
|
194
|
+
tmp.unlink(missing_ok=True)
|
|
195
|
+
except OSError:
|
|
196
|
+
pass
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def refresh_token(auth_json_path: str | pathlib.Path | None = None) -> ChatGPTTokenData:
|
|
200
|
+
current = load_token_data(auth_json_path)
|
|
201
|
+
lock = _refresh_lock(current.auth_path)
|
|
202
|
+
with lock:
|
|
203
|
+
current = load_token_data(auth_json_path)
|
|
204
|
+
endpoint = os.getenv(REFRESH_URL_OVERRIDE_ENV, DEFAULT_REFRESH_URL)
|
|
205
|
+
body = json.dumps({
|
|
206
|
+
"client_id": CHATGPT_OAUTH_CLIENT_ID,
|
|
207
|
+
"grant_type": "refresh_token",
|
|
208
|
+
"refresh_token": current.refresh_token,
|
|
209
|
+
}).encode()
|
|
210
|
+
request = urllib.request.Request(
|
|
211
|
+
endpoint,
|
|
212
|
+
data=body,
|
|
213
|
+
headers={"Content-Type": "application/json"},
|
|
214
|
+
method="POST",
|
|
215
|
+
)
|
|
216
|
+
try:
|
|
217
|
+
with urllib.request.urlopen(request, timeout=30) as response:
|
|
218
|
+
payload = json.loads(response.read().decode("utf-8"))
|
|
219
|
+
except urllib.error.HTTPError as exc:
|
|
220
|
+
text = exc.read().decode("utf-8", "replace")
|
|
221
|
+
redacted = redact_text(text, current.access_token, current.refresh_token, current.id_token)
|
|
222
|
+
if exc.code == 401:
|
|
223
|
+
raise ChatGPTOAuthRefreshError(f"ChatGPT OAuth refresh token is invalid; rerun codex login: {redacted}") from exc
|
|
224
|
+
raise ChatGPTOAuthRefreshError(f"ChatGPT OAuth token refresh failed: HTTP {exc.code}: {redacted}") from exc
|
|
225
|
+
except Exception as exc: # noqa: BLE001
|
|
226
|
+
raise ChatGPTOAuthRefreshError(f"ChatGPT OAuth token refresh failed: {exc}") from exc
|
|
227
|
+
if not isinstance(payload, dict):
|
|
228
|
+
raise ChatGPTOAuthRefreshError("ChatGPT OAuth token refresh returned invalid JSON")
|
|
229
|
+
data = json.loads(current.auth_path.read_text(encoding="utf-8"))
|
|
230
|
+
tokens = data.setdefault("tokens", {})
|
|
231
|
+
if payload.get("id_token"):
|
|
232
|
+
tokens["id_token"] = payload["id_token"]
|
|
233
|
+
if payload.get("access_token"):
|
|
234
|
+
tokens["access_token"] = payload["access_token"]
|
|
235
|
+
if payload.get("refresh_token"):
|
|
236
|
+
tokens["refresh_token"] = payload["refresh_token"]
|
|
237
|
+
data["last_refresh"] = _dt.datetime.now(_dt.UTC).isoformat().replace("+00:00", "Z")
|
|
238
|
+
_write_auth_json(current.auth_path, data)
|
|
239
|
+
return load_token_data(auth_json_path)
|