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,3 @@
1
+ """codex-as-api: Use ChatGPT/Codex OAuth as a local OpenAI-compatible API server."""
2
+
3
+ __version__ = "0.1.0"
@@ -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)