coco-framework 0.1.0__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.
- coco/__init__.py +3 -0
- coco/agents/__init__.py +9 -0
- coco/agents/_subagent_transformer.py +307 -0
- coco/agents/factory.py +2164 -0
- coco/agents/middleware/__init__.py +100 -0
- coco/agents/middleware/_execution.py +385 -0
- coco/agents/middleware/_redaction.py +454 -0
- coco/agents/middleware/_retry.py +134 -0
- coco/agents/middleware/_trace_policy.py +61 -0
- coco/agents/middleware/context_editing.py +306 -0
- coco/agents/middleware/file_search.py +433 -0
- coco/agents/middleware/human_in_the_loop.py +680 -0
- coco/agents/middleware/internal_call_transformer.py +108 -0
- coco/agents/middleware/model_call_limit.py +267 -0
- coco/agents/middleware/model_fallback.py +417 -0
- coco/agents/middleware/model_retry.py +327 -0
- coco/agents/middleware/pii.py +878 -0
- coco/agents/middleware/provider_tool_search.py +307 -0
- coco/agents/middleware/shell_tool.py +949 -0
- coco/agents/middleware/summarization.py +910 -0
- coco/agents/middleware/todo.py +357 -0
- coco/agents/middleware/tool_call_limit.py +495 -0
- coco/agents/middleware/tool_emulator.py +251 -0
- coco/agents/middleware/tool_error.py +209 -0
- coco/agents/middleware/tool_retry.py +421 -0
- coco/agents/middleware/tool_selection.py +515 -0
- coco/agents/middleware/types.py +2201 -0
- coco/agents/structured_output.py +472 -0
- coco/chat_models/__init__.py +7 -0
- coco/chat_models/base.py +1082 -0
- coco/embeddings/__init__.py +18 -0
- coco/embeddings/base.py +279 -0
- coco/mcp/__init__.py +36 -0
- coco/mcp/adapter.py +245 -0
- coco/mcp/elicitation.py +394 -0
- coco/mcp/tools.py +307 -0
- coco/messages/__init__.py +73 -0
- coco/py.typed +0 -0
- coco/rate_limiters/__init__.py +13 -0
- coco/tools/__init__.py +22 -0
- coco/tools/tool_node.py +20 -0
- coco_anthropic/__init__.py +15 -0
- coco_anthropic/_client_utils.py +81 -0
- coco_anthropic/_compat.py +228 -0
- coco_anthropic/_sdk_compat.py +124 -0
- coco_anthropic/_version.py +3 -0
- coco_anthropic/chat_models.py +3349 -0
- coco_anthropic/data/__init__.py +1 -0
- coco_anthropic/data/_profiles.py +491 -0
- coco_anthropic/data/profile_augmentations.toml +69 -0
- coco_anthropic/experimental.py +140 -0
- coco_anthropic/llms.py +428 -0
- coco_anthropic/middleware/__init__.py +25 -0
- coco_anthropic/middleware/anthropic_tools.py +1197 -0
- coco_anthropic/middleware/bash.py +84 -0
- coco_anthropic/middleware/file_search.py +360 -0
- coco_anthropic/middleware/prompt_caching.py +276 -0
- coco_anthropic/output_parsers.py +93 -0
- coco_anthropic/py.typed +0 -0
- coco_core/__init__.py +20 -0
- coco_core/_api/__init__.py +87 -0
- coco_core/_api/beta_decorator.py +266 -0
- coco_core/_api/deprecation.py +637 -0
- coco_core/_api/internal.py +23 -0
- coco_core/_api/path.py +50 -0
- coco_core/_import_utils.py +41 -0
- coco_core/_protocol.py +625 -0
- coco_core/_security/__init__.py +36 -0
- coco_core/_security/_exceptions.py +9 -0
- coco_core/_security/_policy.py +313 -0
- coco_core/_security/_ssrf_protection.py +155 -0
- coco_core/_security/_transport.py +254 -0
- coco_core/agents.py +258 -0
- coco_core/caches.py +272 -0
- coco_core/callbacks/__init__.py +132 -0
- coco_core/callbacks/base.py +1229 -0
- coco_core/callbacks/file.py +266 -0
- coco_core/callbacks/manager.py +2826 -0
- coco_core/callbacks/stdout.py +123 -0
- coco_core/callbacks/streaming_stdout.py +154 -0
- coco_core/callbacks/usage.py +124 -0
- coco_core/chat_history.py +267 -0
- coco_core/chat_loaders.py +26 -0
- coco_core/chat_sessions.py +19 -0
- coco_core/cross_encoders.py +18 -0
- coco_core/document_loaders/__init__.py +36 -0
- coco_core/document_loaders/base.py +155 -0
- coco_core/document_loaders/blob_loaders.py +38 -0
- coco_core/documents/__init__.py +55 -0
- coco_core/documents/base.py +347 -0
- coco_core/documents/compressor.py +74 -0
- coco_core/documents/transformers.py +79 -0
- coco_core/embeddings/__init__.py +31 -0
- coco_core/embeddings/embeddings.py +78 -0
- coco_core/embeddings/fake.py +129 -0
- coco_core/env.py +22 -0
- coco_core/example_selectors/__init__.py +47 -0
- coco_core/example_selectors/base.py +60 -0
- coco_core/example_selectors/length_based.py +131 -0
- coco_core/example_selectors/semantic_similarity.py +366 -0
- coco_core/exceptions.py +166 -0
- coco_core/globals.py +72 -0
- coco_core/indexing/__init__.py +53 -0
- coco_core/indexing/api.py +954 -0
- coco_core/indexing/base.py +661 -0
- coco_core/indexing/in_memory.py +104 -0
- coco_core/language_models/__init__.py +116 -0
- coco_core/language_models/_compat_bridge.py +844 -0
- coco_core/language_models/_utils.py +343 -0
- coco_core/language_models/base.py +500 -0
- coco_core/language_models/chat_model_stream.py +1525 -0
- coco_core/language_models/chat_models.py +2748 -0
- coco_core/language_models/fake.py +137 -0
- coco_core/language_models/fake_chat_models.py +396 -0
- coco_core/language_models/llms.py +1569 -0
- coco_core/language_models/model_profile.py +183 -0
- coco_core/load/__init__.py +44 -0
- coco_core/load/_validation.py +191 -0
- coco_core/load/dump.py +120 -0
- coco_core/load/load.py +828 -0
- coco_core/load/mapping.py +1097 -0
- coco_core/load/serializable.py +406 -0
- coco_core/messages/__init__.py +198 -0
- coco_core/messages/ai.py +854 -0
- coco_core/messages/base.py +518 -0
- coco_core/messages/block_translators/__init__.py +112 -0
- coco_core/messages/block_translators/anthropic.py +534 -0
- coco_core/messages/block_translators/bedrock.py +108 -0
- coco_core/messages/block_translators/bedrock_converse.py +329 -0
- coco_core/messages/block_translators/coco_v0.py +303 -0
- coco_core/messages/block_translators/google_genai.py +610 -0
- coco_core/messages/block_translators/google_vertexai.py +21 -0
- coco_core/messages/block_translators/groq.py +157 -0
- coco_core/messages/block_translators/openai.py +1089 -0
- coco_core/messages/chat.py +64 -0
- coco_core/messages/content.py +1488 -0
- coco_core/messages/function.py +62 -0
- coco_core/messages/human.py +70 -0
- coco_core/messages/modifier.py +33 -0
- coco_core/messages/system.py +70 -0
- coco_core/messages/tool.py +418 -0
- coco_core/messages/utils.py +2442 -0
- coco_core/output_parsers/__init__.py +101 -0
- coco_core/output_parsers/base.py +361 -0
- coco_core/output_parsers/format_instructions.py +16 -0
- coco_core/output_parsers/json.py +139 -0
- coco_core/output_parsers/list.py +249 -0
- coco_core/output_parsers/openai_functions.py +324 -0
- coco_core/output_parsers/openai_tools.py +383 -0
- coco_core/output_parsers/pydantic.py +141 -0
- coco_core/output_parsers/string.py +63 -0
- coco_core/output_parsers/transform.py +175 -0
- coco_core/output_parsers/xml.py +301 -0
- coco_core/outputs/__init__.py +66 -0
- coco_core/outputs/chat_generation.py +147 -0
- coco_core/outputs/chat_result.py +39 -0
- coco_core/outputs/generation.py +80 -0
- coco_core/outputs/llm_result.py +112 -0
- coco_core/outputs/run_info.py +22 -0
- coco_core/prompt_values.py +161 -0
- coco_core/prompts/__init__.py +101 -0
- coco_core/prompts/base.py +508 -0
- coco_core/prompts/chat.py +1495 -0
- coco_core/prompts/dict.py +177 -0
- coco_core/prompts/few_shot.py +483 -0
- coco_core/prompts/few_shot_with_templates.py +239 -0
- coco_core/prompts/image.py +177 -0
- coco_core/prompts/loading.py +296 -0
- coco_core/prompts/message.py +97 -0
- coco_core/prompts/prompt.py +312 -0
- coco_core/prompts/string.py +399 -0
- coco_core/prompts/structured.py +214 -0
- coco_core/py.typed +0 -0
- coco_core/rate_limiters.py +254 -0
- coco_core/retrievers.py +328 -0
- coco_core/runnables/__init__.py +136 -0
- coco_core/runnables/base.py +6725 -0
- coco_core/runnables/branch.py +457 -0
- coco_core/runnables/config.py +712 -0
- coco_core/runnables/configurable.py +710 -0
- coco_core/runnables/fallbacks.py +663 -0
- coco_core/runnables/graph.py +741 -0
- coco_core/runnables/graph_ascii.py +366 -0
- coco_core/runnables/graph_mermaid.py +503 -0
- coco_core/runnables/graph_png.py +215 -0
- coco_core/runnables/history.py +631 -0
- coco_core/runnables/passthrough.py +849 -0
- coco_core/runnables/retry.py +379 -0
- coco_core/runnables/router.py +239 -0
- coco_core/runnables/schema.py +188 -0
- coco_core/runnables/utils.py +798 -0
- coco_core/stores.py +291 -0
- coco_core/structured_query.py +203 -0
- coco_core/sys_info.py +154 -0
- coco_core/tools/__init__.py +95 -0
- coco_core/tools/base.py +1948 -0
- coco_core/tools/convert.py +497 -0
- coco_core/tools/render.py +67 -0
- coco_core/tools/retriever.py +94 -0
- coco_core/tools/simple.py +208 -0
- coco_core/tools/structured.py +322 -0
- coco_core/tracers/__init__.py +47 -0
- coco_core/tracers/_compat.py +95 -0
- coco_core/tracers/_context.py +337 -0
- coco_core/tracers/_streaming.py +53 -0
- coco_core/tracers/base.py +955 -0
- coco_core/tracers/coco.py +279 -0
- coco_core/tracers/context.py +205 -0
- coco_core/tracers/core.py +766 -0
- coco_core/tracers/event_stream.py +1101 -0
- coco_core/tracers/log_stream.py +765 -0
- coco_core/tracers/memory_stream.py +200 -0
- coco_core/tracers/root_listeners.py +130 -0
- coco_core/tracers/run_collector.py +40 -0
- coco_core/tracers/schemas.py +14 -0
- coco_core/tracers/stdout.py +205 -0
- coco_core/utils/__init__.py +111 -0
- coco_core/utils/_gateway.py +274 -0
- coco_core/utils/_merge.py +218 -0
- coco_core/utils/aiter.py +357 -0
- coco_core/utils/env.py +86 -0
- coco_core/utils/formatting.py +80 -0
- coco_core/utils/function_calling.py +869 -0
- coco_core/utils/html.py +132 -0
- coco_core/utils/image.py +15 -0
- coco_core/utils/input.py +82 -0
- coco_core/utils/interactive_env.py +12 -0
- coco_core/utils/iter.py +229 -0
- coco_core/utils/json.py +230 -0
- coco_core/utils/json_schema.py +273 -0
- coco_core/utils/mustache.py +706 -0
- coco_core/utils/pydantic.py +631 -0
- coco_core/utils/strings.py +69 -0
- coco_core/utils/usage.py +61 -0
- coco_core/utils/utils.py +528 -0
- coco_core/utils/uuid.py +57 -0
- coco_core/vectorstores/__init__.py +53 -0
- coco_core/vectorstores/base.py +1111 -0
- coco_core/vectorstores/in_memory.py +546 -0
- coco_core/vectorstores/utils.py +163 -0
- coco_core/version.py +3 -0
- coco_framework-0.1.0.dist-info/METADATA +184 -0
- coco_framework-0.1.0.dist-info/RECORD +400 -0
- coco_framework-0.1.0.dist-info/WHEEL +4 -0
- coco_framework-0.1.0.dist-info/licenses/LICENSE +24 -0
- coco_framework-0.1.0.dist-info/licenses/NOTICE +15 -0
- coco_graph/_internal/__init__.py +4 -0
- coco_graph/_internal/_cache.py +31 -0
- coco_graph/_internal/_config.py +474 -0
- coco_graph/_internal/_constants.py +140 -0
- coco_graph/_internal/_fields.py +213 -0
- coco_graph/_internal/_future.py +220 -0
- coco_graph/_internal/_pydantic.py +275 -0
- coco_graph/_internal/_queue.py +124 -0
- coco_graph/_internal/_replay.py +90 -0
- coco_graph/_internal/_retry.py +29 -0
- coco_graph/_internal/_runnable.py +969 -0
- coco_graph/_internal/_scratchpad.py +19 -0
- coco_graph/_internal/_serde.py +253 -0
- coco_graph/_internal/_timeout.py +25 -0
- coco_graph/_internal/_typing.py +54 -0
- coco_graph/cache/base/__init__.py +48 -0
- coco_graph/cache/base/py.typed +0 -0
- coco_graph/cache/memory/__init__.py +73 -0
- coco_graph/cache/redis/__init__.py +144 -0
- coco_graph/cache/sqlite/__init__.py +120 -0
- coco_graph/callbacks.py +394 -0
- coco_graph/channels/__init__.py +29 -0
- coco_graph/channels/any_value.py +72 -0
- coco_graph/channels/base.py +121 -0
- coco_graph/channels/binop.py +155 -0
- coco_graph/channels/delta.py +202 -0
- coco_graph/channels/ephemeral_value.py +79 -0
- coco_graph/channels/last_value.py +151 -0
- coco_graph/channels/named_barrier_value.py +167 -0
- coco_graph/channels/topic.py +94 -0
- coco_graph/channels/untracked_value.py +73 -0
- coco_graph/checkpoint/base/__init__.py +861 -0
- coco_graph/checkpoint/base/id.py +109 -0
- coco_graph/checkpoint/base/py.typed +0 -0
- coco_graph/checkpoint/memory/__init__.py +698 -0
- coco_graph/checkpoint/memory/py.typed +0 -0
- coco_graph/checkpoint/postgres/__init__.py +602 -0
- coco_graph/checkpoint/postgres/_ainternal.py +23 -0
- coco_graph/checkpoint/postgres/_internal.py +21 -0
- coco_graph/checkpoint/postgres/aio.py +691 -0
- coco_graph/checkpoint/postgres/base.py +666 -0
- coco_graph/checkpoint/postgres/py.typed +0 -0
- coco_graph/checkpoint/postgres/shallow.py +967 -0
- coco_graph/checkpoint/serde/__init__.py +0 -0
- coco_graph/checkpoint/serde/_msgpack.py +97 -0
- coco_graph/checkpoint/serde/base.py +64 -0
- coco_graph/checkpoint/serde/encrypted.py +80 -0
- coco_graph/checkpoint/serde/event_hooks.py +52 -0
- coco_graph/checkpoint/serde/jsonplus.py +883 -0
- coco_graph/checkpoint/serde/py.typed +0 -0
- coco_graph/checkpoint/serde/types.py +68 -0
- coco_graph/checkpoint/sqlite/__init__.py +645 -0
- coco_graph/checkpoint/sqlite/_delta.py +172 -0
- coco_graph/checkpoint/sqlite/aio.py +738 -0
- coco_graph/checkpoint/sqlite/py.typed +0 -0
- coco_graph/checkpoint/sqlite/utils.py +116 -0
- coco_graph/config.py +196 -0
- coco_graph/constants.py +64 -0
- coco_graph/errors.py +241 -0
- coco_graph/func/__init__.py +620 -0
- coco_graph/graph/__init__.py +12 -0
- coco_graph/graph/_branch.py +225 -0
- coco_graph/graph/_node.py +103 -0
- coco_graph/graph/message.py +437 -0
- coco_graph/graph/state.py +1978 -0
- coco_graph/graph/ui.py +227 -0
- coco_graph/managed/__init__.py +3 -0
- coco_graph/managed/base.py +31 -0
- coco_graph/managed/is_last_step.py +24 -0
- coco_graph/prebuilt/__init__.py +23 -0
- coco_graph/prebuilt/_tool_call_stream.py +117 -0
- coco_graph/prebuilt/_tool_call_transformer.py +165 -0
- coco_graph/prebuilt/chat_agent_executor.py +1015 -0
- coco_graph/prebuilt/interrupt.py +105 -0
- coco_graph/prebuilt/py.typed +0 -0
- coco_graph/prebuilt/tool_node.py +2030 -0
- coco_graph/prebuilt/tool_validator.py +221 -0
- coco_graph/pregel/__init__.py +3 -0
- coco_graph/pregel/_algo.py +1460 -0
- coco_graph/pregel/_call.py +298 -0
- coco_graph/pregel/_checkpoint.py +331 -0
- coco_graph/pregel/_config.py +0 -0
- coco_graph/pregel/_draw.py +294 -0
- coco_graph/pregel/_executor.py +223 -0
- coco_graph/pregel/_io.py +174 -0
- coco_graph/pregel/_log.py +3 -0
- coco_graph/pregel/_loop.py +1988 -0
- coco_graph/pregel/_messages.py +461 -0
- coco_graph/pregel/_read.py +312 -0
- coco_graph/pregel/_retry.py +854 -0
- coco_graph/pregel/_runner.py +941 -0
- coco_graph/pregel/_tools.py +268 -0
- coco_graph/pregel/_utils.py +221 -0
- coco_graph/pregel/_validate.py +120 -0
- coco_graph/pregel/_write.py +192 -0
- coco_graph/pregel/debug.py +302 -0
- coco_graph/pregel/main.py +4364 -0
- coco_graph/pregel/protocol.py +288 -0
- coco_graph/pregel/types.py +38 -0
- coco_graph/py.typed +0 -0
- coco_graph/runtime.py +350 -0
- coco_graph/store/base/__init__.py +1322 -0
- coco_graph/store/base/batch.py +371 -0
- coco_graph/store/base/embed.py +436 -0
- coco_graph/store/base/py.typed +0 -0
- coco_graph/store/memory/__init__.py +592 -0
- coco_graph/store/memory/py.typed +0 -0
- coco_graph/store/postgres/__init__.py +4 -0
- coco_graph/store/postgres/aio.py +592 -0
- coco_graph/store/postgres/base.py +1480 -0
- coco_graph/store/postgres/py.typed +0 -0
- coco_graph/store/sqlite/__init__.py +4 -0
- coco_graph/store/sqlite/aio.py +632 -0
- coco_graph/store/sqlite/base.py +1514 -0
- coco_graph/stream/__init__.py +45 -0
- coco_graph/stream/_convert.py +32 -0
- coco_graph/stream/_mux.py +523 -0
- coco_graph/stream/_types.py +330 -0
- coco_graph/stream/run_stream.py +735 -0
- coco_graph/stream/stream_channel.py +341 -0
- coco_graph/stream/transformers.py +1039 -0
- coco_graph/types.py +1079 -0
- coco_graph/typing.py +48 -0
- coco_graph/utils/__init__.py +1 -0
- coco_graph/utils/config.py +4 -0
- coco_graph/utils/runnable.py +3 -0
- coco_graph/version.py +13 -0
- coco_graph/warnings.py +69 -0
- coco_openai/__init__.py +20 -0
- coco_openai/_compat.py +45 -0
- coco_openai/_version.py +3 -0
- coco_openai/chat_models/__init__.py +6 -0
- coco_openai/chat_models/_client_utils.py +684 -0
- coco_openai/chat_models/_compat.py +525 -0
- coco_openai/chat_models/azure.py +1227 -0
- coco_openai/chat_models/base.py +5654 -0
- coco_openai/chat_models/codex.py +557 -0
- coco_openai/chatgpt_oauth.py +1063 -0
- coco_openai/data/__init__.py +1 -0
- coco_openai/data/_profiles.py +1655 -0
- coco_openai/data/profile_augmentations.toml +127 -0
- coco_openai/embeddings/__init__.py +6 -0
- coco_openai/embeddings/azure.py +240 -0
- coco_openai/embeddings/base.py +820 -0
- coco_openai/llms/__init__.py +6 -0
- coco_openai/llms/azure.py +249 -0
- coco_openai/llms/base.py +919 -0
- coco_openai/middleware/__init__.py +11 -0
- coco_openai/middleware/openai_moderation.py +484 -0
- coco_openai/output_parsers/__init__.py +9 -0
- coco_openai/output_parsers/tools.py +9 -0
- coco_openai/py.typed +0 -0
- coco_openai/tools/__init__.py +5 -0
- coco_openai/tools/custom_tool.py +111 -0
|
@@ -0,0 +1,1063 @@
|
|
|
1
|
+
"""ChatGPT OAuth helpers for `_ChatOpenAICodex`.
|
|
2
|
+
|
|
3
|
+
Implements OAuth 2.0 Authorization Code Flow with PKCE against the OpenAI
|
|
4
|
+
auth endpoints used by Codex/ChatGPT subscription auth, plus a small file-backed
|
|
5
|
+
token store and refresh logic.
|
|
6
|
+
|
|
7
|
+
These helpers exist to keep login and token management *separate* from model
|
|
8
|
+
invocation. `_ChatOpenAICodex` only consumes a `_ChatGPTOAuthTokenProvider`.
|
|
9
|
+
|
|
10
|
+
!!! warning
|
|
11
|
+
|
|
12
|
+
This is provider-specific subscription auth and is independent from the
|
|
13
|
+
standard OpenAI API-key flow used by `ChatOpenAI`. Refresh-token rotation
|
|
14
|
+
against `~/.codex/auth.json` can break Codex CLI / VS Code sessions, so
|
|
15
|
+
the default store lives at `~/.coco/chatgpt-auth.json`.
|
|
16
|
+
|
|
17
|
+
!!! warning "Experimental and unofficial"
|
|
18
|
+
|
|
19
|
+
These helpers are not an official OpenAI API integration. Use them only
|
|
20
|
+
where your OpenAI account, workspace, plan, and applicable OpenAI terms
|
|
21
|
+
permit ChatGPT-authenticated Codex access. You are responsible for ensuring
|
|
22
|
+
your implementation complies with OpenAI's terms, usage policies, account
|
|
23
|
+
restrictions, rate limits, and safeguards.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import asyncio
|
|
29
|
+
import base64
|
|
30
|
+
import contextlib
|
|
31
|
+
import hashlib
|
|
32
|
+
import html
|
|
33
|
+
import http.server
|
|
34
|
+
import ipaddress
|
|
35
|
+
import json
|
|
36
|
+
import logging
|
|
37
|
+
import os
|
|
38
|
+
import secrets
|
|
39
|
+
import threading
|
|
40
|
+
import time
|
|
41
|
+
import urllib.parse
|
|
42
|
+
import webbrowser
|
|
43
|
+
from dataclasses import dataclass, field
|
|
44
|
+
from datetime import datetime, timedelta, timezone
|
|
45
|
+
from pathlib import Path
|
|
46
|
+
from typing import TYPE_CHECKING, Any, Literal, Protocol, runtime_checkable
|
|
47
|
+
|
|
48
|
+
import httpx
|
|
49
|
+
|
|
50
|
+
if TYPE_CHECKING:
|
|
51
|
+
from collections.abc import Iterator
|
|
52
|
+
|
|
53
|
+
logger = logging.getLogger(__name__)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
CHATGPT_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
|
57
|
+
CHATGPT_AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize"
|
|
58
|
+
CHATGPT_TOKEN_URL = "https://auth.openai.com/oauth/token" # noqa: S105
|
|
59
|
+
CHATGPT_DEVICE_CODE_URL = "https://auth.openai.com/api/accounts/deviceauth/usercode"
|
|
60
|
+
CHATGPT_DEVICE_TOKEN_URL = "https://auth.openai.com/api/accounts/deviceauth/token" # noqa: S105
|
|
61
|
+
CHATGPT_DEVICE_REDIRECT_URI = "https://auth.openai.com/deviceauth/callback"
|
|
62
|
+
CHATGPT_AUTH_CLAIMS_NAMESPACE = "https://api.openai.com/auth"
|
|
63
|
+
DEFAULT_REDIRECT_HOST = "localhost"
|
|
64
|
+
DEFAULT_REDIRECT_PORT = 1455
|
|
65
|
+
DEFAULT_REDIRECT_PATH = "/auth/callback"
|
|
66
|
+
DEFAULT_SCOPE = "openid profile email offline_access"
|
|
67
|
+
DEFAULT_REFRESH_SKEW = timedelta(minutes=5)
|
|
68
|
+
DEFAULT_STORE_PATH = Path.home() / ".coco" / "chatgpt-auth.json"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class _ChatGPTToken:
|
|
73
|
+
"""A ChatGPT OAuth token bundle.
|
|
74
|
+
|
|
75
|
+
`expires_at` is timezone-aware. The JWT-derived optionals (`account_id`,
|
|
76
|
+
`plan_type`, `user_id`) are populated when decodable from the `id_token`;
|
|
77
|
+
`id_token` itself is the raw token, not derived from it. Secret-bearing
|
|
78
|
+
fields (`access_token`, `refresh_token`, `id_token`) are excluded from the
|
|
79
|
+
default `repr` so the token does not leak into logs or tracebacks.
|
|
80
|
+
|
|
81
|
+
Instances are frozen: the constructor invariants below hold for the life of
|
|
82
|
+
the object, which matters because providers cache and share a single token
|
|
83
|
+
and replace it wholesale on refresh rather than mutating fields in place.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
access_token: str = field(repr=False)
|
|
87
|
+
refresh_token: str = field(repr=False)
|
|
88
|
+
expires_at: datetime
|
|
89
|
+
account_id: str | None = None
|
|
90
|
+
plan_type: str | None = None
|
|
91
|
+
user_id: str | None = None
|
|
92
|
+
id_token: str | None = field(default=None, repr=False)
|
|
93
|
+
|
|
94
|
+
def __post_init__(self) -> None:
|
|
95
|
+
"""Validate non-empty secrets and timezone-aware `expires_at`."""
|
|
96
|
+
if not self.access_token:
|
|
97
|
+
msg = "`access_token` must be a non-empty string."
|
|
98
|
+
raise ValueError(msg)
|
|
99
|
+
if not self.refresh_token:
|
|
100
|
+
msg = "`refresh_token` must be a non-empty string."
|
|
101
|
+
raise ValueError(msg)
|
|
102
|
+
if self.expires_at.tzinfo is None:
|
|
103
|
+
msg = "`expires_at` must be timezone-aware (UTC)."
|
|
104
|
+
raise ValueError(msg)
|
|
105
|
+
|
|
106
|
+
def is_expired(self, *, skew: timedelta = DEFAULT_REFRESH_SKEW) -> bool:
|
|
107
|
+
"""Return `True` if the token is past (or within `skew` of) expiry."""
|
|
108
|
+
return datetime.now(timezone.utc) >= (self.expires_at - skew)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class _ChatGPTOAuthRefreshError(RuntimeError):
|
|
112
|
+
"""Raised when a refresh-token grant fails irrecoverably.
|
|
113
|
+
|
|
114
|
+
Typically signals that the stored refresh token has been revoked or has
|
|
115
|
+
expired; the caller should re-run `login_chatgpt()` (or the device-code
|
|
116
|
+
equivalent) to obtain a new bundle.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@runtime_checkable
|
|
121
|
+
class _ChatGPTOAuthTokenProvider(Protocol): # noqa: PYI046
|
|
122
|
+
"""Refresh-aware token source consumed by `_ChatOpenAICodex`."""
|
|
123
|
+
|
|
124
|
+
def get_token(self) -> _ChatGPTToken:
|
|
125
|
+
"""Return a current token, refreshing if necessary."""
|
|
126
|
+
...
|
|
127
|
+
|
|
128
|
+
async def aget_token(self) -> _ChatGPTToken:
|
|
129
|
+
"""Async variant of `get_token`.
|
|
130
|
+
|
|
131
|
+
Implementations must offer the same locking and refresh guarantees
|
|
132
|
+
as `get_token`: concurrent callers must not race on token storage.
|
|
133
|
+
"""
|
|
134
|
+
...
|
|
135
|
+
|
|
136
|
+
def get_access_token(self) -> str:
|
|
137
|
+
"""Return only the access token string (sync callable for SDKs)."""
|
|
138
|
+
...
|
|
139
|
+
|
|
140
|
+
async def aget_access_token(self) -> str:
|
|
141
|
+
"""Return only the access token string (async callable for SDKs)."""
|
|
142
|
+
...
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _b64url_decode_segment(segment: str) -> bytes:
|
|
146
|
+
"""Decode a single base64url JWT segment, handling missing padding."""
|
|
147
|
+
padding = "=" * (-len(segment) % 4)
|
|
148
|
+
return base64.urlsafe_b64decode(segment + padding)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def decode_jwt_claims(token: str) -> dict[str, Any]:
|
|
152
|
+
"""Decode a JWT's payload without signature verification.
|
|
153
|
+
|
|
154
|
+
!!! danger
|
|
155
|
+
This is for *local claim extraction only*. Never use the returned
|
|
156
|
+
claims for security or authorization decisions.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
token: A JWT (`header.payload.signature`).
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
Decoded payload as a dict. Returns an empty dict if the token is
|
|
163
|
+
malformed.
|
|
164
|
+
"""
|
|
165
|
+
if not token or token.count(".") < 2:
|
|
166
|
+
return {}
|
|
167
|
+
try:
|
|
168
|
+
_, payload, _ = token.split(".", 2)
|
|
169
|
+
return json.loads(_b64url_decode_segment(payload))
|
|
170
|
+
except (ValueError, json.JSONDecodeError, UnicodeDecodeError):
|
|
171
|
+
return {}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _extract_chatgpt_claims(id_token: str | None) -> dict[str, str | None]:
|
|
175
|
+
"""Pull the ChatGPT account/plan/user IDs out of an ID-token JWT."""
|
|
176
|
+
out: dict[str, str | None] = {
|
|
177
|
+
"account_id": None,
|
|
178
|
+
"plan_type": None,
|
|
179
|
+
"user_id": None,
|
|
180
|
+
}
|
|
181
|
+
if not id_token:
|
|
182
|
+
return out
|
|
183
|
+
claims = decode_jwt_claims(id_token)
|
|
184
|
+
auth = claims.get(CHATGPT_AUTH_CLAIMS_NAMESPACE) or {}
|
|
185
|
+
if isinstance(auth, dict):
|
|
186
|
+
out["account_id"] = auth.get("chatgpt_account_id")
|
|
187
|
+
out["plan_type"] = auth.get("chatgpt_plan_type")
|
|
188
|
+
out["user_id"] = auth.get("chatgpt_user_id")
|
|
189
|
+
if out["account_id"] is None:
|
|
190
|
+
# A present-but-unparseable id_token (or one missing the namespaced
|
|
191
|
+
# auth claim) silently drops the `ChatGPT-Account-Id` header, which
|
|
192
|
+
# surfaces later as an opaque backend rejection. Leave a breadcrumb.
|
|
193
|
+
logger.debug(
|
|
194
|
+
"No `chatgpt_account_id` claim extracted from the ChatGPT "
|
|
195
|
+
"id_token; the `ChatGPT-Account-Id` header will be omitted."
|
|
196
|
+
)
|
|
197
|
+
return out
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _expires_at_from_response(payload: dict[str, Any]) -> datetime:
|
|
201
|
+
raw = payload.get("expires_in")
|
|
202
|
+
try:
|
|
203
|
+
expires_in = int(raw) if raw is not None else 0
|
|
204
|
+
except (TypeError, ValueError) as exc:
|
|
205
|
+
msg = f"OAuth token response had invalid `expires_in`: {raw!r}"
|
|
206
|
+
raise _ChatGPTOAuthRefreshError(msg) from exc
|
|
207
|
+
if expires_in <= 0:
|
|
208
|
+
msg = (
|
|
209
|
+
"OAuth token response had missing or non-positive `expires_in`; "
|
|
210
|
+
"refusing to store an immediately-expired token."
|
|
211
|
+
)
|
|
212
|
+
raise _ChatGPTOAuthRefreshError(msg)
|
|
213
|
+
return datetime.now(timezone.utc) + timedelta(seconds=expires_in)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _token_from_response(
|
|
217
|
+
payload: dict[str, Any],
|
|
218
|
+
*,
|
|
219
|
+
fallback_refresh_token: str | None = None,
|
|
220
|
+
) -> _ChatGPTToken:
|
|
221
|
+
"""Build a `_ChatGPTToken` from an OAuth token-endpoint response."""
|
|
222
|
+
if not payload.get("access_token"):
|
|
223
|
+
msg = "OAuth token response did not include an `access_token`."
|
|
224
|
+
raise _ChatGPTOAuthRefreshError(msg)
|
|
225
|
+
id_token = payload.get("id_token")
|
|
226
|
+
claims = _extract_chatgpt_claims(id_token)
|
|
227
|
+
refresh_token = payload.get("refresh_token") or fallback_refresh_token
|
|
228
|
+
if not refresh_token:
|
|
229
|
+
msg = (
|
|
230
|
+
"OAuth token response did not include a `refresh_token` and no "
|
|
231
|
+
"prior refresh token was available; re-run `login_chatgpt()`."
|
|
232
|
+
)
|
|
233
|
+
raise _ChatGPTOAuthRefreshError(msg)
|
|
234
|
+
return _ChatGPTToken(
|
|
235
|
+
access_token=payload["access_token"],
|
|
236
|
+
refresh_token=refresh_token,
|
|
237
|
+
expires_at=_expires_at_from_response(payload),
|
|
238
|
+
account_id=claims["account_id"],
|
|
239
|
+
plan_type=claims["plan_type"],
|
|
240
|
+
user_id=claims["user_id"],
|
|
241
|
+
id_token=id_token,
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _serialize_token(token: _ChatGPTToken) -> dict[str, Any]:
|
|
246
|
+
return {
|
|
247
|
+
"access_token": token.access_token,
|
|
248
|
+
"refresh_token": token.refresh_token,
|
|
249
|
+
"expires_at": token.expires_at.astimezone(timezone.utc).isoformat(),
|
|
250
|
+
"account_id": token.account_id,
|
|
251
|
+
"plan_type": token.plan_type,
|
|
252
|
+
"user_id": token.user_id,
|
|
253
|
+
"id_token": token.id_token,
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _deserialize_token(data: dict[str, Any]) -> _ChatGPTToken:
|
|
258
|
+
expires_at_raw = data.get("expires_at")
|
|
259
|
+
if isinstance(expires_at_raw, str):
|
|
260
|
+
expires_at = datetime.fromisoformat(expires_at_raw)
|
|
261
|
+
if expires_at.tzinfo is None:
|
|
262
|
+
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
|
263
|
+
elif isinstance(expires_at_raw, (int, float)):
|
|
264
|
+
expires_at = datetime.fromtimestamp(expires_at_raw, tz=timezone.utc)
|
|
265
|
+
else:
|
|
266
|
+
msg = "Stored token is missing `expires_at`."
|
|
267
|
+
raise ValueError(msg)
|
|
268
|
+
return _ChatGPTToken(
|
|
269
|
+
access_token=data["access_token"],
|
|
270
|
+
refresh_token=data["refresh_token"],
|
|
271
|
+
expires_at=expires_at,
|
|
272
|
+
account_id=data.get("account_id"),
|
|
273
|
+
plan_type=data.get("plan_type"),
|
|
274
|
+
user_id=data.get("user_id"),
|
|
275
|
+
id_token=data.get("id_token"),
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _chmod_warn(path: Path, mode: int) -> None:
|
|
280
|
+
"""Best-effort `chmod` that logs (but does not raise) on failure.
|
|
281
|
+
|
|
282
|
+
On filesystems without POSIX perms (Windows, some FUSE/SMB mounts) the
|
|
283
|
+
file may end up world-readable. Logging surfaces that to operators so
|
|
284
|
+
they don't silently trust the "private perms" claim of the caller.
|
|
285
|
+
"""
|
|
286
|
+
try:
|
|
287
|
+
os.chmod(path, mode) # noqa: PTH101
|
|
288
|
+
except (OSError, NotImplementedError) as exc:
|
|
289
|
+
logger.warning(
|
|
290
|
+
"Failed to set permissions %o on %s: %s — token store may not "
|
|
291
|
+
"have private permissions on this filesystem.",
|
|
292
|
+
mode,
|
|
293
|
+
path,
|
|
294
|
+
exc,
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _atomic_write_private_json(path: Path, data: dict[str, Any]) -> None:
|
|
299
|
+
"""Write `data` as JSON to `path` with 0600 perms (where supported)."""
|
|
300
|
+
parent = path.parent
|
|
301
|
+
parent.mkdir(parents=True, exist_ok=True)
|
|
302
|
+
_chmod_warn(parent, 0o700)
|
|
303
|
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
304
|
+
payload = json.dumps(data, indent=2, sort_keys=True)
|
|
305
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
|
|
306
|
+
fd = os.open(tmp, flags, 0o600)
|
|
307
|
+
try:
|
|
308
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
309
|
+
fh.write(payload)
|
|
310
|
+
except Exception:
|
|
311
|
+
with contextlib.suppress(OSError):
|
|
312
|
+
tmp.unlink()
|
|
313
|
+
raise
|
|
314
|
+
tmp.replace(path)
|
|
315
|
+
_chmod_warn(path, 0o600)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
@contextlib.contextmanager
|
|
319
|
+
def _file_lock(path: Path) -> Iterator[None]:
|
|
320
|
+
"""Best-effort cross-platform file lock around refresh + write.
|
|
321
|
+
|
|
322
|
+
On POSIX this acquires an exclusive `fcntl.flock` on a sibling
|
|
323
|
+
`.lock` file. On Windows (or any platform where `fcntl` is
|
|
324
|
+
unavailable) the lock degrades to a no-op and a warning is logged so
|
|
325
|
+
callers know that cross-process safety is best-effort.
|
|
326
|
+
"""
|
|
327
|
+
lock_path = path.with_suffix(path.suffix + ".lock")
|
|
328
|
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
329
|
+
fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o600)
|
|
330
|
+
locked = False
|
|
331
|
+
try:
|
|
332
|
+
try:
|
|
333
|
+
import fcntl
|
|
334
|
+
except ImportError:
|
|
335
|
+
logger.warning(
|
|
336
|
+
"fcntl is unavailable on this platform; ChatGPT token store "
|
|
337
|
+
"at %s is not protected against cross-process races.",
|
|
338
|
+
path,
|
|
339
|
+
)
|
|
340
|
+
else:
|
|
341
|
+
try:
|
|
342
|
+
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
343
|
+
locked = True
|
|
344
|
+
except OSError as exc:
|
|
345
|
+
logger.warning(
|
|
346
|
+
"fcntl.flock failed on %s: %s — token store is not "
|
|
347
|
+
"protected against cross-process races.",
|
|
348
|
+
lock_path,
|
|
349
|
+
exc,
|
|
350
|
+
)
|
|
351
|
+
yield
|
|
352
|
+
finally:
|
|
353
|
+
if locked:
|
|
354
|
+
try:
|
|
355
|
+
import fcntl
|
|
356
|
+
|
|
357
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
358
|
+
except (ImportError, OSError) as exc:
|
|
359
|
+
logger.warning("Failed to release file lock on %s: %s", lock_path, exc)
|
|
360
|
+
os.close(fd)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _redact(value: str | None) -> str:
|
|
364
|
+
if not value:
|
|
365
|
+
return "<empty>"
|
|
366
|
+
return f"<redacted len={len(value)}>"
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _parse_oauth_error(resp: httpx.Response) -> tuple[str | None, str]:
|
|
370
|
+
"""Return `(error_code, body_excerpt)` from an OAuth error response."""
|
|
371
|
+
try:
|
|
372
|
+
payload = resp.json()
|
|
373
|
+
except (ValueError, json.JSONDecodeError):
|
|
374
|
+
return None, resp.text[:500]
|
|
375
|
+
if isinstance(payload, dict):
|
|
376
|
+
error = payload.get("error")
|
|
377
|
+
description = payload.get("error_description") or ""
|
|
378
|
+
excerpt = f"{error}: {description}".strip(": ") or resp.text[:500]
|
|
379
|
+
return (error if isinstance(error, str) else None), excerpt
|
|
380
|
+
return None, resp.text[:500]
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _raise_for_oauth_response(url: str, resp: httpx.Response) -> None:
|
|
384
|
+
if resp.status_code < 400:
|
|
385
|
+
return
|
|
386
|
+
error_code, excerpt = _parse_oauth_error(resp)
|
|
387
|
+
if error_code == "invalid_grant":
|
|
388
|
+
msg = (
|
|
389
|
+
"ChatGPT refresh token is no longer valid (`invalid_grant`). "
|
|
390
|
+
"Re-run `login_chatgpt()` to obtain a new token."
|
|
391
|
+
)
|
|
392
|
+
raise _ChatGPTOAuthRefreshError(msg)
|
|
393
|
+
msg = f"OAuth request to {url} failed with status {resp.status_code}: {excerpt}"
|
|
394
|
+
raise RuntimeError(msg)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _post_form(
|
|
398
|
+
url: str,
|
|
399
|
+
data: dict[str, str],
|
|
400
|
+
*,
|
|
401
|
+
timeout: float = 30.0,
|
|
402
|
+
) -> dict[str, Any]:
|
|
403
|
+
"""POST a form payload and return the parsed JSON body."""
|
|
404
|
+
with httpx.Client(timeout=timeout) as client:
|
|
405
|
+
resp = client.post(
|
|
406
|
+
url,
|
|
407
|
+
data=data,
|
|
408
|
+
headers={"Accept": "application/json"},
|
|
409
|
+
)
|
|
410
|
+
_raise_for_oauth_response(url, resp)
|
|
411
|
+
return resp.json()
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
_DEVICE_POLL_PENDING_ERRORS = frozenset({"authorization_pending", "slow_down"})
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _post_device_poll_form(
|
|
418
|
+
url: str,
|
|
419
|
+
data: dict[str, str],
|
|
420
|
+
*,
|
|
421
|
+
timeout: float = 30.0,
|
|
422
|
+
) -> dict[str, Any]:
|
|
423
|
+
"""POST a device-code poll and return expected pending error payloads."""
|
|
424
|
+
with httpx.Client(timeout=timeout) as client:
|
|
425
|
+
resp = client.post(
|
|
426
|
+
url,
|
|
427
|
+
data=data,
|
|
428
|
+
headers={"Accept": "application/json"},
|
|
429
|
+
)
|
|
430
|
+
if resp.status_code < 400:
|
|
431
|
+
return resp.json()
|
|
432
|
+
error_code, _ = _parse_oauth_error(resp)
|
|
433
|
+
if error_code in _DEVICE_POLL_PENDING_ERRORS:
|
|
434
|
+
return resp.json()
|
|
435
|
+
_raise_for_oauth_response(url, resp)
|
|
436
|
+
return resp.json()
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
async def _apost_form(
|
|
440
|
+
url: str,
|
|
441
|
+
data: dict[str, str],
|
|
442
|
+
*,
|
|
443
|
+
timeout: float = 30.0,
|
|
444
|
+
) -> dict[str, Any]:
|
|
445
|
+
"""POST a form payload asynchronously and return the parsed JSON body."""
|
|
446
|
+
async with httpx.AsyncClient(timeout=timeout) as client:
|
|
447
|
+
resp = await client.post(
|
|
448
|
+
url,
|
|
449
|
+
data=data,
|
|
450
|
+
headers={"Accept": "application/json"},
|
|
451
|
+
)
|
|
452
|
+
_raise_for_oauth_response(url, resp)
|
|
453
|
+
return resp.json()
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
@dataclass
|
|
457
|
+
class _FileChatGPTOAuthTokenProvider:
|
|
458
|
+
"""File-backed `_ChatGPTOAuthTokenProvider`.
|
|
459
|
+
|
|
460
|
+
Stores tokens at `path` (defaults to `DEFAULT_STORE_PATH`) with private
|
|
461
|
+
permissions and refreshes them on read when they are within
|
|
462
|
+
`refresh_skew` of expiry. Refresh token rotation is preserved across
|
|
463
|
+
writes: if the OAuth response omits `refresh_token`, the existing one is
|
|
464
|
+
reused.
|
|
465
|
+
|
|
466
|
+
!!! warning
|
|
467
|
+
The default path is intentionally distinct from `~/.codex/auth.json`
|
|
468
|
+
so that refresh-token rotation here does not invalidate Codex CLI /
|
|
469
|
+
VS Code sessions.
|
|
470
|
+
"""
|
|
471
|
+
|
|
472
|
+
path: Path = field(default_factory=lambda: DEFAULT_STORE_PATH)
|
|
473
|
+
client_id: str = CHATGPT_CLIENT_ID
|
|
474
|
+
token_url: str = CHATGPT_TOKEN_URL
|
|
475
|
+
refresh_skew: timedelta = DEFAULT_REFRESH_SKEW
|
|
476
|
+
timeout: float = 30.0
|
|
477
|
+
_cached: _ChatGPTToken | None = field(default=None, init=False, repr=False)
|
|
478
|
+
_lock: threading.Lock = field(
|
|
479
|
+
default_factory=threading.Lock, init=False, repr=False
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
@classmethod
|
|
483
|
+
def from_default_store(cls) -> _FileChatGPTOAuthTokenProvider:
|
|
484
|
+
"""Construct a provider with all defaults (path, client ID, etc.).
|
|
485
|
+
|
|
486
|
+
Equivalent to `_FileChatGPTOAuthTokenProvider()`; the alias exists as
|
|
487
|
+
a discoverable entry point for callers reading the default-path
|
|
488
|
+
contract from the module docstring.
|
|
489
|
+
"""
|
|
490
|
+
return cls()
|
|
491
|
+
|
|
492
|
+
def _read_from_disk(self) -> _ChatGPTToken | None:
|
|
493
|
+
"""Return the stored token, or `None` if no store exists.
|
|
494
|
+
|
|
495
|
+
Raises `RuntimeError` (rather than returning `None`) if the file
|
|
496
|
+
exists but cannot be parsed — that way the user is not told to
|
|
497
|
+
"re-login" when the actual fix is to repair or remove a corrupt
|
|
498
|
+
store at `self.path`.
|
|
499
|
+
"""
|
|
500
|
+
if not self.path.exists():
|
|
501
|
+
return None
|
|
502
|
+
try:
|
|
503
|
+
raw_text = self.path.read_text(encoding="utf-8")
|
|
504
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
505
|
+
msg = (
|
|
506
|
+
f"Failed to read ChatGPT token store at {self.path}: {exc}. "
|
|
507
|
+
"Repair file permissions/encoding or delete the file and "
|
|
508
|
+
"re-run `login_chatgpt()`."
|
|
509
|
+
)
|
|
510
|
+
raise RuntimeError(msg) from exc
|
|
511
|
+
try:
|
|
512
|
+
data = json.loads(raw_text)
|
|
513
|
+
except json.JSONDecodeError as exc:
|
|
514
|
+
msg = (
|
|
515
|
+
f"ChatGPT token store at {self.path} is not valid JSON: "
|
|
516
|
+
f"{exc}. Delete the file and re-run `login_chatgpt()`."
|
|
517
|
+
)
|
|
518
|
+
raise RuntimeError(msg) from exc
|
|
519
|
+
try:
|
|
520
|
+
return _deserialize_token(data)
|
|
521
|
+
except (KeyError, ValueError) as exc:
|
|
522
|
+
msg = (
|
|
523
|
+
f"ChatGPT token store at {self.path} is missing required "
|
|
524
|
+
f"fields ({exc}). Delete the file and re-run "
|
|
525
|
+
"`login_chatgpt()`."
|
|
526
|
+
)
|
|
527
|
+
raise RuntimeError(msg) from exc
|
|
528
|
+
|
|
529
|
+
def _write_to_disk(self, token: _ChatGPTToken) -> None:
|
|
530
|
+
_atomic_write_private_json(self.path, _serialize_token(token))
|
|
531
|
+
|
|
532
|
+
def save(self, token: _ChatGPTToken) -> None:
|
|
533
|
+
"""Persist `token` to disk and cache it in memory."""
|
|
534
|
+
with self._lock, _file_lock(self.path):
|
|
535
|
+
self._write_to_disk(token)
|
|
536
|
+
self._cached = token
|
|
537
|
+
|
|
538
|
+
def _build_refresh_payload(self, refresh_token: str) -> dict[str, str]:
|
|
539
|
+
return {
|
|
540
|
+
"grant_type": "refresh_token",
|
|
541
|
+
"refresh_token": refresh_token,
|
|
542
|
+
"client_id": self.client_id,
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
def _apply_refresh_response(
|
|
546
|
+
self, response: dict[str, Any], previous_refresh: str
|
|
547
|
+
) -> _ChatGPTToken:
|
|
548
|
+
token = _token_from_response(response, fallback_refresh_token=previous_refresh)
|
|
549
|
+
self._write_to_disk(token)
|
|
550
|
+
self._cached = token
|
|
551
|
+
return token
|
|
552
|
+
|
|
553
|
+
def _refresh_sync(self, existing: _ChatGPTToken) -> _ChatGPTToken:
|
|
554
|
+
logger.debug(
|
|
555
|
+
"Refreshing ChatGPT access token (refresh_token=%s).",
|
|
556
|
+
_redact(existing.refresh_token),
|
|
557
|
+
)
|
|
558
|
+
response = _post_form(
|
|
559
|
+
self.token_url,
|
|
560
|
+
self._build_refresh_payload(existing.refresh_token),
|
|
561
|
+
timeout=self.timeout,
|
|
562
|
+
)
|
|
563
|
+
return self._apply_refresh_response(response, existing.refresh_token)
|
|
564
|
+
|
|
565
|
+
def _load_existing(self) -> _ChatGPTToken:
|
|
566
|
+
existing = self._cached or self._read_from_disk()
|
|
567
|
+
if existing is None:
|
|
568
|
+
msg = (
|
|
569
|
+
f"No ChatGPT OAuth token found at {self.path}. Run "
|
|
570
|
+
"`coco_openai.chatgpt_oauth.login_chatgpt()` first."
|
|
571
|
+
)
|
|
572
|
+
raise FileNotFoundError(msg)
|
|
573
|
+
return existing
|
|
574
|
+
|
|
575
|
+
def _load_existing_before_refresh(self) -> _ChatGPTToken:
|
|
576
|
+
existing = self._load_existing()
|
|
577
|
+
if not existing.is_expired(skew=self.refresh_skew):
|
|
578
|
+
return existing
|
|
579
|
+
disk_token = self._read_from_disk()
|
|
580
|
+
if disk_token is not None:
|
|
581
|
+
self._cached = disk_token
|
|
582
|
+
return disk_token
|
|
583
|
+
return existing
|
|
584
|
+
|
|
585
|
+
def get_token(self) -> _ChatGPTToken:
|
|
586
|
+
"""Return a fresh token, refreshing on disk if needed.
|
|
587
|
+
|
|
588
|
+
Raises:
|
|
589
|
+
FileNotFoundError: No token store exists at `self.path`; run
|
|
590
|
+
`login_chatgpt()` first.
|
|
591
|
+
_ChatGPTOAuthRefreshError: The stored refresh token was rejected
|
|
592
|
+
(e.g. revoked or expired); re-run `login_chatgpt()`.
|
|
593
|
+
"""
|
|
594
|
+
with self._lock, _file_lock(self.path):
|
|
595
|
+
existing = self._load_existing_before_refresh()
|
|
596
|
+
if not existing.is_expired(skew=self.refresh_skew):
|
|
597
|
+
self._cached = existing
|
|
598
|
+
return existing
|
|
599
|
+
return self._refresh_sync(existing)
|
|
600
|
+
|
|
601
|
+
async def aget_token(self) -> _ChatGPTToken:
|
|
602
|
+
"""Async variant of `get_token` with the same locking guarantees.
|
|
603
|
+
|
|
604
|
+
The thread lock and cross-process file lock are acquired off the
|
|
605
|
+
event loop via `asyncio.to_thread` so concurrent async callers do
|
|
606
|
+
not race on `_cached` or on the on-disk token bundle. The HTTP
|
|
607
|
+
refresh runs synchronously inside that worker thread; this avoids
|
|
608
|
+
nesting event loops while still keeping the cross-process lock
|
|
609
|
+
held for the entire refresh + write window.
|
|
610
|
+
|
|
611
|
+
Raises:
|
|
612
|
+
FileNotFoundError: No token store exists at `self.path`; run
|
|
613
|
+
`login_chatgpt()` first.
|
|
614
|
+
_ChatGPTOAuthRefreshError: The stored refresh token was rejected
|
|
615
|
+
(e.g. revoked or expired); re-run `login_chatgpt()`.
|
|
616
|
+
"""
|
|
617
|
+
return await asyncio.to_thread(self._aget_token_locked_blocking)
|
|
618
|
+
|
|
619
|
+
def _aget_token_locked_blocking(self) -> _ChatGPTToken:
|
|
620
|
+
with self._lock, _file_lock(self.path):
|
|
621
|
+
existing = self._load_existing_before_refresh()
|
|
622
|
+
if not existing.is_expired(skew=self.refresh_skew):
|
|
623
|
+
self._cached = existing
|
|
624
|
+
return existing
|
|
625
|
+
return self._refresh_sync(existing)
|
|
626
|
+
|
|
627
|
+
def get_access_token(self) -> str:
|
|
628
|
+
"""Return only the access-token string."""
|
|
629
|
+
return self.get_token().access_token
|
|
630
|
+
|
|
631
|
+
async def aget_access_token(self) -> str:
|
|
632
|
+
"""Return only the access-token string (async)."""
|
|
633
|
+
token = await self.aget_token()
|
|
634
|
+
return token.access_token
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def _generate_pkce_pair() -> tuple[str, str]:
|
|
638
|
+
"""Return a `(code_verifier, code_challenge)` pair using S256."""
|
|
639
|
+
verifier = (
|
|
640
|
+
base64.urlsafe_b64encode(secrets.token_bytes(64)).rstrip(b"=").decode("ascii")
|
|
641
|
+
)
|
|
642
|
+
digest = hashlib.sha256(verifier.encode("ascii")).digest()
|
|
643
|
+
challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
|
|
644
|
+
return verifier, challenge
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def _build_authorize_url(
|
|
648
|
+
*,
|
|
649
|
+
client_id: str,
|
|
650
|
+
redirect_uri: str,
|
|
651
|
+
state: str,
|
|
652
|
+
code_challenge: str,
|
|
653
|
+
scope: str = DEFAULT_SCOPE,
|
|
654
|
+
extra_params: dict[str, str] | None = None,
|
|
655
|
+
) -> str:
|
|
656
|
+
params = {
|
|
657
|
+
"client_id": client_id,
|
|
658
|
+
"response_type": "code",
|
|
659
|
+
"redirect_uri": redirect_uri,
|
|
660
|
+
"scope": scope,
|
|
661
|
+
"code_challenge": code_challenge,
|
|
662
|
+
"code_challenge_method": "S256",
|
|
663
|
+
"state": state,
|
|
664
|
+
}
|
|
665
|
+
if extra_params:
|
|
666
|
+
params.update(extra_params)
|
|
667
|
+
return f"{CHATGPT_AUTHORIZE_URL}?{urllib.parse.urlencode(params)}"
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
class _CallbackHandler(http.server.BaseHTTPRequestHandler):
|
|
671
|
+
server_result: dict[str, str] = {}
|
|
672
|
+
callback_path: str = DEFAULT_REDIRECT_PATH
|
|
673
|
+
|
|
674
|
+
def do_GET(self) -> None:
|
|
675
|
+
parsed = urllib.parse.urlparse(self.path)
|
|
676
|
+
if parsed.path != self.callback_path:
|
|
677
|
+
# Surface path mismatches: otherwise a misconfigured
|
|
678
|
+
# `callback_path` looks identical to "still waiting" and only
|
|
679
|
+
# ends in a generic timeout. (Path only — never the query, which
|
|
680
|
+
# carries the auth code.)
|
|
681
|
+
logger.debug(
|
|
682
|
+
"Ignoring callback request for unexpected path %r (expected %r).",
|
|
683
|
+
parsed.path,
|
|
684
|
+
self.callback_path,
|
|
685
|
+
)
|
|
686
|
+
self.send_response(404)
|
|
687
|
+
self.end_headers()
|
|
688
|
+
return
|
|
689
|
+
query = urllib.parse.parse_qs(parsed.query)
|
|
690
|
+
for key in ("code", "state", "error", "error_description"):
|
|
691
|
+
value = query.get(key)
|
|
692
|
+
if value:
|
|
693
|
+
self.server_result[key] = value[0]
|
|
694
|
+
self.send_response(200)
|
|
695
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
696
|
+
self.end_headers()
|
|
697
|
+
error = self.server_result.get("error")
|
|
698
|
+
if error:
|
|
699
|
+
error_description = self.server_result.get("error_description")
|
|
700
|
+
logger.error(
|
|
701
|
+
"ChatGPT OAuth callback returned error %r (%s)",
|
|
702
|
+
error,
|
|
703
|
+
error_description or "no description",
|
|
704
|
+
)
|
|
705
|
+
if error_description:
|
|
706
|
+
description = f"{error_description} (error: {error})"
|
|
707
|
+
else:
|
|
708
|
+
description = (
|
|
709
|
+
f"ChatGPT returned error '{error}'. Close this tab and "
|
|
710
|
+
"try `login_chatgpt()` again."
|
|
711
|
+
)
|
|
712
|
+
body = _oauth_error_html(description)
|
|
713
|
+
else:
|
|
714
|
+
body = _oauth_success_html(
|
|
715
|
+
"ChatGPT sign-in complete. You can close this browser tab "
|
|
716
|
+
"and return to your terminal.",
|
|
717
|
+
)
|
|
718
|
+
self.wfile.write(body.encode("utf-8"))
|
|
719
|
+
|
|
720
|
+
def log_message(self, format: str, *args: Any) -> None: # noqa: A002
|
|
721
|
+
# Don't leak callback URLs (which contain auth codes) into stderr.
|
|
722
|
+
return
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def _oauth_success_html(message: str) -> str:
|
|
726
|
+
return _oauth_result_html(
|
|
727
|
+
title="ChatGPT sign-in complete",
|
|
728
|
+
heading="You're signed in",
|
|
729
|
+
message=message,
|
|
730
|
+
status="success",
|
|
731
|
+
)
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def _oauth_error_html(message: str) -> str:
|
|
735
|
+
return _oauth_result_html(
|
|
736
|
+
title="ChatGPT sign-in failed",
|
|
737
|
+
heading="Sign-in failed",
|
|
738
|
+
message=message,
|
|
739
|
+
status="error",
|
|
740
|
+
)
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def _oauth_result_html(
|
|
744
|
+
*,
|
|
745
|
+
title: str,
|
|
746
|
+
heading: str,
|
|
747
|
+
message: str,
|
|
748
|
+
status: Literal["success", "error"],
|
|
749
|
+
) -> str:
|
|
750
|
+
accent = "#137333" if status == "success" else "#b3261e"
|
|
751
|
+
background = "#eef7f0" if status == "success" else "#fceeee"
|
|
752
|
+
mark = "✓" if status == "success" else "!"
|
|
753
|
+
escaped_title = html.escape(title)
|
|
754
|
+
escaped_heading = html.escape(heading)
|
|
755
|
+
escaped_message = html.escape(message)
|
|
756
|
+
return (
|
|
757
|
+
'<!doctype html><html lang="en"><head><meta charset="utf-8">'
|
|
758
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1">'
|
|
759
|
+
f"<title>{escaped_title}</title>"
|
|
760
|
+
"<style>"
|
|
761
|
+
"body{margin:0;min-height:100vh;display:grid;place-items:center;"
|
|
762
|
+
"font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;"
|
|
763
|
+
"background:#f8faf9;color:#1f2328}"
|
|
764
|
+
".panel{width:min(480px,calc(100vw - 40px));box-sizing:border-box;"
|
|
765
|
+
"padding:32px;border:1px solid #d8dee4;border-radius:8px;"
|
|
766
|
+
"background:#fff;box-shadow:0 18px 45px rgba(31,35,40,.08)}"
|
|
767
|
+
".mark{width:44px;height:44px;border-radius:50%;display:grid;"
|
|
768
|
+
"place-items:center;margin-bottom:20px;font-weight:700;font-size:22px}"
|
|
769
|
+
"h1{font-size:24px;line-height:1.2;margin:0 0 10px}"
|
|
770
|
+
"p{font-size:15px;line-height:1.5;margin:0;color:#57606a}"
|
|
771
|
+
"@media (prefers-color-scheme: dark){"
|
|
772
|
+
"body{background:#0d1117;color:#e6edf3}"
|
|
773
|
+
".panel{background:#161b22;border-color:#30363d;"
|
|
774
|
+
"box-shadow:0 18px 45px rgba(0,0,0,.4)}"
|
|
775
|
+
"p{color:#9da7b3}}"
|
|
776
|
+
"</style></head><body>"
|
|
777
|
+
'<main class="panel">'
|
|
778
|
+
f'<div class="mark" style="background:{background};color:{accent}">'
|
|
779
|
+
f"{mark}</div>"
|
|
780
|
+
f"<h1>{escaped_heading}</h1><p>{escaped_message}</p>"
|
|
781
|
+
"</main>"
|
|
782
|
+
"</body></html>"
|
|
783
|
+
)
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
def _wait_for_callback(
|
|
787
|
+
*,
|
|
788
|
+
host: str,
|
|
789
|
+
port: int,
|
|
790
|
+
callback_path: str,
|
|
791
|
+
timeout: float,
|
|
792
|
+
) -> dict[str, str]:
|
|
793
|
+
class _BoundCallbackHandler(_CallbackHandler):
|
|
794
|
+
server_result: dict[str, str] = {}
|
|
795
|
+
|
|
796
|
+
_BoundCallbackHandler.callback_path = callback_path
|
|
797
|
+
try:
|
|
798
|
+
server = http.server.HTTPServer((host, port), _BoundCallbackHandler)
|
|
799
|
+
except OSError as exc:
|
|
800
|
+
msg = (
|
|
801
|
+
f"Could not bind ChatGPT OAuth callback server on "
|
|
802
|
+
f"http://{host}:{port}: {exc}. Free the port or pass `port=` "
|
|
803
|
+
"to `login_chatgpt()` with an unused port."
|
|
804
|
+
)
|
|
805
|
+
raise RuntimeError(msg) from exc
|
|
806
|
+
server.timeout = 1.0
|
|
807
|
+
deadline = time.monotonic() + timeout
|
|
808
|
+
try:
|
|
809
|
+
while time.monotonic() < deadline:
|
|
810
|
+
server.handle_request()
|
|
811
|
+
if _BoundCallbackHandler.server_result.get(
|
|
812
|
+
"code"
|
|
813
|
+
) or _BoundCallbackHandler.server_result.get("error"):
|
|
814
|
+
return dict(_BoundCallbackHandler.server_result)
|
|
815
|
+
finally:
|
|
816
|
+
server.server_close()
|
|
817
|
+
msg = f"Timed out waiting for ChatGPT OAuth callback on http://{host}:{port}"
|
|
818
|
+
raise TimeoutError(msg)
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
def _validate_loopback_host(host: str) -> None:
|
|
822
|
+
"""Reject non-loopback callback hosts.
|
|
823
|
+
|
|
824
|
+
The callback server receives the OAuth authorization `code` in the request
|
|
825
|
+
URL. Binding it to a non-loopback interface (e.g. `0.0.0.0`) would expose
|
|
826
|
+
that code on the local network, so only loopback hosts are permitted —
|
|
827
|
+
RFC 8252 §8.3 expects a loopback redirect for native-app PKCE flows.
|
|
828
|
+
|
|
829
|
+
Args:
|
|
830
|
+
host: The callback host passed to `login_chatgpt`.
|
|
831
|
+
|
|
832
|
+
Raises:
|
|
833
|
+
ValueError: `host` is not `localhost` or a loopback IP address.
|
|
834
|
+
"""
|
|
835
|
+
if host == "localhost":
|
|
836
|
+
return
|
|
837
|
+
try:
|
|
838
|
+
is_loopback = ipaddress.ip_address(host).is_loopback
|
|
839
|
+
except ValueError:
|
|
840
|
+
# Not an IP literal and not `localhost`; can't prove it's loopback.
|
|
841
|
+
is_loopback = False
|
|
842
|
+
if not is_loopback:
|
|
843
|
+
msg = (
|
|
844
|
+
f"`host={host!r}` is not a loopback address. The OAuth callback "
|
|
845
|
+
"server receives the authorization code in the request URL, so it "
|
|
846
|
+
"must bind to a loopback interface (`localhost`, `127.0.0.1`, or "
|
|
847
|
+
"`::1`) to avoid exposing the code on the network."
|
|
848
|
+
)
|
|
849
|
+
raise ValueError(msg)
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
def login_chatgpt(
|
|
853
|
+
*,
|
|
854
|
+
store_path: Path | None = None,
|
|
855
|
+
client_id: str = CHATGPT_CLIENT_ID,
|
|
856
|
+
host: str = DEFAULT_REDIRECT_HOST,
|
|
857
|
+
port: int = DEFAULT_REDIRECT_PORT,
|
|
858
|
+
callback_path: str = DEFAULT_REDIRECT_PATH,
|
|
859
|
+
scope: str = DEFAULT_SCOPE,
|
|
860
|
+
open_browser: bool = True,
|
|
861
|
+
timeout: float = 300.0,
|
|
862
|
+
) -> _FileChatGPTOAuthTokenProvider:
|
|
863
|
+
"""Run the ChatGPT OAuth 2.0 Authorization Code Flow with PKCE.
|
|
864
|
+
|
|
865
|
+
Starts a loopback callback server, optionally opens a browser to the
|
|
866
|
+
OpenAI authorize endpoint (when `open_browser=True`; the URL is always
|
|
867
|
+
printed as a fallback), exchanges the returned code for tokens, and
|
|
868
|
+
persists them via `_FileChatGPTOAuthTokenProvider`.
|
|
869
|
+
|
|
870
|
+
Args:
|
|
871
|
+
store_path: Where to persist the token. Defaults to
|
|
872
|
+
`DEFAULT_STORE_PATH`.
|
|
873
|
+
client_id: OAuth client ID (defaults to Codex/ChatGPT client).
|
|
874
|
+
host: Local callback host. Must be a loopback address.
|
|
875
|
+
port: Local callback port.
|
|
876
|
+
callback_path: Local callback path.
|
|
877
|
+
scope: OAuth scope string.
|
|
878
|
+
open_browser: Whether to launch the system browser.
|
|
879
|
+
timeout: Seconds to wait for the callback.
|
|
880
|
+
|
|
881
|
+
Returns:
|
|
882
|
+
A `_FileChatGPTOAuthTokenProvider` ready for use by
|
|
883
|
+
`_ChatOpenAICodex`.
|
|
884
|
+
|
|
885
|
+
Raises:
|
|
886
|
+
ValueError: `host` is not a loopback address.
|
|
887
|
+
RuntimeError: The callback server could not bind, the `state` did not
|
|
888
|
+
match (CSRF), the provider returned an OAuth error, or no
|
|
889
|
+
authorization code was returned.
|
|
890
|
+
TimeoutError: No callback was received within `timeout` seconds.
|
|
891
|
+
|
|
892
|
+
See Also:
|
|
893
|
+
`login_chatgpt_device`: Headless fallback for environments without a
|
|
894
|
+
browser or the ability to bind a localhost callback port.
|
|
895
|
+
"""
|
|
896
|
+
_validate_loopback_host(host)
|
|
897
|
+
redirect_uri = f"http://{host}:{port}{callback_path}"
|
|
898
|
+
state = secrets.token_urlsafe(32)
|
|
899
|
+
verifier, challenge = _generate_pkce_pair()
|
|
900
|
+
authorize_url = _build_authorize_url(
|
|
901
|
+
client_id=client_id,
|
|
902
|
+
redirect_uri=redirect_uri,
|
|
903
|
+
state=state,
|
|
904
|
+
code_challenge=challenge,
|
|
905
|
+
scope=scope,
|
|
906
|
+
)
|
|
907
|
+
|
|
908
|
+
# Surface the URL prominently so users can complete sign-in manually if
|
|
909
|
+
# the browser launch fails or the environment is headless.
|
|
910
|
+
print( # noqa: T201
|
|
911
|
+
f"\nChatGPT sign-in: open the following URL in a browser:\n {authorize_url}\n"
|
|
912
|
+
)
|
|
913
|
+
logger.info("Opening ChatGPT sign-in flow at %s", CHATGPT_AUTHORIZE_URL)
|
|
914
|
+
if open_browser:
|
|
915
|
+
try:
|
|
916
|
+
webbrowser.open(authorize_url)
|
|
917
|
+
except webbrowser.Error as exc:
|
|
918
|
+
logger.warning(
|
|
919
|
+
"Could not launch a browser: %s. Copy the URL above instead.",
|
|
920
|
+
exc,
|
|
921
|
+
)
|
|
922
|
+
|
|
923
|
+
result = _wait_for_callback(
|
|
924
|
+
host=host, port=port, callback_path=callback_path, timeout=timeout
|
|
925
|
+
)
|
|
926
|
+
# Validate `state` first: a CSRF mismatch is a security signal and must
|
|
927
|
+
# fail closed before any other branch (including server-reported errors)
|
|
928
|
+
# is considered.
|
|
929
|
+
if result.get("state") != state:
|
|
930
|
+
msg = "ChatGPT OAuth callback state mismatch."
|
|
931
|
+
raise RuntimeError(msg)
|
|
932
|
+
if "error" in result:
|
|
933
|
+
description = result.get("error_description", "")
|
|
934
|
+
msg = f"ChatGPT OAuth callback returned error: {result['error']} {description}"
|
|
935
|
+
raise RuntimeError(msg)
|
|
936
|
+
code = result.get("code")
|
|
937
|
+
if not code:
|
|
938
|
+
msg = "ChatGPT OAuth callback did not include an authorization code."
|
|
939
|
+
raise RuntimeError(msg)
|
|
940
|
+
|
|
941
|
+
response = _post_form(
|
|
942
|
+
CHATGPT_TOKEN_URL,
|
|
943
|
+
{
|
|
944
|
+
"grant_type": "authorization_code",
|
|
945
|
+
"code": code,
|
|
946
|
+
"redirect_uri": redirect_uri,
|
|
947
|
+
"client_id": client_id,
|
|
948
|
+
"code_verifier": verifier,
|
|
949
|
+
},
|
|
950
|
+
)
|
|
951
|
+
token = _token_from_response(response)
|
|
952
|
+
provider = _FileChatGPTOAuthTokenProvider(
|
|
953
|
+
path=store_path or DEFAULT_STORE_PATH, client_id=client_id
|
|
954
|
+
)
|
|
955
|
+
provider.save(token)
|
|
956
|
+
return provider
|
|
957
|
+
|
|
958
|
+
|
|
959
|
+
def login_chatgpt_device(
|
|
960
|
+
*,
|
|
961
|
+
store_path: Path | None = None,
|
|
962
|
+
client_id: str = CHATGPT_CLIENT_ID,
|
|
963
|
+
poll_interval: float = 5.0,
|
|
964
|
+
timeout: float = 600.0,
|
|
965
|
+
) -> _FileChatGPTOAuthTokenProvider:
|
|
966
|
+
"""Run the ChatGPT device-code OAuth flow.
|
|
967
|
+
|
|
968
|
+
This is the headless fallback for environments without a browser. The
|
|
969
|
+
function prints a verification URL and user code, polls for completion,
|
|
970
|
+
then exchanges the resulting code via the OAuth token endpoint using
|
|
971
|
+
`CHATGPT_DEVICE_REDIRECT_URI`.
|
|
972
|
+
|
|
973
|
+
Args:
|
|
974
|
+
store_path: Where to persist the token. Defaults to
|
|
975
|
+
`DEFAULT_STORE_PATH`.
|
|
976
|
+
client_id: OAuth client ID (defaults to Codex/ChatGPT client).
|
|
977
|
+
poll_interval: Seconds between polls.
|
|
978
|
+
timeout: Total seconds to wait.
|
|
979
|
+
|
|
980
|
+
Returns:
|
|
981
|
+
A configured `_FileChatGPTOAuthTokenProvider`.
|
|
982
|
+
|
|
983
|
+
Raises:
|
|
984
|
+
RuntimeError: The device-code response was missing required fields, or
|
|
985
|
+
device authorization failed with a terminal error.
|
|
986
|
+
TimeoutError: Authorization was not completed within `timeout` seconds.
|
|
987
|
+
|
|
988
|
+
See Also:
|
|
989
|
+
`login_chatgpt`: Browser-based loopback flow preferred when a local
|
|
990
|
+
browser and free callback port are available.
|
|
991
|
+
"""
|
|
992
|
+
_verifier, challenge = _generate_pkce_pair()
|
|
993
|
+
start = _post_form(
|
|
994
|
+
CHATGPT_DEVICE_CODE_URL,
|
|
995
|
+
{
|
|
996
|
+
"client_id": client_id,
|
|
997
|
+
"scope": DEFAULT_SCOPE,
|
|
998
|
+
"code_challenge": challenge,
|
|
999
|
+
"code_challenge_method": "S256",
|
|
1000
|
+
},
|
|
1001
|
+
)
|
|
1002
|
+
device_code = start.get("device_code")
|
|
1003
|
+
user_code = start.get("user_code")
|
|
1004
|
+
verification_uri = start.get("verification_uri") or start.get(
|
|
1005
|
+
"verification_uri_complete"
|
|
1006
|
+
)
|
|
1007
|
+
if not (device_code and user_code and verification_uri):
|
|
1008
|
+
msg = "ChatGPT device-code response missing required fields."
|
|
1009
|
+
raise RuntimeError(msg)
|
|
1010
|
+
logger.info(
|
|
1011
|
+
"Open %s in a browser and enter user code: %s", verification_uri, user_code
|
|
1012
|
+
)
|
|
1013
|
+
|
|
1014
|
+
deadline = time.monotonic() + timeout
|
|
1015
|
+
authorization_code: str | None = None
|
|
1016
|
+
current_interval = poll_interval
|
|
1017
|
+
while time.monotonic() < deadline:
|
|
1018
|
+
poll = _post_device_poll_form(
|
|
1019
|
+
CHATGPT_DEVICE_TOKEN_URL,
|
|
1020
|
+
{"client_id": client_id, "device_code": device_code},
|
|
1021
|
+
)
|
|
1022
|
+
if poll.get("authorization_code"):
|
|
1023
|
+
authorization_code = poll["authorization_code"]
|
|
1024
|
+
break
|
|
1025
|
+
error = poll.get("error")
|
|
1026
|
+
if error == "slow_down":
|
|
1027
|
+
# RFC 8628 §3.5: bump the poll interval by 5 seconds to comply
|
|
1028
|
+
# with server-side rate limiting; otherwise we risk being banned.
|
|
1029
|
+
current_interval += 5
|
|
1030
|
+
elif error and error != "authorization_pending":
|
|
1031
|
+
msg = f"Device authorization failed: {error}"
|
|
1032
|
+
raise RuntimeError(msg)
|
|
1033
|
+
time.sleep(current_interval)
|
|
1034
|
+
if not authorization_code:
|
|
1035
|
+
msg = "Timed out waiting for ChatGPT device authorization."
|
|
1036
|
+
raise TimeoutError(msg)
|
|
1037
|
+
|
|
1038
|
+
response = _post_form(
|
|
1039
|
+
CHATGPT_TOKEN_URL,
|
|
1040
|
+
{
|
|
1041
|
+
"grant_type": "authorization_code",
|
|
1042
|
+
"code": authorization_code,
|
|
1043
|
+
"redirect_uri": CHATGPT_DEVICE_REDIRECT_URI,
|
|
1044
|
+
"client_id": client_id,
|
|
1045
|
+
"code_verifier": _verifier,
|
|
1046
|
+
},
|
|
1047
|
+
)
|
|
1048
|
+
token = _token_from_response(response)
|
|
1049
|
+
provider = _FileChatGPTOAuthTokenProvider(
|
|
1050
|
+
path=store_path or DEFAULT_STORE_PATH, client_id=client_id
|
|
1051
|
+
)
|
|
1052
|
+
provider.save(token)
|
|
1053
|
+
return provider
|
|
1054
|
+
|
|
1055
|
+
|
|
1056
|
+
__all__ = [
|
|
1057
|
+
"CHATGPT_AUTHORIZE_URL",
|
|
1058
|
+
"CHATGPT_CLIENT_ID",
|
|
1059
|
+
"CHATGPT_TOKEN_URL",
|
|
1060
|
+
"decode_jwt_claims",
|
|
1061
|
+
"login_chatgpt",
|
|
1062
|
+
"login_chatgpt_device",
|
|
1063
|
+
]
|