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.
Files changed (400) hide show
  1. coco/__init__.py +3 -0
  2. coco/agents/__init__.py +9 -0
  3. coco/agents/_subagent_transformer.py +307 -0
  4. coco/agents/factory.py +2164 -0
  5. coco/agents/middleware/__init__.py +100 -0
  6. coco/agents/middleware/_execution.py +385 -0
  7. coco/agents/middleware/_redaction.py +454 -0
  8. coco/agents/middleware/_retry.py +134 -0
  9. coco/agents/middleware/_trace_policy.py +61 -0
  10. coco/agents/middleware/context_editing.py +306 -0
  11. coco/agents/middleware/file_search.py +433 -0
  12. coco/agents/middleware/human_in_the_loop.py +680 -0
  13. coco/agents/middleware/internal_call_transformer.py +108 -0
  14. coco/agents/middleware/model_call_limit.py +267 -0
  15. coco/agents/middleware/model_fallback.py +417 -0
  16. coco/agents/middleware/model_retry.py +327 -0
  17. coco/agents/middleware/pii.py +878 -0
  18. coco/agents/middleware/provider_tool_search.py +307 -0
  19. coco/agents/middleware/shell_tool.py +949 -0
  20. coco/agents/middleware/summarization.py +910 -0
  21. coco/agents/middleware/todo.py +357 -0
  22. coco/agents/middleware/tool_call_limit.py +495 -0
  23. coco/agents/middleware/tool_emulator.py +251 -0
  24. coco/agents/middleware/tool_error.py +209 -0
  25. coco/agents/middleware/tool_retry.py +421 -0
  26. coco/agents/middleware/tool_selection.py +515 -0
  27. coco/agents/middleware/types.py +2201 -0
  28. coco/agents/structured_output.py +472 -0
  29. coco/chat_models/__init__.py +7 -0
  30. coco/chat_models/base.py +1082 -0
  31. coco/embeddings/__init__.py +18 -0
  32. coco/embeddings/base.py +279 -0
  33. coco/mcp/__init__.py +36 -0
  34. coco/mcp/adapter.py +245 -0
  35. coco/mcp/elicitation.py +394 -0
  36. coco/mcp/tools.py +307 -0
  37. coco/messages/__init__.py +73 -0
  38. coco/py.typed +0 -0
  39. coco/rate_limiters/__init__.py +13 -0
  40. coco/tools/__init__.py +22 -0
  41. coco/tools/tool_node.py +20 -0
  42. coco_anthropic/__init__.py +15 -0
  43. coco_anthropic/_client_utils.py +81 -0
  44. coco_anthropic/_compat.py +228 -0
  45. coco_anthropic/_sdk_compat.py +124 -0
  46. coco_anthropic/_version.py +3 -0
  47. coco_anthropic/chat_models.py +3349 -0
  48. coco_anthropic/data/__init__.py +1 -0
  49. coco_anthropic/data/_profiles.py +491 -0
  50. coco_anthropic/data/profile_augmentations.toml +69 -0
  51. coco_anthropic/experimental.py +140 -0
  52. coco_anthropic/llms.py +428 -0
  53. coco_anthropic/middleware/__init__.py +25 -0
  54. coco_anthropic/middleware/anthropic_tools.py +1197 -0
  55. coco_anthropic/middleware/bash.py +84 -0
  56. coco_anthropic/middleware/file_search.py +360 -0
  57. coco_anthropic/middleware/prompt_caching.py +276 -0
  58. coco_anthropic/output_parsers.py +93 -0
  59. coco_anthropic/py.typed +0 -0
  60. coco_core/__init__.py +20 -0
  61. coco_core/_api/__init__.py +87 -0
  62. coco_core/_api/beta_decorator.py +266 -0
  63. coco_core/_api/deprecation.py +637 -0
  64. coco_core/_api/internal.py +23 -0
  65. coco_core/_api/path.py +50 -0
  66. coco_core/_import_utils.py +41 -0
  67. coco_core/_protocol.py +625 -0
  68. coco_core/_security/__init__.py +36 -0
  69. coco_core/_security/_exceptions.py +9 -0
  70. coco_core/_security/_policy.py +313 -0
  71. coco_core/_security/_ssrf_protection.py +155 -0
  72. coco_core/_security/_transport.py +254 -0
  73. coco_core/agents.py +258 -0
  74. coco_core/caches.py +272 -0
  75. coco_core/callbacks/__init__.py +132 -0
  76. coco_core/callbacks/base.py +1229 -0
  77. coco_core/callbacks/file.py +266 -0
  78. coco_core/callbacks/manager.py +2826 -0
  79. coco_core/callbacks/stdout.py +123 -0
  80. coco_core/callbacks/streaming_stdout.py +154 -0
  81. coco_core/callbacks/usage.py +124 -0
  82. coco_core/chat_history.py +267 -0
  83. coco_core/chat_loaders.py +26 -0
  84. coco_core/chat_sessions.py +19 -0
  85. coco_core/cross_encoders.py +18 -0
  86. coco_core/document_loaders/__init__.py +36 -0
  87. coco_core/document_loaders/base.py +155 -0
  88. coco_core/document_loaders/blob_loaders.py +38 -0
  89. coco_core/documents/__init__.py +55 -0
  90. coco_core/documents/base.py +347 -0
  91. coco_core/documents/compressor.py +74 -0
  92. coco_core/documents/transformers.py +79 -0
  93. coco_core/embeddings/__init__.py +31 -0
  94. coco_core/embeddings/embeddings.py +78 -0
  95. coco_core/embeddings/fake.py +129 -0
  96. coco_core/env.py +22 -0
  97. coco_core/example_selectors/__init__.py +47 -0
  98. coco_core/example_selectors/base.py +60 -0
  99. coco_core/example_selectors/length_based.py +131 -0
  100. coco_core/example_selectors/semantic_similarity.py +366 -0
  101. coco_core/exceptions.py +166 -0
  102. coco_core/globals.py +72 -0
  103. coco_core/indexing/__init__.py +53 -0
  104. coco_core/indexing/api.py +954 -0
  105. coco_core/indexing/base.py +661 -0
  106. coco_core/indexing/in_memory.py +104 -0
  107. coco_core/language_models/__init__.py +116 -0
  108. coco_core/language_models/_compat_bridge.py +844 -0
  109. coco_core/language_models/_utils.py +343 -0
  110. coco_core/language_models/base.py +500 -0
  111. coco_core/language_models/chat_model_stream.py +1525 -0
  112. coco_core/language_models/chat_models.py +2748 -0
  113. coco_core/language_models/fake.py +137 -0
  114. coco_core/language_models/fake_chat_models.py +396 -0
  115. coco_core/language_models/llms.py +1569 -0
  116. coco_core/language_models/model_profile.py +183 -0
  117. coco_core/load/__init__.py +44 -0
  118. coco_core/load/_validation.py +191 -0
  119. coco_core/load/dump.py +120 -0
  120. coco_core/load/load.py +828 -0
  121. coco_core/load/mapping.py +1097 -0
  122. coco_core/load/serializable.py +406 -0
  123. coco_core/messages/__init__.py +198 -0
  124. coco_core/messages/ai.py +854 -0
  125. coco_core/messages/base.py +518 -0
  126. coco_core/messages/block_translators/__init__.py +112 -0
  127. coco_core/messages/block_translators/anthropic.py +534 -0
  128. coco_core/messages/block_translators/bedrock.py +108 -0
  129. coco_core/messages/block_translators/bedrock_converse.py +329 -0
  130. coco_core/messages/block_translators/coco_v0.py +303 -0
  131. coco_core/messages/block_translators/google_genai.py +610 -0
  132. coco_core/messages/block_translators/google_vertexai.py +21 -0
  133. coco_core/messages/block_translators/groq.py +157 -0
  134. coco_core/messages/block_translators/openai.py +1089 -0
  135. coco_core/messages/chat.py +64 -0
  136. coco_core/messages/content.py +1488 -0
  137. coco_core/messages/function.py +62 -0
  138. coco_core/messages/human.py +70 -0
  139. coco_core/messages/modifier.py +33 -0
  140. coco_core/messages/system.py +70 -0
  141. coco_core/messages/tool.py +418 -0
  142. coco_core/messages/utils.py +2442 -0
  143. coco_core/output_parsers/__init__.py +101 -0
  144. coco_core/output_parsers/base.py +361 -0
  145. coco_core/output_parsers/format_instructions.py +16 -0
  146. coco_core/output_parsers/json.py +139 -0
  147. coco_core/output_parsers/list.py +249 -0
  148. coco_core/output_parsers/openai_functions.py +324 -0
  149. coco_core/output_parsers/openai_tools.py +383 -0
  150. coco_core/output_parsers/pydantic.py +141 -0
  151. coco_core/output_parsers/string.py +63 -0
  152. coco_core/output_parsers/transform.py +175 -0
  153. coco_core/output_parsers/xml.py +301 -0
  154. coco_core/outputs/__init__.py +66 -0
  155. coco_core/outputs/chat_generation.py +147 -0
  156. coco_core/outputs/chat_result.py +39 -0
  157. coco_core/outputs/generation.py +80 -0
  158. coco_core/outputs/llm_result.py +112 -0
  159. coco_core/outputs/run_info.py +22 -0
  160. coco_core/prompt_values.py +161 -0
  161. coco_core/prompts/__init__.py +101 -0
  162. coco_core/prompts/base.py +508 -0
  163. coco_core/prompts/chat.py +1495 -0
  164. coco_core/prompts/dict.py +177 -0
  165. coco_core/prompts/few_shot.py +483 -0
  166. coco_core/prompts/few_shot_with_templates.py +239 -0
  167. coco_core/prompts/image.py +177 -0
  168. coco_core/prompts/loading.py +296 -0
  169. coco_core/prompts/message.py +97 -0
  170. coco_core/prompts/prompt.py +312 -0
  171. coco_core/prompts/string.py +399 -0
  172. coco_core/prompts/structured.py +214 -0
  173. coco_core/py.typed +0 -0
  174. coco_core/rate_limiters.py +254 -0
  175. coco_core/retrievers.py +328 -0
  176. coco_core/runnables/__init__.py +136 -0
  177. coco_core/runnables/base.py +6725 -0
  178. coco_core/runnables/branch.py +457 -0
  179. coco_core/runnables/config.py +712 -0
  180. coco_core/runnables/configurable.py +710 -0
  181. coco_core/runnables/fallbacks.py +663 -0
  182. coco_core/runnables/graph.py +741 -0
  183. coco_core/runnables/graph_ascii.py +366 -0
  184. coco_core/runnables/graph_mermaid.py +503 -0
  185. coco_core/runnables/graph_png.py +215 -0
  186. coco_core/runnables/history.py +631 -0
  187. coco_core/runnables/passthrough.py +849 -0
  188. coco_core/runnables/retry.py +379 -0
  189. coco_core/runnables/router.py +239 -0
  190. coco_core/runnables/schema.py +188 -0
  191. coco_core/runnables/utils.py +798 -0
  192. coco_core/stores.py +291 -0
  193. coco_core/structured_query.py +203 -0
  194. coco_core/sys_info.py +154 -0
  195. coco_core/tools/__init__.py +95 -0
  196. coco_core/tools/base.py +1948 -0
  197. coco_core/tools/convert.py +497 -0
  198. coco_core/tools/render.py +67 -0
  199. coco_core/tools/retriever.py +94 -0
  200. coco_core/tools/simple.py +208 -0
  201. coco_core/tools/structured.py +322 -0
  202. coco_core/tracers/__init__.py +47 -0
  203. coco_core/tracers/_compat.py +95 -0
  204. coco_core/tracers/_context.py +337 -0
  205. coco_core/tracers/_streaming.py +53 -0
  206. coco_core/tracers/base.py +955 -0
  207. coco_core/tracers/coco.py +279 -0
  208. coco_core/tracers/context.py +205 -0
  209. coco_core/tracers/core.py +766 -0
  210. coco_core/tracers/event_stream.py +1101 -0
  211. coco_core/tracers/log_stream.py +765 -0
  212. coco_core/tracers/memory_stream.py +200 -0
  213. coco_core/tracers/root_listeners.py +130 -0
  214. coco_core/tracers/run_collector.py +40 -0
  215. coco_core/tracers/schemas.py +14 -0
  216. coco_core/tracers/stdout.py +205 -0
  217. coco_core/utils/__init__.py +111 -0
  218. coco_core/utils/_gateway.py +274 -0
  219. coco_core/utils/_merge.py +218 -0
  220. coco_core/utils/aiter.py +357 -0
  221. coco_core/utils/env.py +86 -0
  222. coco_core/utils/formatting.py +80 -0
  223. coco_core/utils/function_calling.py +869 -0
  224. coco_core/utils/html.py +132 -0
  225. coco_core/utils/image.py +15 -0
  226. coco_core/utils/input.py +82 -0
  227. coco_core/utils/interactive_env.py +12 -0
  228. coco_core/utils/iter.py +229 -0
  229. coco_core/utils/json.py +230 -0
  230. coco_core/utils/json_schema.py +273 -0
  231. coco_core/utils/mustache.py +706 -0
  232. coco_core/utils/pydantic.py +631 -0
  233. coco_core/utils/strings.py +69 -0
  234. coco_core/utils/usage.py +61 -0
  235. coco_core/utils/utils.py +528 -0
  236. coco_core/utils/uuid.py +57 -0
  237. coco_core/vectorstores/__init__.py +53 -0
  238. coco_core/vectorstores/base.py +1111 -0
  239. coco_core/vectorstores/in_memory.py +546 -0
  240. coco_core/vectorstores/utils.py +163 -0
  241. coco_core/version.py +3 -0
  242. coco_framework-0.1.0.dist-info/METADATA +184 -0
  243. coco_framework-0.1.0.dist-info/RECORD +400 -0
  244. coco_framework-0.1.0.dist-info/WHEEL +4 -0
  245. coco_framework-0.1.0.dist-info/licenses/LICENSE +24 -0
  246. coco_framework-0.1.0.dist-info/licenses/NOTICE +15 -0
  247. coco_graph/_internal/__init__.py +4 -0
  248. coco_graph/_internal/_cache.py +31 -0
  249. coco_graph/_internal/_config.py +474 -0
  250. coco_graph/_internal/_constants.py +140 -0
  251. coco_graph/_internal/_fields.py +213 -0
  252. coco_graph/_internal/_future.py +220 -0
  253. coco_graph/_internal/_pydantic.py +275 -0
  254. coco_graph/_internal/_queue.py +124 -0
  255. coco_graph/_internal/_replay.py +90 -0
  256. coco_graph/_internal/_retry.py +29 -0
  257. coco_graph/_internal/_runnable.py +969 -0
  258. coco_graph/_internal/_scratchpad.py +19 -0
  259. coco_graph/_internal/_serde.py +253 -0
  260. coco_graph/_internal/_timeout.py +25 -0
  261. coco_graph/_internal/_typing.py +54 -0
  262. coco_graph/cache/base/__init__.py +48 -0
  263. coco_graph/cache/base/py.typed +0 -0
  264. coco_graph/cache/memory/__init__.py +73 -0
  265. coco_graph/cache/redis/__init__.py +144 -0
  266. coco_graph/cache/sqlite/__init__.py +120 -0
  267. coco_graph/callbacks.py +394 -0
  268. coco_graph/channels/__init__.py +29 -0
  269. coco_graph/channels/any_value.py +72 -0
  270. coco_graph/channels/base.py +121 -0
  271. coco_graph/channels/binop.py +155 -0
  272. coco_graph/channels/delta.py +202 -0
  273. coco_graph/channels/ephemeral_value.py +79 -0
  274. coco_graph/channels/last_value.py +151 -0
  275. coco_graph/channels/named_barrier_value.py +167 -0
  276. coco_graph/channels/topic.py +94 -0
  277. coco_graph/channels/untracked_value.py +73 -0
  278. coco_graph/checkpoint/base/__init__.py +861 -0
  279. coco_graph/checkpoint/base/id.py +109 -0
  280. coco_graph/checkpoint/base/py.typed +0 -0
  281. coco_graph/checkpoint/memory/__init__.py +698 -0
  282. coco_graph/checkpoint/memory/py.typed +0 -0
  283. coco_graph/checkpoint/postgres/__init__.py +602 -0
  284. coco_graph/checkpoint/postgres/_ainternal.py +23 -0
  285. coco_graph/checkpoint/postgres/_internal.py +21 -0
  286. coco_graph/checkpoint/postgres/aio.py +691 -0
  287. coco_graph/checkpoint/postgres/base.py +666 -0
  288. coco_graph/checkpoint/postgres/py.typed +0 -0
  289. coco_graph/checkpoint/postgres/shallow.py +967 -0
  290. coco_graph/checkpoint/serde/__init__.py +0 -0
  291. coco_graph/checkpoint/serde/_msgpack.py +97 -0
  292. coco_graph/checkpoint/serde/base.py +64 -0
  293. coco_graph/checkpoint/serde/encrypted.py +80 -0
  294. coco_graph/checkpoint/serde/event_hooks.py +52 -0
  295. coco_graph/checkpoint/serde/jsonplus.py +883 -0
  296. coco_graph/checkpoint/serde/py.typed +0 -0
  297. coco_graph/checkpoint/serde/types.py +68 -0
  298. coco_graph/checkpoint/sqlite/__init__.py +645 -0
  299. coco_graph/checkpoint/sqlite/_delta.py +172 -0
  300. coco_graph/checkpoint/sqlite/aio.py +738 -0
  301. coco_graph/checkpoint/sqlite/py.typed +0 -0
  302. coco_graph/checkpoint/sqlite/utils.py +116 -0
  303. coco_graph/config.py +196 -0
  304. coco_graph/constants.py +64 -0
  305. coco_graph/errors.py +241 -0
  306. coco_graph/func/__init__.py +620 -0
  307. coco_graph/graph/__init__.py +12 -0
  308. coco_graph/graph/_branch.py +225 -0
  309. coco_graph/graph/_node.py +103 -0
  310. coco_graph/graph/message.py +437 -0
  311. coco_graph/graph/state.py +1978 -0
  312. coco_graph/graph/ui.py +227 -0
  313. coco_graph/managed/__init__.py +3 -0
  314. coco_graph/managed/base.py +31 -0
  315. coco_graph/managed/is_last_step.py +24 -0
  316. coco_graph/prebuilt/__init__.py +23 -0
  317. coco_graph/prebuilt/_tool_call_stream.py +117 -0
  318. coco_graph/prebuilt/_tool_call_transformer.py +165 -0
  319. coco_graph/prebuilt/chat_agent_executor.py +1015 -0
  320. coco_graph/prebuilt/interrupt.py +105 -0
  321. coco_graph/prebuilt/py.typed +0 -0
  322. coco_graph/prebuilt/tool_node.py +2030 -0
  323. coco_graph/prebuilt/tool_validator.py +221 -0
  324. coco_graph/pregel/__init__.py +3 -0
  325. coco_graph/pregel/_algo.py +1460 -0
  326. coco_graph/pregel/_call.py +298 -0
  327. coco_graph/pregel/_checkpoint.py +331 -0
  328. coco_graph/pregel/_config.py +0 -0
  329. coco_graph/pregel/_draw.py +294 -0
  330. coco_graph/pregel/_executor.py +223 -0
  331. coco_graph/pregel/_io.py +174 -0
  332. coco_graph/pregel/_log.py +3 -0
  333. coco_graph/pregel/_loop.py +1988 -0
  334. coco_graph/pregel/_messages.py +461 -0
  335. coco_graph/pregel/_read.py +312 -0
  336. coco_graph/pregel/_retry.py +854 -0
  337. coco_graph/pregel/_runner.py +941 -0
  338. coco_graph/pregel/_tools.py +268 -0
  339. coco_graph/pregel/_utils.py +221 -0
  340. coco_graph/pregel/_validate.py +120 -0
  341. coco_graph/pregel/_write.py +192 -0
  342. coco_graph/pregel/debug.py +302 -0
  343. coco_graph/pregel/main.py +4364 -0
  344. coco_graph/pregel/protocol.py +288 -0
  345. coco_graph/pregel/types.py +38 -0
  346. coco_graph/py.typed +0 -0
  347. coco_graph/runtime.py +350 -0
  348. coco_graph/store/base/__init__.py +1322 -0
  349. coco_graph/store/base/batch.py +371 -0
  350. coco_graph/store/base/embed.py +436 -0
  351. coco_graph/store/base/py.typed +0 -0
  352. coco_graph/store/memory/__init__.py +592 -0
  353. coco_graph/store/memory/py.typed +0 -0
  354. coco_graph/store/postgres/__init__.py +4 -0
  355. coco_graph/store/postgres/aio.py +592 -0
  356. coco_graph/store/postgres/base.py +1480 -0
  357. coco_graph/store/postgres/py.typed +0 -0
  358. coco_graph/store/sqlite/__init__.py +4 -0
  359. coco_graph/store/sqlite/aio.py +632 -0
  360. coco_graph/store/sqlite/base.py +1514 -0
  361. coco_graph/stream/__init__.py +45 -0
  362. coco_graph/stream/_convert.py +32 -0
  363. coco_graph/stream/_mux.py +523 -0
  364. coco_graph/stream/_types.py +330 -0
  365. coco_graph/stream/run_stream.py +735 -0
  366. coco_graph/stream/stream_channel.py +341 -0
  367. coco_graph/stream/transformers.py +1039 -0
  368. coco_graph/types.py +1079 -0
  369. coco_graph/typing.py +48 -0
  370. coco_graph/utils/__init__.py +1 -0
  371. coco_graph/utils/config.py +4 -0
  372. coco_graph/utils/runnable.py +3 -0
  373. coco_graph/version.py +13 -0
  374. coco_graph/warnings.py +69 -0
  375. coco_openai/__init__.py +20 -0
  376. coco_openai/_compat.py +45 -0
  377. coco_openai/_version.py +3 -0
  378. coco_openai/chat_models/__init__.py +6 -0
  379. coco_openai/chat_models/_client_utils.py +684 -0
  380. coco_openai/chat_models/_compat.py +525 -0
  381. coco_openai/chat_models/azure.py +1227 -0
  382. coco_openai/chat_models/base.py +5654 -0
  383. coco_openai/chat_models/codex.py +557 -0
  384. coco_openai/chatgpt_oauth.py +1063 -0
  385. coco_openai/data/__init__.py +1 -0
  386. coco_openai/data/_profiles.py +1655 -0
  387. coco_openai/data/profile_augmentations.toml +127 -0
  388. coco_openai/embeddings/__init__.py +6 -0
  389. coco_openai/embeddings/azure.py +240 -0
  390. coco_openai/embeddings/base.py +820 -0
  391. coco_openai/llms/__init__.py +6 -0
  392. coco_openai/llms/azure.py +249 -0
  393. coco_openai/llms/base.py +919 -0
  394. coco_openai/middleware/__init__.py +11 -0
  395. coco_openai/middleware/openai_moderation.py +484 -0
  396. coco_openai/output_parsers/__init__.py +9 -0
  397. coco_openai/output_parsers/tools.py +9 -0
  398. coco_openai/py.typed +0 -0
  399. coco_openai/tools/__init__.py +5 -0
  400. 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 = "&check;" 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
+ ]