ccproxy-api 0.1.6__py3-none-any.whl → 0.2.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 (481) hide show
  1. ccproxy/api/__init__.py +1 -15
  2. ccproxy/api/app.py +439 -212
  3. ccproxy/api/bootstrap.py +30 -0
  4. ccproxy/api/decorators.py +85 -0
  5. ccproxy/api/dependencies.py +145 -176
  6. ccproxy/api/format_validation.py +54 -0
  7. ccproxy/api/middleware/cors.py +6 -3
  8. ccproxy/api/middleware/errors.py +402 -530
  9. ccproxy/api/middleware/hooks.py +563 -0
  10. ccproxy/api/middleware/normalize_headers.py +59 -0
  11. ccproxy/api/middleware/request_id.py +35 -16
  12. ccproxy/api/middleware/streaming_hooks.py +292 -0
  13. ccproxy/api/routes/__init__.py +5 -14
  14. ccproxy/api/routes/health.py +39 -672
  15. ccproxy/api/routes/plugins.py +277 -0
  16. ccproxy/auth/__init__.py +2 -19
  17. ccproxy/auth/bearer.py +25 -15
  18. ccproxy/auth/dependencies.py +123 -157
  19. ccproxy/auth/exceptions.py +0 -12
  20. ccproxy/auth/manager.py +35 -49
  21. ccproxy/auth/managers/__init__.py +10 -0
  22. ccproxy/auth/managers/base.py +523 -0
  23. ccproxy/auth/managers/base_enhanced.py +63 -0
  24. ccproxy/auth/managers/token_snapshot.py +77 -0
  25. ccproxy/auth/models/base.py +65 -0
  26. ccproxy/auth/models/credentials.py +40 -0
  27. ccproxy/auth/oauth/__init__.py +4 -18
  28. ccproxy/auth/oauth/base.py +533 -0
  29. ccproxy/auth/oauth/cli_errors.py +37 -0
  30. ccproxy/auth/oauth/flows.py +430 -0
  31. ccproxy/auth/oauth/protocol.py +366 -0
  32. ccproxy/auth/oauth/registry.py +408 -0
  33. ccproxy/auth/oauth/router.py +396 -0
  34. ccproxy/auth/oauth/routes.py +186 -113
  35. ccproxy/auth/oauth/session.py +151 -0
  36. ccproxy/auth/oauth/templates.py +342 -0
  37. ccproxy/auth/storage/__init__.py +2 -5
  38. ccproxy/auth/storage/base.py +279 -5
  39. ccproxy/auth/storage/generic.py +134 -0
  40. ccproxy/cli/__init__.py +1 -2
  41. ccproxy/cli/_settings_help.py +351 -0
  42. ccproxy/cli/commands/auth.py +1519 -793
  43. ccproxy/cli/commands/config/commands.py +209 -276
  44. ccproxy/cli/commands/plugins.py +669 -0
  45. ccproxy/cli/commands/serve.py +75 -810
  46. ccproxy/cli/commands/status.py +254 -0
  47. ccproxy/cli/decorators.py +83 -0
  48. ccproxy/cli/helpers.py +22 -60
  49. ccproxy/cli/main.py +359 -10
  50. ccproxy/cli/options/claude_options.py +0 -25
  51. ccproxy/config/__init__.py +7 -11
  52. ccproxy/config/core.py +227 -0
  53. ccproxy/config/env_generator.py +232 -0
  54. ccproxy/config/runtime.py +67 -0
  55. ccproxy/config/security.py +36 -3
  56. ccproxy/config/settings.py +382 -441
  57. ccproxy/config/toml_generator.py +299 -0
  58. ccproxy/config/utils.py +452 -0
  59. ccproxy/core/__init__.py +7 -271
  60. ccproxy/{_version.py → core/_version.py} +16 -3
  61. ccproxy/core/async_task_manager.py +516 -0
  62. ccproxy/core/async_utils.py +47 -14
  63. ccproxy/core/auth/__init__.py +6 -0
  64. ccproxy/core/constants.py +16 -50
  65. ccproxy/core/errors.py +53 -0
  66. ccproxy/core/id_utils.py +20 -0
  67. ccproxy/core/interfaces.py +16 -123
  68. ccproxy/core/logging.py +473 -18
  69. ccproxy/core/plugins/__init__.py +77 -0
  70. ccproxy/core/plugins/cli_discovery.py +211 -0
  71. ccproxy/core/plugins/declaration.py +455 -0
  72. ccproxy/core/plugins/discovery.py +604 -0
  73. ccproxy/core/plugins/factories.py +967 -0
  74. ccproxy/core/plugins/hooks/__init__.py +30 -0
  75. ccproxy/core/plugins/hooks/base.py +58 -0
  76. ccproxy/core/plugins/hooks/events.py +46 -0
  77. ccproxy/core/plugins/hooks/implementations/__init__.py +16 -0
  78. ccproxy/core/plugins/hooks/implementations/formatters/__init__.py +11 -0
  79. ccproxy/core/plugins/hooks/implementations/formatters/json.py +552 -0
  80. ccproxy/core/plugins/hooks/implementations/formatters/raw.py +370 -0
  81. ccproxy/core/plugins/hooks/implementations/http_tracer.py +431 -0
  82. ccproxy/core/plugins/hooks/layers.py +44 -0
  83. ccproxy/core/plugins/hooks/manager.py +186 -0
  84. ccproxy/core/plugins/hooks/registry.py +139 -0
  85. ccproxy/core/plugins/hooks/thread_manager.py +203 -0
  86. ccproxy/core/plugins/hooks/types.py +22 -0
  87. ccproxy/core/plugins/interfaces.py +416 -0
  88. ccproxy/core/plugins/loader.py +166 -0
  89. ccproxy/core/plugins/middleware.py +233 -0
  90. ccproxy/core/plugins/models.py +59 -0
  91. ccproxy/core/plugins/protocol.py +180 -0
  92. ccproxy/core/plugins/runtime.py +519 -0
  93. ccproxy/{observability/context.py → core/request_context.py} +137 -94
  94. ccproxy/core/status_report.py +211 -0
  95. ccproxy/core/transformers.py +13 -8
  96. ccproxy/data/claude_headers_fallback.json +558 -0
  97. ccproxy/data/codex_headers_fallback.json +121 -0
  98. ccproxy/http/__init__.py +30 -0
  99. ccproxy/http/base.py +95 -0
  100. ccproxy/http/client.py +323 -0
  101. ccproxy/http/hooks.py +642 -0
  102. ccproxy/http/pool.py +279 -0
  103. ccproxy/llms/formatters/__init__.py +7 -0
  104. ccproxy/llms/formatters/anthropic_to_openai/__init__.py +55 -0
  105. ccproxy/llms/formatters/anthropic_to_openai/errors.py +65 -0
  106. ccproxy/llms/formatters/anthropic_to_openai/requests.py +356 -0
  107. ccproxy/llms/formatters/anthropic_to_openai/responses.py +153 -0
  108. ccproxy/llms/formatters/anthropic_to_openai/streams.py +1546 -0
  109. ccproxy/llms/formatters/base.py +140 -0
  110. ccproxy/llms/formatters/base_model.py +33 -0
  111. ccproxy/llms/formatters/common/__init__.py +51 -0
  112. ccproxy/llms/formatters/common/identifiers.py +48 -0
  113. ccproxy/llms/formatters/common/streams.py +254 -0
  114. ccproxy/llms/formatters/common/thinking.py +74 -0
  115. ccproxy/llms/formatters/common/usage.py +135 -0
  116. ccproxy/llms/formatters/constants.py +55 -0
  117. ccproxy/llms/formatters/context.py +116 -0
  118. ccproxy/llms/formatters/mapping.py +33 -0
  119. ccproxy/llms/formatters/openai_to_anthropic/__init__.py +55 -0
  120. ccproxy/llms/formatters/openai_to_anthropic/_helpers.py +141 -0
  121. ccproxy/llms/formatters/openai_to_anthropic/errors.py +53 -0
  122. ccproxy/llms/formatters/openai_to_anthropic/requests.py +674 -0
  123. ccproxy/llms/formatters/openai_to_anthropic/responses.py +285 -0
  124. ccproxy/llms/formatters/openai_to_anthropic/streams.py +530 -0
  125. ccproxy/llms/formatters/openai_to_openai/__init__.py +53 -0
  126. ccproxy/llms/formatters/openai_to_openai/_helpers.py +325 -0
  127. ccproxy/llms/formatters/openai_to_openai/errors.py +6 -0
  128. ccproxy/llms/formatters/openai_to_openai/requests.py +388 -0
  129. ccproxy/llms/formatters/openai_to_openai/responses.py +594 -0
  130. ccproxy/llms/formatters/openai_to_openai/streams.py +1832 -0
  131. ccproxy/llms/formatters/utils.py +306 -0
  132. ccproxy/llms/models/__init__.py +9 -0
  133. ccproxy/llms/models/anthropic.py +619 -0
  134. ccproxy/llms/models/openai.py +844 -0
  135. ccproxy/llms/streaming/__init__.py +26 -0
  136. ccproxy/llms/streaming/accumulators.py +1074 -0
  137. ccproxy/llms/streaming/formatters.py +251 -0
  138. ccproxy/{adapters/openai/streaming.py → llms/streaming/processors.py} +193 -240
  139. ccproxy/models/__init__.py +8 -159
  140. ccproxy/models/detection.py +92 -193
  141. ccproxy/models/provider.py +75 -0
  142. ccproxy/plugins/access_log/README.md +32 -0
  143. ccproxy/plugins/access_log/__init__.py +20 -0
  144. ccproxy/plugins/access_log/config.py +33 -0
  145. ccproxy/plugins/access_log/formatter.py +126 -0
  146. ccproxy/plugins/access_log/hook.py +763 -0
  147. ccproxy/plugins/access_log/logger.py +254 -0
  148. ccproxy/plugins/access_log/plugin.py +137 -0
  149. ccproxy/plugins/access_log/writer.py +109 -0
  150. ccproxy/plugins/analytics/README.md +24 -0
  151. ccproxy/plugins/analytics/__init__.py +1 -0
  152. ccproxy/plugins/analytics/config.py +5 -0
  153. ccproxy/plugins/analytics/ingest.py +85 -0
  154. ccproxy/plugins/analytics/models.py +97 -0
  155. ccproxy/plugins/analytics/plugin.py +121 -0
  156. ccproxy/plugins/analytics/routes.py +163 -0
  157. ccproxy/plugins/analytics/service.py +284 -0
  158. ccproxy/plugins/claude_api/README.md +29 -0
  159. ccproxy/plugins/claude_api/__init__.py +10 -0
  160. ccproxy/plugins/claude_api/adapter.py +829 -0
  161. ccproxy/plugins/claude_api/config.py +52 -0
  162. ccproxy/plugins/claude_api/detection_service.py +461 -0
  163. ccproxy/plugins/claude_api/health.py +175 -0
  164. ccproxy/plugins/claude_api/hooks.py +284 -0
  165. ccproxy/plugins/claude_api/models.py +256 -0
  166. ccproxy/plugins/claude_api/plugin.py +298 -0
  167. ccproxy/plugins/claude_api/routes.py +118 -0
  168. ccproxy/plugins/claude_api/streaming_metrics.py +68 -0
  169. ccproxy/plugins/claude_api/tasks.py +84 -0
  170. ccproxy/plugins/claude_sdk/README.md +35 -0
  171. ccproxy/plugins/claude_sdk/__init__.py +80 -0
  172. ccproxy/plugins/claude_sdk/adapter.py +749 -0
  173. ccproxy/plugins/claude_sdk/auth.py +57 -0
  174. ccproxy/{claude_sdk → plugins/claude_sdk}/client.py +63 -39
  175. ccproxy/plugins/claude_sdk/config.py +210 -0
  176. ccproxy/{claude_sdk → plugins/claude_sdk}/converter.py +6 -6
  177. ccproxy/plugins/claude_sdk/detection_service.py +163 -0
  178. ccproxy/{services/claude_sdk_service.py → plugins/claude_sdk/handler.py} +123 -304
  179. ccproxy/plugins/claude_sdk/health.py +113 -0
  180. ccproxy/plugins/claude_sdk/hooks.py +115 -0
  181. ccproxy/{claude_sdk → plugins/claude_sdk}/manager.py +42 -32
  182. ccproxy/{claude_sdk → plugins/claude_sdk}/message_queue.py +8 -8
  183. ccproxy/{models/claude_sdk.py → plugins/claude_sdk/models.py} +64 -16
  184. ccproxy/plugins/claude_sdk/options.py +154 -0
  185. ccproxy/{claude_sdk → plugins/claude_sdk}/parser.py +23 -5
  186. ccproxy/plugins/claude_sdk/plugin.py +269 -0
  187. ccproxy/plugins/claude_sdk/routes.py +104 -0
  188. ccproxy/{claude_sdk → plugins/claude_sdk}/session_client.py +124 -12
  189. ccproxy/plugins/claude_sdk/session_pool.py +700 -0
  190. ccproxy/{claude_sdk → plugins/claude_sdk}/stream_handle.py +48 -43
  191. ccproxy/{claude_sdk → plugins/claude_sdk}/stream_worker.py +22 -18
  192. ccproxy/{claude_sdk → plugins/claude_sdk}/streaming.py +50 -16
  193. ccproxy/plugins/claude_sdk/tasks.py +97 -0
  194. ccproxy/plugins/claude_shared/README.md +18 -0
  195. ccproxy/plugins/claude_shared/__init__.py +12 -0
  196. ccproxy/plugins/claude_shared/model_defaults.py +171 -0
  197. ccproxy/plugins/codex/README.md +35 -0
  198. ccproxy/plugins/codex/__init__.py +6 -0
  199. ccproxy/plugins/codex/adapter.py +635 -0
  200. ccproxy/{config/codex.py → plugins/codex/config.py} +78 -12
  201. ccproxy/plugins/codex/detection_service.py +544 -0
  202. ccproxy/plugins/codex/health.py +162 -0
  203. ccproxy/plugins/codex/hooks.py +263 -0
  204. ccproxy/plugins/codex/model_defaults.py +39 -0
  205. ccproxy/plugins/codex/models.py +263 -0
  206. ccproxy/plugins/codex/plugin.py +275 -0
  207. ccproxy/plugins/codex/routes.py +129 -0
  208. ccproxy/plugins/codex/streaming_metrics.py +324 -0
  209. ccproxy/plugins/codex/tasks.py +106 -0
  210. ccproxy/plugins/codex/utils/__init__.py +1 -0
  211. ccproxy/plugins/codex/utils/sse_parser.py +106 -0
  212. ccproxy/plugins/command_replay/README.md +34 -0
  213. ccproxy/plugins/command_replay/__init__.py +17 -0
  214. ccproxy/plugins/command_replay/config.py +133 -0
  215. ccproxy/plugins/command_replay/formatter.py +432 -0
  216. ccproxy/plugins/command_replay/hook.py +294 -0
  217. ccproxy/plugins/command_replay/plugin.py +161 -0
  218. ccproxy/plugins/copilot/README.md +39 -0
  219. ccproxy/plugins/copilot/__init__.py +11 -0
  220. ccproxy/plugins/copilot/adapter.py +465 -0
  221. ccproxy/plugins/copilot/config.py +155 -0
  222. ccproxy/plugins/copilot/data/copilot_fallback.json +41 -0
  223. ccproxy/plugins/copilot/detection_service.py +255 -0
  224. ccproxy/plugins/copilot/manager.py +275 -0
  225. ccproxy/plugins/copilot/model_defaults.py +284 -0
  226. ccproxy/plugins/copilot/models.py +148 -0
  227. ccproxy/plugins/copilot/oauth/__init__.py +16 -0
  228. ccproxy/plugins/copilot/oauth/client.py +494 -0
  229. ccproxy/plugins/copilot/oauth/models.py +385 -0
  230. ccproxy/plugins/copilot/oauth/provider.py +602 -0
  231. ccproxy/plugins/copilot/oauth/storage.py +170 -0
  232. ccproxy/plugins/copilot/plugin.py +360 -0
  233. ccproxy/plugins/copilot/routes.py +294 -0
  234. ccproxy/plugins/credential_balancer/README.md +124 -0
  235. ccproxy/plugins/credential_balancer/__init__.py +6 -0
  236. ccproxy/plugins/credential_balancer/config.py +270 -0
  237. ccproxy/plugins/credential_balancer/factory.py +415 -0
  238. ccproxy/plugins/credential_balancer/hook.py +51 -0
  239. ccproxy/plugins/credential_balancer/manager.py +587 -0
  240. ccproxy/plugins/credential_balancer/plugin.py +146 -0
  241. ccproxy/plugins/dashboard/README.md +25 -0
  242. ccproxy/plugins/dashboard/__init__.py +1 -0
  243. ccproxy/plugins/dashboard/config.py +8 -0
  244. ccproxy/plugins/dashboard/plugin.py +71 -0
  245. ccproxy/plugins/dashboard/routes.py +67 -0
  246. ccproxy/plugins/docker/README.md +32 -0
  247. ccproxy/{docker → plugins/docker}/__init__.py +3 -0
  248. ccproxy/{docker → plugins/docker}/adapter.py +108 -10
  249. ccproxy/plugins/docker/config.py +82 -0
  250. ccproxy/{docker → plugins/docker}/docker_path.py +4 -3
  251. ccproxy/{docker → plugins/docker}/middleware.py +2 -2
  252. ccproxy/plugins/docker/plugin.py +198 -0
  253. ccproxy/{docker → plugins/docker}/stream_process.py +3 -3
  254. ccproxy/plugins/duckdb_storage/README.md +26 -0
  255. ccproxy/plugins/duckdb_storage/__init__.py +1 -0
  256. ccproxy/plugins/duckdb_storage/config.py +22 -0
  257. ccproxy/plugins/duckdb_storage/plugin.py +128 -0
  258. ccproxy/plugins/duckdb_storage/routes.py +51 -0
  259. ccproxy/plugins/duckdb_storage/storage.py +633 -0
  260. ccproxy/plugins/max_tokens/README.md +38 -0
  261. ccproxy/plugins/max_tokens/__init__.py +12 -0
  262. ccproxy/plugins/max_tokens/adapter.py +235 -0
  263. ccproxy/plugins/max_tokens/config.py +86 -0
  264. ccproxy/plugins/max_tokens/models.py +53 -0
  265. ccproxy/plugins/max_tokens/plugin.py +200 -0
  266. ccproxy/plugins/max_tokens/service.py +271 -0
  267. ccproxy/plugins/max_tokens/token_limits.json +54 -0
  268. ccproxy/plugins/metrics/README.md +35 -0
  269. ccproxy/plugins/metrics/__init__.py +10 -0
  270. ccproxy/{observability/metrics.py → plugins/metrics/collector.py} +20 -153
  271. ccproxy/plugins/metrics/config.py +85 -0
  272. ccproxy/plugins/metrics/grafana/dashboards/ccproxy-dashboard.json +1720 -0
  273. ccproxy/plugins/metrics/hook.py +403 -0
  274. ccproxy/plugins/metrics/plugin.py +268 -0
  275. ccproxy/{observability → plugins/metrics}/pushgateway.py +57 -59
  276. ccproxy/plugins/metrics/routes.py +107 -0
  277. ccproxy/plugins/metrics/tasks.py +117 -0
  278. ccproxy/plugins/oauth_claude/README.md +35 -0
  279. ccproxy/plugins/oauth_claude/__init__.py +14 -0
  280. ccproxy/plugins/oauth_claude/client.py +270 -0
  281. ccproxy/plugins/oauth_claude/config.py +84 -0
  282. ccproxy/plugins/oauth_claude/manager.py +482 -0
  283. ccproxy/plugins/oauth_claude/models.py +266 -0
  284. ccproxy/plugins/oauth_claude/plugin.py +149 -0
  285. ccproxy/plugins/oauth_claude/provider.py +571 -0
  286. ccproxy/plugins/oauth_claude/storage.py +212 -0
  287. ccproxy/plugins/oauth_codex/README.md +38 -0
  288. ccproxy/plugins/oauth_codex/__init__.py +14 -0
  289. ccproxy/plugins/oauth_codex/client.py +224 -0
  290. ccproxy/plugins/oauth_codex/config.py +95 -0
  291. ccproxy/plugins/oauth_codex/manager.py +256 -0
  292. ccproxy/plugins/oauth_codex/models.py +239 -0
  293. ccproxy/plugins/oauth_codex/plugin.py +146 -0
  294. ccproxy/plugins/oauth_codex/provider.py +574 -0
  295. ccproxy/plugins/oauth_codex/storage.py +92 -0
  296. ccproxy/plugins/permissions/README.md +28 -0
  297. ccproxy/plugins/permissions/__init__.py +22 -0
  298. ccproxy/plugins/permissions/config.py +28 -0
  299. ccproxy/{cli/commands/permission_handler.py → plugins/permissions/handlers/cli.py} +49 -25
  300. ccproxy/plugins/permissions/handlers/protocol.py +33 -0
  301. ccproxy/plugins/permissions/handlers/terminal.py +675 -0
  302. ccproxy/{api/routes → plugins/permissions}/mcp.py +34 -7
  303. ccproxy/{models/permissions.py → plugins/permissions/models.py} +65 -1
  304. ccproxy/plugins/permissions/plugin.py +153 -0
  305. ccproxy/{api/routes/permissions.py → plugins/permissions/routes.py} +20 -16
  306. ccproxy/{api/services/permission_service.py → plugins/permissions/service.py} +65 -11
  307. ccproxy/{api → plugins/permissions}/ui/permission_handler_protocol.py +1 -1
  308. ccproxy/{api → plugins/permissions}/ui/terminal_permission_handler.py +66 -10
  309. ccproxy/plugins/pricing/README.md +34 -0
  310. ccproxy/plugins/pricing/__init__.py +6 -0
  311. ccproxy/{pricing → plugins/pricing}/cache.py +7 -6
  312. ccproxy/{config/pricing.py → plugins/pricing/config.py} +32 -6
  313. ccproxy/plugins/pricing/exceptions.py +35 -0
  314. ccproxy/plugins/pricing/loader.py +440 -0
  315. ccproxy/{pricing → plugins/pricing}/models.py +13 -23
  316. ccproxy/plugins/pricing/plugin.py +169 -0
  317. ccproxy/plugins/pricing/service.py +191 -0
  318. ccproxy/plugins/pricing/tasks.py +300 -0
  319. ccproxy/{pricing → plugins/pricing}/updater.py +86 -72
  320. ccproxy/plugins/pricing/utils.py +99 -0
  321. ccproxy/plugins/request_tracer/README.md +40 -0
  322. ccproxy/plugins/request_tracer/__init__.py +7 -0
  323. ccproxy/plugins/request_tracer/config.py +120 -0
  324. ccproxy/plugins/request_tracer/hook.py +415 -0
  325. ccproxy/plugins/request_tracer/plugin.py +255 -0
  326. ccproxy/scheduler/__init__.py +2 -14
  327. ccproxy/scheduler/core.py +26 -41
  328. ccproxy/scheduler/manager.py +63 -107
  329. ccproxy/scheduler/registry.py +6 -32
  330. ccproxy/scheduler/tasks.py +346 -314
  331. ccproxy/services/__init__.py +0 -1
  332. ccproxy/services/adapters/__init__.py +11 -0
  333. ccproxy/services/adapters/base.py +123 -0
  334. ccproxy/services/adapters/chain_composer.py +88 -0
  335. ccproxy/services/adapters/chain_validation.py +44 -0
  336. ccproxy/services/adapters/chat_accumulator.py +200 -0
  337. ccproxy/services/adapters/delta_utils.py +142 -0
  338. ccproxy/services/adapters/format_adapter.py +136 -0
  339. ccproxy/services/adapters/format_context.py +11 -0
  340. ccproxy/services/adapters/format_registry.py +158 -0
  341. ccproxy/services/adapters/http_adapter.py +1045 -0
  342. ccproxy/services/adapters/mock_adapter.py +118 -0
  343. ccproxy/services/adapters/protocols.py +35 -0
  344. ccproxy/services/adapters/simple_converters.py +571 -0
  345. ccproxy/services/auth_registry.py +180 -0
  346. ccproxy/services/cache/__init__.py +6 -0
  347. ccproxy/services/cache/response_cache.py +261 -0
  348. ccproxy/services/cli_detection.py +437 -0
  349. ccproxy/services/config/__init__.py +6 -0
  350. ccproxy/services/config/proxy_configuration.py +111 -0
  351. ccproxy/services/container.py +256 -0
  352. ccproxy/services/factories.py +380 -0
  353. ccproxy/services/handler_config.py +76 -0
  354. ccproxy/services/interfaces.py +298 -0
  355. ccproxy/services/mocking/__init__.py +6 -0
  356. ccproxy/services/mocking/mock_handler.py +291 -0
  357. ccproxy/services/tracing/__init__.py +7 -0
  358. ccproxy/services/tracing/interfaces.py +61 -0
  359. ccproxy/services/tracing/null_tracer.py +57 -0
  360. ccproxy/streaming/__init__.py +23 -0
  361. ccproxy/streaming/buffer.py +1056 -0
  362. ccproxy/streaming/deferred.py +897 -0
  363. ccproxy/streaming/handler.py +117 -0
  364. ccproxy/streaming/interfaces.py +77 -0
  365. ccproxy/streaming/simple_adapter.py +39 -0
  366. ccproxy/streaming/sse.py +109 -0
  367. ccproxy/streaming/sse_parser.py +127 -0
  368. ccproxy/templates/__init__.py +6 -0
  369. ccproxy/templates/plugin_scaffold.py +695 -0
  370. ccproxy/testing/endpoints/__init__.py +33 -0
  371. ccproxy/testing/endpoints/cli.py +215 -0
  372. ccproxy/testing/endpoints/config.py +874 -0
  373. ccproxy/testing/endpoints/console.py +57 -0
  374. ccproxy/testing/endpoints/models.py +100 -0
  375. ccproxy/testing/endpoints/runner.py +1903 -0
  376. ccproxy/testing/endpoints/tools.py +308 -0
  377. ccproxy/testing/mock_responses.py +70 -1
  378. ccproxy/testing/response_handlers.py +20 -0
  379. ccproxy/utils/__init__.py +0 -6
  380. ccproxy/utils/binary_resolver.py +476 -0
  381. ccproxy/utils/caching.py +327 -0
  382. ccproxy/utils/cli_logging.py +101 -0
  383. ccproxy/utils/command_line.py +251 -0
  384. ccproxy/utils/headers.py +228 -0
  385. ccproxy/utils/model_mapper.py +120 -0
  386. ccproxy/utils/startup_helpers.py +95 -342
  387. ccproxy/utils/version_checker.py +279 -6
  388. ccproxy_api-0.2.0.dist-info/METADATA +212 -0
  389. ccproxy_api-0.2.0.dist-info/RECORD +417 -0
  390. {ccproxy_api-0.1.6.dist-info → ccproxy_api-0.2.0.dist-info}/WHEEL +1 -1
  391. ccproxy_api-0.2.0.dist-info/entry_points.txt +24 -0
  392. ccproxy/__init__.py +0 -4
  393. ccproxy/adapters/__init__.py +0 -11
  394. ccproxy/adapters/base.py +0 -80
  395. ccproxy/adapters/codex/__init__.py +0 -11
  396. ccproxy/adapters/openai/__init__.py +0 -42
  397. ccproxy/adapters/openai/adapter.py +0 -953
  398. ccproxy/adapters/openai/models.py +0 -412
  399. ccproxy/adapters/openai/response_adapter.py +0 -355
  400. ccproxy/adapters/openai/response_models.py +0 -178
  401. ccproxy/api/middleware/headers.py +0 -49
  402. ccproxy/api/middleware/logging.py +0 -180
  403. ccproxy/api/middleware/request_content_logging.py +0 -297
  404. ccproxy/api/middleware/server_header.py +0 -58
  405. ccproxy/api/responses.py +0 -89
  406. ccproxy/api/routes/claude.py +0 -371
  407. ccproxy/api/routes/codex.py +0 -1231
  408. ccproxy/api/routes/metrics.py +0 -1029
  409. ccproxy/api/routes/proxy.py +0 -211
  410. ccproxy/api/services/__init__.py +0 -6
  411. ccproxy/auth/conditional.py +0 -84
  412. ccproxy/auth/credentials_adapter.py +0 -93
  413. ccproxy/auth/models.py +0 -118
  414. ccproxy/auth/oauth/models.py +0 -48
  415. ccproxy/auth/openai/__init__.py +0 -13
  416. ccproxy/auth/openai/credentials.py +0 -166
  417. ccproxy/auth/openai/oauth_client.py +0 -334
  418. ccproxy/auth/openai/storage.py +0 -184
  419. ccproxy/auth/storage/json_file.py +0 -158
  420. ccproxy/auth/storage/keyring.py +0 -189
  421. ccproxy/claude_sdk/__init__.py +0 -18
  422. ccproxy/claude_sdk/options.py +0 -194
  423. ccproxy/claude_sdk/session_pool.py +0 -550
  424. ccproxy/cli/docker/__init__.py +0 -34
  425. ccproxy/cli/docker/adapter_factory.py +0 -157
  426. ccproxy/cli/docker/params.py +0 -274
  427. ccproxy/config/auth.py +0 -153
  428. ccproxy/config/claude.py +0 -348
  429. ccproxy/config/cors.py +0 -79
  430. ccproxy/config/discovery.py +0 -95
  431. ccproxy/config/docker_settings.py +0 -264
  432. ccproxy/config/observability.py +0 -158
  433. ccproxy/config/reverse_proxy.py +0 -31
  434. ccproxy/config/scheduler.py +0 -108
  435. ccproxy/config/server.py +0 -86
  436. ccproxy/config/validators.py +0 -231
  437. ccproxy/core/codex_transformers.py +0 -389
  438. ccproxy/core/http.py +0 -328
  439. ccproxy/core/http_transformers.py +0 -812
  440. ccproxy/core/proxy.py +0 -143
  441. ccproxy/core/validators.py +0 -288
  442. ccproxy/models/errors.py +0 -42
  443. ccproxy/models/messages.py +0 -269
  444. ccproxy/models/requests.py +0 -107
  445. ccproxy/models/responses.py +0 -270
  446. ccproxy/models/types.py +0 -102
  447. ccproxy/observability/__init__.py +0 -51
  448. ccproxy/observability/access_logger.py +0 -457
  449. ccproxy/observability/sse_events.py +0 -303
  450. ccproxy/observability/stats_printer.py +0 -753
  451. ccproxy/observability/storage/__init__.py +0 -1
  452. ccproxy/observability/storage/duckdb_simple.py +0 -677
  453. ccproxy/observability/storage/models.py +0 -70
  454. ccproxy/observability/streaming_response.py +0 -107
  455. ccproxy/pricing/__init__.py +0 -19
  456. ccproxy/pricing/loader.py +0 -251
  457. ccproxy/services/claude_detection_service.py +0 -269
  458. ccproxy/services/codex_detection_service.py +0 -263
  459. ccproxy/services/credentials/__init__.py +0 -55
  460. ccproxy/services/credentials/config.py +0 -105
  461. ccproxy/services/credentials/manager.py +0 -561
  462. ccproxy/services/credentials/oauth_client.py +0 -481
  463. ccproxy/services/proxy_service.py +0 -1827
  464. ccproxy/static/.keep +0 -0
  465. ccproxy/utils/cost_calculator.py +0 -210
  466. ccproxy/utils/disconnection_monitor.py +0 -83
  467. ccproxy/utils/model_mapping.py +0 -199
  468. ccproxy/utils/models_provider.py +0 -150
  469. ccproxy/utils/simple_request_logger.py +0 -284
  470. ccproxy/utils/streaming_metrics.py +0 -199
  471. ccproxy_api-0.1.6.dist-info/METADATA +0 -615
  472. ccproxy_api-0.1.6.dist-info/RECORD +0 -189
  473. ccproxy_api-0.1.6.dist-info/entry_points.txt +0 -4
  474. /ccproxy/{api/middleware/auth.py → auth/models/__init__.py} +0 -0
  475. /ccproxy/{claude_sdk → plugins/claude_sdk}/exceptions.py +0 -0
  476. /ccproxy/{docker → plugins/docker}/models.py +0 -0
  477. /ccproxy/{docker → plugins/docker}/protocol.py +0 -0
  478. /ccproxy/{docker → plugins/docker}/validators.py +0 -0
  479. /ccproxy/{auth/oauth/storage.py → plugins/permissions/handlers/__init__.py} +0 -0
  480. /ccproxy/{api → plugins/permissions}/ui/__init__.py +0 -0
  481. {ccproxy_api-0.1.6.dist-info → ccproxy_api-0.2.0.dist-info}/licenses/LICENSE +0 -0
@@ -1,964 +1,1690 @@
1
1
  """Authentication and credential management commands."""
2
2
 
3
3
  import asyncio
4
+ import contextlib
5
+ import inspect
6
+ import logging
7
+ import os
8
+ from collections.abc import Coroutine
4
9
  from datetime import UTC, datetime
5
10
  from pathlib import Path
6
- from typing import TYPE_CHECKING, Annotated
7
-
8
-
9
- if TYPE_CHECKING:
10
- from ccproxy.auth.openai import OpenAIOAuthClient, OpenAITokenManager
11
- from ccproxy.config.codex import CodexSettings
11
+ from typing import Annotated, Any, cast
12
12
 
13
+ import structlog
13
14
  import typer
14
15
  from rich import box
15
16
  from rich.console import Console
16
17
  from rich.table import Table
17
- from structlog import get_logger
18
18
 
19
+ from ccproxy.auth.managers.token_snapshot import TokenSnapshot
20
+ from ccproxy.auth.oauth.cli_errors import (
21
+ AuthProviderError,
22
+ AuthTimedOutError,
23
+ AuthUserAbortedError,
24
+ NetworkError,
25
+ PortBindError,
26
+ )
27
+ from ccproxy.auth.oauth.flows import BrowserFlow, DeviceCodeFlow, ManualCodeFlow
28
+ from ccproxy.auth.oauth.registry import FlowType, OAuthRegistry
19
29
  from ccproxy.cli.helpers import get_rich_toolkit
20
- from ccproxy.config.settings import get_settings
21
- from ccproxy.core.async_utils import get_claude_docker_home_dir
22
- from ccproxy.services.credentials import CredentialsManager
30
+ from ccproxy.config.settings import Settings
31
+ from ccproxy.core.logging import bootstrap_cli_logging, get_logger, setup_logging
32
+ from ccproxy.core.plugins import load_cli_plugins
33
+ from ccproxy.core.plugins.hooks.manager import HookManager
34
+ from ccproxy.core.plugins.hooks.registry import HookRegistry
35
+ from ccproxy.services.container import ServiceContainer
23
36
 
24
37
 
25
- app = typer.Typer(name="auth", help="Authentication and credential management")
38
+ app = typer.Typer(
39
+ name="auth", help="Authentication and credential management", no_args_is_help=True
40
+ )
26
41
 
27
42
  console = Console()
28
43
  logger = get_logger(__name__)
29
44
 
30
45
 
31
- def get_credentials_manager(
32
- custom_paths: list[Path] | None = None,
33
- ) -> CredentialsManager:
34
- """Get a CredentialsManager instance with custom paths if provided."""
35
- if custom_paths:
36
- # Get base settings and update storage paths
37
- settings = get_settings()
38
- settings.auth.storage.storage_paths = custom_paths
39
- return CredentialsManager(config=settings.auth)
40
- else:
41
- # Use default settings
42
- settings = get_settings()
43
- return CredentialsManager(config=settings.auth)
44
-
45
-
46
- def get_docker_credential_paths() -> list[Path]:
47
- """Get credential file paths for Docker environment."""
48
- docker_home = Path(get_claude_docker_home_dir())
49
- return [
50
- docker_home / ".claude" / ".credentials.json",
51
- docker_home / ".config" / "claude" / ".credentials.json",
52
- Path(".credentials.json"),
53
- ]
54
-
55
-
56
- @app.command(name="validate")
57
- def validate_credentials(
58
- docker: Annotated[
59
- bool,
60
- typer.Option(
61
- "--docker",
62
- help="Use Docker credential paths (from get_claude_docker_home_dir())",
63
- ),
64
- ] = False,
65
- credential_file: Annotated[
66
- str | None,
67
- typer.Option(
68
- "--credential-file",
69
- help="Path to specific credential file to validate",
70
- ),
71
- ] = None,
72
- ) -> None:
73
- """Validate Claude CLI credentials.
46
+ # Cache settings and container to avoid repeated config file loading
47
+ _cached_settings: Settings | None = None
48
+ _cached_container: ServiceContainer | None = None
74
49
 
75
- Checks for valid Claude credentials in standard locations:
76
- - ~/.claude/credentials.json
77
- - ~/.config/claude/credentials.json
78
50
 
79
- With --docker flag, checks Docker credential paths:
80
- - {docker_home}/.claude/credentials.json
81
- - {docker_home}/.config/claude/credentials.json
51
+ @contextlib.contextmanager
52
+ def _temporary_disable_provider_storage(provider: Any, *, disable: bool) -> Any:
53
+ """Temporarily disable provider/client storage (used for custom credential paths)."""
82
54
 
83
- With --credential-file, validates the specified file directly.
55
+ if not disable:
56
+ yield
57
+ return
84
58
 
85
- Examples:
86
- ccproxy auth validate
87
- ccproxy auth validate --docker
88
- ccproxy auth validate --credential-file /path/to/credentials.json
89
- """
90
- toolkit = get_rich_toolkit()
91
- toolkit.print("[bold cyan]Claude Credentials Validation[/bold cyan]", centered=True)
92
- toolkit.print_line()
59
+ original_provider_storage = getattr(provider, "storage", None)
60
+ client = getattr(provider, "client", None)
61
+ original_client_storage = getattr(client, "storage", None) if client else None
93
62
 
94
63
  try:
95
- # Get credential paths based on options
96
- custom_paths = None
97
- if credential_file:
98
- custom_paths = [Path(credential_file)]
99
- elif docker:
100
- custom_paths = get_docker_credential_paths()
101
-
102
- # Validate credentials
103
- manager = get_credentials_manager(custom_paths)
104
- validation_result = asyncio.run(manager.validate())
105
-
106
- if validation_result.valid:
107
- # Create a status table
108
- table = Table(
109
- show_header=True,
110
- header_style="bold cyan",
111
- box=box.ROUNDED,
112
- title="Credential Status",
113
- title_style="bold white",
64
+ if hasattr(provider, "storage"):
65
+ provider.storage = None
66
+ if client is not None and hasattr(client, "storage"):
67
+ client.storage = None
68
+ yield
69
+ finally:
70
+ if hasattr(provider, "storage"):
71
+ provider.storage = original_provider_storage
72
+ if client is not None and hasattr(client, "storage"):
73
+ client.storage = original_client_storage
74
+
75
+
76
+ def _normalize_credentials_file_option(
77
+ toolkit: Any,
78
+ file_option: Path | None,
79
+ *,
80
+ require_exists: bool,
81
+ create_parent: bool = False,
82
+ ) -> Path | None:
83
+ """Resolve and validate a user-supplied credential file path."""
84
+
85
+ if file_option is None:
86
+ return None
87
+
88
+ custom_path = file_option.expanduser()
89
+ try:
90
+ custom_path = custom_path.resolve()
91
+ except FileNotFoundError:
92
+ # If parents do not exist, fall back to absolute path for messaging
93
+ custom_path = custom_path.absolute()
94
+
95
+ if custom_path.exists() and custom_path.is_dir():
96
+ toolkit.print(
97
+ f"Target path '{custom_path}' is a directory. Provide a file path.",
98
+ tag="error",
99
+ )
100
+ raise typer.Exit(1)
101
+
102
+ if require_exists and not custom_path.exists():
103
+ toolkit.print(
104
+ f"Credential file '{custom_path}' not found.",
105
+ tag="error",
106
+ )
107
+ raise typer.Exit(1)
108
+
109
+ if create_parent:
110
+ try:
111
+ custom_path.parent.mkdir(parents=True, exist_ok=True)
112
+ except Exception as exc:
113
+ toolkit.print(
114
+ f"Failed to create directory '{custom_path.parent}': {exc}",
115
+ tag="error",
114
116
  )
115
- table.add_column("Property", style="cyan")
116
- table.add_column("Value", style="white")
117
-
118
- # Status
119
- status = "Valid" if not validation_result.expired else "Expired"
120
- status_style = "green" if not validation_result.expired else "red"
121
- table.add_row("Status", f"[{status_style}]{status}[/{status_style}]")
122
-
123
- # Path
124
- if validation_result.path:
125
- table.add_row("Location", f"[dim]{validation_result.path}[/dim]")
126
-
127
- # Subscription type
128
- if validation_result.credentials:
129
- sub_type = (
130
- validation_result.credentials.claude_ai_oauth.subscription_type
131
- or "Unknown"
132
- )
133
- table.add_row("Subscription", f"[bold]{sub_type}[/bold]")
134
-
135
- # Expiration
136
- oauth_token = validation_result.credentials.claude_ai_oauth
137
- exp_dt = oauth_token.expires_at_datetime
138
- now = datetime.now(UTC)
139
- time_diff = exp_dt - now
140
-
141
- if time_diff.total_seconds() > 0:
142
- days = time_diff.days
143
- hours = time_diff.seconds // 3600
144
- exp_str = f"{exp_dt.strftime('%Y-%m-%d %H:%M:%S UTC')} ({days}d {hours}h remaining)"
145
- else:
146
- exp_str = f"{exp_dt.strftime('%Y-%m-%d %H:%M:%S UTC')} [red](Expired)[/red]"
117
+ raise typer.Exit(1) from exc
147
118
 
148
- table.add_row("Expires", exp_str)
119
+ return custom_path
149
120
 
150
- # Scopes
151
- scopes = oauth_token.scopes
152
- if scopes:
153
- table.add_row("Scopes", ", ".join(str(s) for s in scopes))
154
121
 
155
- console.print(table)
122
+ def _get_cached_settings() -> Settings:
123
+ """Get cached settings instance."""
124
+ global _cached_settings
125
+ if _cached_settings is None:
126
+ _cached_settings = Settings.from_config()
127
+ return _cached_settings
156
128
 
157
- # Success message
158
- if not validation_result.expired:
159
- toolkit.print(
160
- "[green]✓[/green] Valid Claude credentials found", tag="success"
161
- )
162
- else:
163
- toolkit.print(
164
- "[yellow]![/yellow] Claude credentials found but expired",
165
- tag="warning",
166
- )
167
- toolkit.print(
168
- "\nPlease refresh your credentials by logging into Claude CLI",
169
- tag="info",
170
- )
171
129
 
172
- else:
173
- # No valid credentials
174
- toolkit.print("[red]✗[/red] No credentials file found", tag="error")
130
+ def _get_service_container() -> ServiceContainer:
131
+ """Create a service container for the auth commands."""
132
+ global _cached_container
133
+ if _cached_container is None:
134
+ settings = _get_cached_settings()
135
+ _cached_container = ServiceContainer(settings)
136
+ return _cached_container
175
137
 
176
- console.print("\n[dim]To authenticate with Claude CLI, run:[/dim]")
177
- console.print("[cyan]claude login[/cyan]")
178
138
 
179
- except Exception as e:
180
- toolkit.print(f"Error validating credentials: {e}", tag="error")
181
- raise typer.Exit(1) from e
139
+ def _apply_auth_logger_level() -> None:
140
+ """Set logger level from settings without configuring handlers."""
141
+ try:
142
+ settings = _get_cached_settings()
143
+ level_name = settings.logging.level
144
+ level = getattr(logging, level_name.upper(), logging.INFO)
145
+ except Exception:
146
+ level = logging.INFO
182
147
 
148
+ logging.getLogger("ccproxy").setLevel(level)
149
+ logging.getLogger(__name__).setLevel(level)
183
150
 
184
- @app.command(name="info")
185
- def credential_info(
186
- docker: Annotated[
187
- bool,
188
- typer.Option(
189
- "--docker",
190
- help="Use Docker credential paths (from get_claude_docker_home_dir())",
191
- ),
192
- ] = False,
193
- credential_file: Annotated[
194
- str | None,
195
- typer.Option(
196
- "--credential-file",
197
- help="Path to specific credential file to display info for",
198
- ),
199
- ] = None,
200
- ) -> None:
201
- """Display detailed credential information.
202
151
 
203
- Shows all available information about Claude credentials including
204
- file location, token details, and subscription information.
152
+ def _ensure_logging_configured() -> None:
153
+ """Ensure global logging is configured with the standard format."""
154
+ if structlog.is_configured():
155
+ return
205
156
 
206
- Examples:
207
- ccproxy auth info
208
- ccproxy auth info --docker
209
- ccproxy auth info --credential-file /path/to/credentials.json
210
- """
211
- toolkit = get_rich_toolkit()
212
- toolkit.print("[bold cyan]Claude Credential Information[/bold cyan]", centered=True)
213
- toolkit.print_line()
157
+ with contextlib.suppress(Exception):
158
+ bootstrap_cli_logging()
159
+
160
+ if structlog.is_configured():
161
+ return
214
162
 
163
+ level_name = os.getenv("LOGGING__LEVEL", "INFO")
164
+ log_file = os.getenv("LOGGING__FILE")
215
165
  try:
216
- # Get credential paths based on options
217
- custom_paths = None
218
- if credential_file:
219
- custom_paths = [Path(credential_file)]
220
- elif docker:
221
- custom_paths = get_docker_credential_paths()
166
+ setup_logging(json_logs=False, log_level_name=level_name, log_file=log_file)
167
+ except Exception:
168
+ _apply_auth_logger_level()
222
169
 
223
- # Get credentials manager and try to load credentials
224
- manager = get_credentials_manager(custom_paths)
225
- credentials = asyncio.run(manager.load())
226
170
 
227
- if not credentials:
228
- toolkit.print("No credential file found", tag="error")
229
- console.print("\n[dim]Expected locations:[/dim]")
230
- for path in manager.config.storage.storage_paths:
231
- console.print(f" - {path}")
232
- raise typer.Exit(1)
171
+ def _expected_plugin_class_name(provider: str) -> str:
172
+ """Return the expected plugin class name from provider input for messaging."""
173
+ import re
233
174
 
234
- # Display account section
235
- console.print("\n[bold]Account[/bold]")
236
- oauth = credentials.claude_ai_oauth
237
-
238
- # Login method based on subscription type
239
- login_method = "Claude Account"
240
- if oauth.subscription_type:
241
- login_method = f"Claude {oauth.subscription_type.title()} Account"
242
- console.print(f" L Login Method: {login_method}")
243
-
244
- # Try to load saved account profile first
245
- profile = asyncio.run(manager.get_account_profile())
246
-
247
- if profile:
248
- # Display saved account data
249
- if profile.organization:
250
- console.print(f" L Organization: {profile.organization.name}")
251
- if profile.organization.organization_type:
252
- console.print(
253
- f" L Organization Type: {profile.organization.organization_type}"
254
- )
255
- if profile.organization.billing_type:
256
- console.print(
257
- f" L Billing Type: {profile.organization.billing_type}"
258
- )
259
- if profile.organization.rate_limit_tier:
260
- console.print(
261
- f" L Rate Limit Tier: {profile.organization.rate_limit_tier}"
262
- )
263
- else:
264
- console.print(" L Organization: [dim]Not available[/dim]")
265
-
266
- if profile.account:
267
- console.print(f" L Email: {profile.account.email}")
268
- if profile.account.full_name:
269
- console.print(f" L Full Name: {profile.account.full_name}")
270
- if profile.account.display_name:
271
- console.print(f" L Display Name: {profile.account.display_name}")
272
- console.print(
273
- f" L Has Claude Pro: {'Yes' if profile.account.has_claude_pro else 'No'}"
274
- )
275
- console.print(
276
- f" L Has Claude Max: {'Yes' if profile.account.has_claude_max else 'No'}"
277
- )
278
- else:
279
- console.print(" L Email: [dim]Not available[/dim]")
280
- else:
281
- # No saved profile, try to fetch fresh data
282
- try:
283
- # First try to get a valid access token (with refresh if needed)
284
- valid_token = asyncio.run(manager.get_access_token())
285
- if valid_token:
286
- profile = asyncio.run(manager.fetch_user_profile())
287
- if profile:
288
- # Save the profile for future use
289
- asyncio.run(manager._save_account_profile(profile))
290
-
291
- if profile.organization:
292
- console.print(
293
- f" L Organization: {profile.organization.name}"
294
- )
295
- else:
296
- console.print(
297
- " L Organization: [dim]Unable to fetch[/dim]"
298
- )
175
+ base = re.sub(r"[^a-zA-Z0-9]+", "_", provider.strip()).strip("_")
176
+ parts = [p for p in base.split("_") if p]
177
+ camel = "".join(s[:1].upper() + s[1:] for s in parts)
178
+ return f"Oauth{camel}Plugin"
299
179
 
300
- if profile.account:
301
- console.print(f" L Email: {profile.account.email}")
302
- else:
303
- console.print(" L Email: [dim]Unable to fetch[/dim]")
304
- else:
305
- console.print(" L Organization: [dim]Unable to fetch[/dim]")
306
- console.print(" L Email: [dim]Unable to fetch[/dim]")
307
180
 
308
- # Reload credentials after potential refresh to show updated token info
309
- credentials = asyncio.run(manager.load())
310
- if credentials:
311
- oauth = credentials.claude_ai_oauth
312
- else:
313
- console.print(" L Organization: [dim]Token refresh failed[/dim]")
314
- console.print(" L Email: [dim]Token refresh failed[/dim]")
315
- except Exception as e:
316
- logger.debug(f"Could not fetch user profile: {e}")
317
- console.print(" L Organization: [dim]Unable to fetch[/dim]")
318
- console.print(" L Email: [dim]Unable to fetch[/dim]")
181
+ def _token_snapshot_from_credentials(
182
+ credentials: Any, provider: str | None = None
183
+ ) -> TokenSnapshot | None:
184
+ """Best-effort conversion of provider credentials into a token snapshot.
319
185
 
320
- # Create details table
321
- console.print()
322
- table = Table(
323
- show_header=True,
324
- header_style="bold cyan",
325
- box=box.ROUNDED,
326
- title="Credential Details",
327
- title_style="bold white",
328
- )
329
- table.add_column("Property", style="cyan")
330
- table.add_column("Value", style="white")
186
+ Uses the BaseCredentials protocol instead of direct imports to avoid boundary violations.
187
+ """
188
+ from ccproxy.auth.models.credentials import BaseCredentials
331
189
 
332
- # File location - check if there's a credentials file or if using keyring
333
- cred_file = asyncio.run(manager.find_credentials_file())
334
- if cred_file:
335
- table.add_row("File Location", str(cred_file))
336
- else:
337
- table.add_row("File Location", "Keyring storage")
190
+ # Check if credentials follow the BaseCredentials protocol
191
+ if not isinstance(credentials, BaseCredentials):
192
+ # If not following the protocol, try to extract basic info using duck typing
193
+ return _extract_token_snapshot_duck_typing(credentials, provider)
338
194
 
339
- # Token info
340
- table.add_row("Subscription Type", oauth.subscription_type or "Unknown")
341
- table.add_row(
342
- "Token Expired",
343
- "[red]Yes[/red]" if oauth.is_expired else "[green]No[/green]",
195
+ # Use the protocol methods
196
+ try:
197
+ data = credentials.to_dict()
198
+ return _build_token_snapshot_from_dict(data, provider)
199
+ except Exception:
200
+ return None
201
+
202
+
203
+ def _extract_token_snapshot_duck_typing(
204
+ credentials: Any, provider: str | None = None
205
+ ) -> TokenSnapshot | None:
206
+ """Extract token snapshot using duck typing for non-protocol credentials."""
207
+ if not credentials:
208
+ return None
209
+
210
+ # Generic duck-typing approach - look for common attributes
211
+ access_token: str | None = None
212
+ refresh_token: str | None = None
213
+ expires_at: datetime | None = None
214
+ account_id: str | None = None
215
+ extras: dict[str, Any] = {}
216
+
217
+ # Try to extract access token from various possible attributes
218
+ for attr in ["access_token", "token"]:
219
+ if hasattr(credentials, attr):
220
+ token_obj = getattr(credentials, attr)
221
+ if token_obj:
222
+ if hasattr(token_obj, "get_secret_value"):
223
+ access_token = token_obj.get_secret_value()
224
+ elif isinstance(token_obj, str):
225
+ access_token = token_obj
226
+ break
227
+
228
+ # Try to extract refresh token
229
+ if hasattr(credentials, "refresh_token"):
230
+ refresh_obj = credentials.refresh_token
231
+ if refresh_obj and hasattr(refresh_obj, "get_secret_value"):
232
+ refresh_token = refresh_obj.get_secret_value()
233
+ elif isinstance(refresh_obj, str):
234
+ refresh_token = refresh_obj
235
+
236
+ # Try to extract expiration
237
+ for attr in ["expires_at", "expires_at_datetime", "expiry"]:
238
+ if hasattr(credentials, attr):
239
+ expires_obj = getattr(credentials, attr)
240
+ if isinstance(expires_obj, datetime):
241
+ expires_at = expires_obj
242
+ break
243
+
244
+ # Try to extract account ID
245
+ for attr in ["account_id", "user_id", "id"]:
246
+ if hasattr(credentials, attr):
247
+ id_obj = getattr(credentials, attr)
248
+ if isinstance(id_obj, str):
249
+ account_id = id_obj
250
+ break
251
+
252
+ if not access_token:
253
+ return None
254
+
255
+ return TokenSnapshot(
256
+ provider=provider or "unknown",
257
+ account_id=account_id,
258
+ access_token=access_token,
259
+ refresh_token=refresh_token,
260
+ expires_at=expires_at,
261
+ extras={},
262
+ )
263
+
264
+
265
+ def _build_token_snapshot_from_dict(
266
+ data: dict[str, Any], provider: str | None = None
267
+ ) -> TokenSnapshot | None:
268
+ """Build token snapshot from dictionary data."""
269
+ if not data:
270
+ return None
271
+
272
+ def _unwrap_secret(value: Any) -> str | None:
273
+ """Return plain string from SecretStr-like values."""
274
+ if value is None:
275
+ return None
276
+ if hasattr(value, "get_secret_value"):
277
+ try:
278
+ result = value.get_secret_value()
279
+ return str(result) if result is not None else None
280
+ except Exception:
281
+ return None
282
+ if isinstance(value, str):
283
+ return value or None
284
+ return None
285
+
286
+ def _coerce_datetime(value: Any) -> datetime | None:
287
+ """Convert supported values into timezone-aware datetime objects."""
288
+ if value is None:
289
+ return None
290
+ if isinstance(value, datetime):
291
+ return value if value.tzinfo else value.replace(tzinfo=UTC)
292
+ if isinstance(value, str):
293
+ try:
294
+ parsed = datetime.fromisoformat(value)
295
+ except ValueError:
296
+ return None
297
+ return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC)
298
+ if isinstance(value, int | float):
299
+ timestamp = float(value)
300
+ # Treat very large integers as millisecond timestamps
301
+ if timestamp > 1e11:
302
+ timestamp /= 1000
303
+ try:
304
+ return datetime.fromtimestamp(timestamp, tz=UTC)
305
+ except (OSError, ValueError):
306
+ return None
307
+ return None
308
+
309
+ provider_value = provider or data.get("provider") or "unknown"
310
+ provider_normalized = provider_value.replace("_", "-")
311
+
312
+ extras: dict[str, Any] = dict(data.get("extras", {}))
313
+ scopes: tuple[str, ...] = tuple(
314
+ str(scope) for scope in data.get("scopes", []) if scope
315
+ )
316
+
317
+ account_id: str | None = _unwrap_secret(data.get("account_id"))
318
+ access_token: str | None = _unwrap_secret(data.get("access_token"))
319
+ refresh_token: str | None = _unwrap_secret(data.get("refresh_token"))
320
+ expires_at: datetime | None = _coerce_datetime(data.get("expires_at"))
321
+
322
+ claude_data = data.get("claudeAiOauth") or data.get("claude_ai_oauth")
323
+ if isinstance(claude_data, dict):
324
+ provider_normalized = "claude-api"
325
+ access_token = access_token or _unwrap_secret(
326
+ claude_data.get("accessToken") or claude_data.get("access_token")
344
327
  )
345
-
346
- # Expiration details
347
- exp_dt = oauth.expires_at_datetime
348
- table.add_row("Expires At", exp_dt.strftime("%Y-%m-%d %H:%M:%S UTC"))
349
-
350
- # Time until expiration
351
- now = datetime.now(UTC)
352
- time_diff = exp_dt - now
353
- if time_diff.total_seconds() > 0:
354
- days = time_diff.days
355
- hours = (time_diff.seconds % 86400) // 3600
356
- minutes = (time_diff.seconds % 3600) // 60
357
- table.add_row(
358
- "Time Remaining", f"{days} days, {hours} hours, {minutes} minutes"
359
- )
360
- else:
361
- table.add_row("Time Remaining", "[red]Expired[/red]")
362
-
363
- # Scopes
364
- if oauth.scopes:
365
- table.add_row("OAuth Scopes", ", ".join(oauth.scopes))
366
-
367
- # Token preview (first and last 8 chars)
368
- if oauth.access_token:
369
- token_preview = f"{oauth.access_token[:8]}...{oauth.access_token[-8:]}"
370
- table.add_row("Access Token", f"[dim]{token_preview}[/dim]")
371
-
372
- # Account profile status
373
- account_profile_exists = profile is not None
374
- table.add_row(
375
- "Account Profile",
376
- "[green]Available[/green]"
377
- if account_profile_exists
378
- else "[yellow]Not saved[/yellow]",
328
+ refresh_token = refresh_token or _unwrap_secret(
329
+ claude_data.get("refreshToken") or claude_data.get("refresh_token")
330
+ )
331
+ expires_at = expires_at or _coerce_datetime(
332
+ claude_data.get("expiresAt") or claude_data.get("expires_at")
333
+ )
334
+ scopes = tuple(str(scope) for scope in claude_data.get("scopes", []) if scope)
335
+ subscription = claude_data.get("subscriptionType") or claude_data.get(
336
+ "subscription_type"
337
+ )
338
+ if subscription:
339
+ extras.setdefault("subscription_type", subscription)
340
+
341
+ tokens_data = data.get("tokens")
342
+ if isinstance(tokens_data, dict):
343
+ provider_normalized = "codex"
344
+ access_token = access_token or _unwrap_secret(tokens_data.get("access_token"))
345
+ refresh_token = refresh_token or _unwrap_secret(
346
+ tokens_data.get("refresh_token")
347
+ )
348
+ account_id = account_id or tokens_data.get("account_id")
349
+ if "id_token_present" not in extras:
350
+ extras["id_token_present"] = bool(tokens_data.get("id_token"))
351
+
352
+ oauth_token_data = data.get("oauth_token") or data.get("oauthToken")
353
+ copilot_token_data = data.get("copilot_token") or data.get("copilotToken")
354
+ if isinstance(oauth_token_data, dict) or isinstance(copilot_token_data, dict):
355
+ provider_normalized = "copilot"
356
+
357
+ if isinstance(copilot_token_data, dict):
358
+ token_value = _unwrap_secret(copilot_token_data.get("token"))
359
+ if token_value:
360
+ access_token = token_value
361
+ expires_at = (
362
+ _coerce_datetime(copilot_token_data.get("expires_at")) or expires_at
379
363
  )
364
+ extras.setdefault("has_copilot_token", True)
380
365
 
366
+ if isinstance(oauth_token_data, dict):
367
+ access_token = access_token or _unwrap_secret(
368
+ oauth_token_data.get("access_token")
369
+ )
370
+ refresh_token = refresh_token or _unwrap_secret(
371
+ oauth_token_data.get("refresh_token")
372
+ )
373
+ scope_field = oauth_token_data.get("scope") or ""
374
+ if scope_field and not scopes:
375
+ scopes = tuple(
376
+ scope
377
+ for scope in (item.strip() for item in str(scope_field).split(" "))
378
+ if scope
379
+ )
380
+ if not extras.get("has_copilot_token"):
381
+ extras["has_copilot_token"] = False
382
+ if not expires_at:
383
+ created_at = oauth_token_data.get("created_at")
384
+ expires_in = oauth_token_data.get("expires_in")
385
+ if isinstance(created_at, int | float) and isinstance(
386
+ expires_in, int | float
387
+ ):
388
+ expires_at = _coerce_datetime(created_at + expires_in)
389
+
390
+ if provider_normalized == "copilot":
391
+ if "refresh_token_present" not in extras:
392
+ extras["refresh_token_present"] = bool(refresh_token)
393
+ extras.setdefault("id_token_present", bool(extras.get("has_copilot_token")))
394
+
395
+ return TokenSnapshot(
396
+ provider=provider_normalized,
397
+ account_id=account_id,
398
+ access_token=access_token,
399
+ refresh_token=refresh_token,
400
+ expires_at=expires_at,
401
+ scopes=scopes,
402
+ extras=extras,
403
+ )
404
+
405
+
406
+ def _render_profile_table(
407
+ profile: dict[str, Any],
408
+ title: str = "Account Information",
409
+ ) -> None:
410
+ """Render a clean, two-column table of profile data using Rich."""
411
+ table = Table(show_header=False, box=box.SIMPLE, title=title)
412
+ table.add_column("Field", style="bold")
413
+ table.add_column("Value")
414
+
415
+ def _val(v: Any) -> str:
416
+ if v is None:
417
+ return ""
418
+ if hasattr(v, "isoformat"):
419
+ try:
420
+ return str(v)
421
+ except Exception:
422
+ return str(v)
423
+ if isinstance(v, bool):
424
+ return "Yes" if v else "No"
425
+ if isinstance(v, list):
426
+ return ", ".join(str(x) for x in v)
427
+ s = str(v)
428
+ return s
429
+
430
+ def _row(label: str, key: str) -> None:
431
+ if key in profile and profile[key] not in (None, "", []):
432
+ table.add_row(label, _val(profile[key]))
433
+
434
+ _row("Provider", "provider_type")
435
+ _row("Account ID", "account_id")
436
+ _row("Email", "email")
437
+ _row("Display Name", "display_name")
438
+
439
+ _row("Subscription", "subscription_type")
440
+ _row("Subscription Status", "subscription_status")
441
+ _row("Subscription Expires", "subscription_expires_at")
442
+
443
+ _row("Organization", "organization_name")
444
+ _row("Organization Role", "organization_role")
445
+
446
+ _row("Has Refresh Token", "has_refresh_token")
447
+ _row("Has ID Token", "has_id_token")
448
+ _row("Token Expires", "token_expires_at")
449
+
450
+ _row("Email Verified", "email_verified")
451
+
452
+ if len(table.rows) > 0:
381
453
  console.print(table)
382
454
 
383
- except Exception as e:
384
- toolkit.print(f"Error getting credential info: {e}", tag="error")
385
- raise typer.Exit(1) from e
386
455
 
456
+ def _render_profile_features(profile: dict[str, Any]) -> None:
457
+ """Render provider-specific features if present."""
458
+ features = profile.get("features")
459
+ if isinstance(features, dict) and features:
460
+ table = Table(show_header=False, box=box.SIMPLE, title="Features")
461
+ table.add_column("Feature", style="bold")
462
+ table.add_column("Value")
463
+ for k, v in features.items():
464
+ name = k.replace("_", " ").title()
465
+ val = (
466
+ "Yes"
467
+ if isinstance(v, bool) and v
468
+ else ("No" if isinstance(v, bool) else str(v))
469
+ )
470
+ if val and val != "No":
471
+ table.add_row(name, val)
472
+ if len(table.rows) > 0:
473
+ console.print(table)
474
+
475
+
476
+ def _provider_plugin_name(provider: str) -> str | None:
477
+ """Map CLI provider name to plugin manifest name."""
478
+ key = provider.strip().lower()
479
+ mapping: dict[str, str] = {
480
+ "codex": "oauth_codex",
481
+ "claude-api": "oauth_claude",
482
+ "claude_api": "oauth_claude",
483
+ }
484
+ return mapping.get(key)
485
+
486
+
487
+ def _await_if_needed(value: Any) -> Any:
488
+ """Await coroutine values in synchronous CLI context."""
489
+ if inspect.isawaitable(value):
490
+ return asyncio.run(cast(Coroutine[Any, Any, Any], value))
491
+ return value
492
+
493
+
494
+ def _resolve_token_manager_from_registry(
495
+ provider: str, oauth_provider: Any, container: ServiceContainer
496
+ ) -> Any | None:
497
+ """Try fetching an auth manager from the global registry."""
498
+ try:
499
+ registry = container.get_auth_manager_registry()
500
+ except Exception as exc: # pragma: no cover - defensive
501
+ logger.debug("auth_manager_registry_unavailable", error=str(exc))
502
+ return None
503
+
504
+ candidates: list[str] = []
505
+
506
+ def _push(name: str | None) -> None:
507
+ if not name:
508
+ return
509
+ normalized = name.strip()
510
+ if not normalized:
511
+ return
512
+ for variant in {
513
+ normalized,
514
+ normalized.replace("-", "_"),
515
+ }: # normalize hyphen/underscore
516
+ if variant not in candidates:
517
+ candidates.append(variant)
518
+
519
+ _push(provider)
520
+ _push(_provider_plugin_name(provider))
521
+ _push(getattr(oauth_provider, "provider_name", None))
522
+
523
+ try:
524
+ info = oauth_provider.get_provider_info()
525
+ _push(getattr(info, "plugin_name", None))
526
+ except Exception as exc: # pragma: no cover - defensive logging only
527
+ logger.debug("provider_info_lookup_failed", error=str(exc))
528
+
529
+ for candidate in candidates:
530
+ try:
531
+ manager = asyncio.run(registry.get(candidate))
532
+ except Exception as exc: # pragma: no cover - defensive
533
+ logger.debug(
534
+ "auth_manager_registry_get_failed", name=candidate, error=str(exc)
535
+ )
536
+ continue
537
+ if manager:
538
+ return manager
539
+
540
+ return None
387
541
 
388
- @app.command(name="login")
389
- def login_command(
390
- docker: Annotated[
391
- bool,
392
- typer.Option(
393
- "--docker",
394
- help="Use Docker credential paths (from get_claude_docker_home_dir())",
395
- ),
396
- ] = False,
397
- credential_file: Annotated[
398
- str | None,
399
- typer.Option(
400
- "--credential-file",
401
- help="Path to specific credential file to save to",
402
- ),
403
- ] = None,
404
- ) -> None:
405
- """Login to Claude using OAuth authentication.
406
542
 
407
- This command will open your web browser to authenticate with Claude
408
- and save the credentials locally.
543
+ def _resolve_token_manager(
544
+ provider: str, oauth_provider: Any, container: ServiceContainer
545
+ ) -> Any | None:
546
+ """Resolve token manager via registry or provider helpers."""
547
+ manager = _resolve_token_manager_from_registry(provider, oauth_provider, container)
548
+ if manager:
549
+ return manager
409
550
 
410
- Examples:
411
- ccproxy auth login
412
- ccproxy auth login --docker
413
- ccproxy auth login --credential-file /path/to/credentials.json
551
+ if hasattr(oauth_provider, "get_token_manager"):
552
+ try:
553
+ candidate = oauth_provider.get_token_manager()
554
+ manager = _await_if_needed(candidate)
555
+ if manager:
556
+ return manager
557
+ except Exception as exc: # pragma: no cover - defensive logging
558
+ logger.debug("get_token_manager_failed", error=str(exc))
559
+
560
+ if hasattr(oauth_provider, "create_token_manager"):
561
+ try:
562
+ candidate = oauth_provider.create_token_manager()
563
+ manager = _await_if_needed(candidate)
564
+ if manager:
565
+ return manager
566
+ except Exception as exc: # pragma: no cover - defensive logging
567
+ logger.debug("create_token_manager_failed", error=str(exc))
568
+
569
+ return None
570
+
571
+
572
+ def _format_seconds(seconds: int | None) -> str:
573
+ """Format seconds into a short human-readable duration."""
574
+ if seconds is None:
575
+ return "Unknown"
576
+ if seconds <= 0:
577
+ return "Expired"
578
+
579
+ remaining = int(seconds)
580
+ parts: list[str] = []
581
+ for label, divisor in (("d", 86_400), ("h", 3_600), ("m", 60)):
582
+ value, remaining = divmod(remaining, divisor)
583
+ if value:
584
+ parts.append(f"{value}{label}")
585
+ if len(parts) == 2:
586
+ break
587
+
588
+ if remaining and len(parts) < 2:
589
+ parts.append(f"{remaining}s")
590
+
591
+ return " ".join(parts) if parts else "<1s"
592
+
593
+
594
+ async def _lazy_register_oauth_provider(
595
+ provider: str,
596
+ registry: OAuthRegistry,
597
+ container: ServiceContainer,
598
+ ) -> Any | None:
599
+ """Initialize filtered CLI plugin system and ensure provider is registered.
600
+
601
+ This bootstraps the hook system and initializes only CLI-safe plugins plus
602
+ the specific auth provider needed. This avoids DuckDB locks, task manager
603
+ errors, and other side effects from heavy provider plugins.
414
604
  """
605
+ settings = container.get_service(Settings)
606
+
607
+ # Respect global plugin enablement flag
608
+ if not getattr(settings, "enable_plugins", True):
609
+ return None
610
+
611
+ # Load only CLI-safe plugins + the specific auth provider needed
612
+ plugin_registry = load_cli_plugins(settings, auth_provider=provider)
613
+
614
+ # Create hook system for CLI HTTP flows
615
+ hook_registry = HookRegistry()
616
+ hook_manager = HookManager(hook_registry)
617
+ # Make HookManager available to any services resolved from the container
618
+ with contextlib.suppress(Exception):
619
+ container.register_service(HookManager, instance=hook_manager)
620
+
621
+ # Provide core services needed by plugins at runtime
622
+
623
+ try:
624
+ # Initialize all plugins; auth providers will register to oauth_registry
625
+ import asyncio as _asyncio
626
+
627
+ if _asyncio.get_event_loop().is_running():
628
+ # In practice, we're already in async context; just await directly
629
+ await plugin_registry.initialize_all(container)
630
+ else: # pragma: no cover - defensive path
631
+ _asyncio.run(plugin_registry.initialize_all(container))
632
+ except Exception as e:
633
+ logger.debug(
634
+ "plugin_initialization_failed_cli",
635
+ error=str(e),
636
+ exc_info=e,
637
+ category="auth",
638
+ )
639
+
640
+ # Normalize provider key and return the registered provider instance
641
+ def _norm(p: str) -> str:
642
+ key = p.strip().lower().replace("_", "-")
643
+ if key in {"claude", "claude-api"}:
644
+ return "claude-api"
645
+ if key in {"codex", "openai", "openai-api"}:
646
+ return "codex"
647
+ return key
648
+
649
+ try:
650
+ return registry.get(_norm(provider))
651
+ except Exception:
652
+ return None
653
+
654
+
655
+ async def discover_oauth_providers(
656
+ container: ServiceContainer,
657
+ ) -> dict[str, tuple[str, str]]:
658
+ """Return available OAuth providers discovered via the plugin loader."""
659
+ providers: dict[str, tuple[str, str]] = {}
660
+ try:
661
+ settings = container.get_service(Settings)
662
+ # For discovery, we can load all plugins temporarily since we don't initialize them
663
+ from ccproxy.core.plugins import load_plugin_system
664
+
665
+ registry, _ = load_plugin_system(settings)
666
+ for name, factory in registry.factories.items():
667
+ from ccproxy.core.plugins import AuthProviderPluginFactory
668
+
669
+ if isinstance(factory, AuthProviderPluginFactory):
670
+ if name == "oauth_claude":
671
+ providers["claude-api"] = ("oauth", "Claude API OAuth")
672
+ elif name == "oauth_codex":
673
+ providers["codex"] = ("oauth", "OpenAI Codex OAuth")
674
+ elif name == "copilot":
675
+ providers["copilot"] = ("oauth", "GitHub Copilot OAuth")
676
+ except Exception as e:
677
+ logger.debug("discover_oauth_providers_failed", error=str(e), exc_info=e)
678
+ return providers
679
+
680
+
681
+ def get_oauth_provider_choices() -> list[str]:
682
+ """Get list of available OAuth provider names for CLI choices."""
683
+ container = _get_service_container()
684
+ providers = asyncio.run(discover_oauth_providers(container))
685
+ return list(providers.keys())
686
+
687
+
688
+ async def get_oauth_client_for_provider(
689
+ provider: str,
690
+ registry: OAuthRegistry,
691
+ container: ServiceContainer,
692
+ ) -> Any:
693
+ """Get OAuth client for the specified provider."""
694
+ oauth_provider = await get_oauth_provider_for_name(provider, registry, container)
695
+ if not oauth_provider:
696
+ raise ValueError(f"Provider '{provider}' not found")
697
+ oauth_client = getattr(oauth_provider, "client", None)
698
+ if not oauth_client:
699
+ raise ValueError(f"Provider '{provider}' does not implement OAuth client")
700
+ return oauth_client
701
+
702
+
703
+ async def check_provider_credentials(
704
+ provider: str,
705
+ registry: OAuthRegistry,
706
+ container: ServiceContainer,
707
+ ) -> dict[str, Any]:
708
+ """Check if provider has valid stored credentials."""
709
+ try:
710
+ oauth_provider = await get_oauth_provider_for_name(
711
+ provider, registry, container
712
+ )
713
+ if not oauth_provider:
714
+ return {
715
+ "has_credentials": False,
716
+ "expired": True,
717
+ "path": None,
718
+ "credentials": None,
719
+ }
720
+
721
+ creds = await oauth_provider.load_credentials()
722
+ has_credentials = creds is not None
723
+
724
+ return {
725
+ "has_credentials": has_credentials,
726
+ "expired": not has_credentials,
727
+ "path": None,
728
+ "credentials": None,
729
+ }
730
+
731
+ except AttributeError as e:
732
+ logger.debug(
733
+ "credentials_check_missing_attribute",
734
+ provider=provider,
735
+ error=str(e),
736
+ exc_info=e,
737
+ )
738
+ return {
739
+ "has_credentials": False,
740
+ "expired": True,
741
+ "path": None,
742
+ "credentials": None,
743
+ }
744
+ except FileNotFoundError as e:
745
+ logger.debug(
746
+ "credentials_file_not_found", provider=provider, error=str(e), exc_info=e
747
+ )
748
+ return {
749
+ "has_credentials": False,
750
+ "expired": True,
751
+ "path": None,
752
+ "credentials": None,
753
+ }
754
+ except Exception as e:
755
+ logger.debug(
756
+ "credentials_check_failed", provider=provider, error=str(e), exc_info=e
757
+ )
758
+ return {
759
+ "has_credentials": False,
760
+ "expired": True,
761
+ "path": None,
762
+ "credentials": None,
763
+ }
764
+
765
+
766
+ @app.command(name="providers")
767
+ def list_providers() -> None:
768
+ """List all available OAuth providers."""
769
+ _ensure_logging_configured()
415
770
  toolkit = get_rich_toolkit()
416
- toolkit.print("[bold cyan]Claude OAuth Login[/bold cyan]", centered=True)
771
+ toolkit.print("[bold cyan]Available OAuth Providers[/bold cyan]", centered=True)
417
772
  toolkit.print_line()
418
773
 
419
774
  try:
420
- # Get credential paths based on options
421
- custom_paths = None
422
- if credential_file:
423
- custom_paths = [Path(credential_file)]
424
- elif docker:
425
- custom_paths = get_docker_credential_paths()
426
-
427
- # Check if already logged in
428
- manager = get_credentials_manager(custom_paths)
429
- validation_result = asyncio.run(manager.validate())
430
- if validation_result.valid and not validation_result.expired:
431
- console.print(
432
- "[yellow]You are already logged in with valid credentials.[/yellow]"
433
- )
434
- console.print(
435
- "Use [cyan]ccproxy auth info[/cyan] to view current credentials."
436
- )
775
+ container = _get_service_container()
776
+ providers = asyncio.run(discover_oauth_providers(container))
437
777
 
438
- overwrite = typer.confirm(
439
- "Do you want to login again and overwrite existing credentials?"
440
- )
441
- if not overwrite:
442
- console.print("Login cancelled.")
443
- return
444
-
445
- # Perform OAuth login
446
- console.print("Starting OAuth login process...")
447
- console.print("Your browser will open for authentication.")
448
- console.print(
449
- "A temporary server will start on port 54545 for the OAuth callback..."
450
- )
778
+ if not providers:
779
+ toolkit.print("No OAuth providers found", tag="warning")
780
+ return
451
781
 
452
- try:
453
- asyncio.run(manager.login())
454
- success = True
455
- except Exception as e:
456
- logger.error(f"Login failed: {e}")
457
- success = False
782
+ table = Table(
783
+ show_header=True,
784
+ header_style="bold cyan",
785
+ box=box.ROUNDED,
786
+ title="OAuth Providers",
787
+ title_style="bold white",
788
+ )
789
+ table.add_column("Provider", style="cyan")
790
+ table.add_column("Auth Type", style="white")
791
+ table.add_column("Description", style="dim")
458
792
 
459
- if success:
460
- toolkit.print("Successfully logged in to Claude!", tag="success")
793
+ for name, (auth_type, description) in providers.items():
794
+ table.add_row(name, auth_type, description)
461
795
 
462
- # Show credential info
463
- console.print("\n[dim]Credential information:[/dim]")
464
- updated_validation = asyncio.run(manager.validate())
465
- if updated_validation.valid and updated_validation.credentials:
466
- oauth_token = updated_validation.credentials.claude_ai_oauth
467
- console.print(
468
- f" Subscription: {oauth_token.subscription_type or 'Unknown'}"
469
- )
470
- if oauth_token.scopes:
471
- console.print(f" Scopes: {', '.join(oauth_token.scopes)}")
472
- exp_dt = oauth_token.expires_at_datetime
473
- console.print(f" Expires: {exp_dt.strftime('%Y-%m-%d %H:%M:%S UTC')}")
474
- else:
475
- toolkit.print("Login failed. Please try again.", tag="error")
476
- raise typer.Exit(1)
796
+ console.print(table)
477
797
 
478
- except KeyboardInterrupt:
479
- console.print("\n[yellow]Login cancelled by user.[/yellow]")
480
- raise typer.Exit(1) from None
798
+ except ImportError as e:
799
+ toolkit.print(f"Plugin import error: {e}", tag="error")
800
+ raise typer.Exit(1) from e
801
+ except AttributeError as e:
802
+ toolkit.print(f"Plugin configuration error: {e}", tag="error")
803
+ raise typer.Exit(1) from e
481
804
  except Exception as e:
482
- toolkit.print(f"Error during login: {e}", tag="error")
805
+ toolkit.print(f"Error listing providers: {e}", tag="error")
483
806
  raise typer.Exit(1) from e
484
807
 
485
808
 
486
- @app.command()
487
- def renew(
488
- docker: Annotated[
809
+ @app.command(name="login")
810
+ def login_command(
811
+ provider: Annotated[
812
+ str,
813
+ typer.Argument(
814
+ help="Provider to authenticate with (claude-api, codex, copilot)"
815
+ ),
816
+ ],
817
+ no_browser: Annotated[
818
+ bool,
819
+ typer.Option("--no-browser", help="Don't automatically open browser for OAuth"),
820
+ ] = False,
821
+ manual: Annotated[
489
822
  bool,
490
823
  typer.Option(
491
- "--docker",
492
- "-d",
493
- help="Renew credentials for Docker environment",
824
+ "--manual", "-m", help="Skip callback server and enter code manually"
494
825
  ),
495
826
  ] = False,
496
- credential_file: Annotated[
827
+ output_file: Annotated[
497
828
  Path | None,
498
829
  typer.Option(
499
- "--credential-file",
500
- "-f",
501
- help="Path to custom credential file",
830
+ "--file",
831
+ help="Write credentials to this path instead of the default storage",
502
832
  ),
503
833
  ] = None,
834
+ force: Annotated[
835
+ bool,
836
+ typer.Option(
837
+ "--force",
838
+ help="Overwrite existing credential file when using --file",
839
+ ),
840
+ ] = False,
504
841
  ) -> None:
505
- """Force renew Claude credentials without checking expiration.
506
-
507
- This command will refresh your access token regardless of whether it's expired.
508
- Useful for testing or when you want to ensure you have the latest token.
842
+ """Login to a provider using OAuth authentication."""
843
+ _ensure_logging_configured()
844
+ # Capture plugin-injected CLI args for potential use by auth providers
845
+ try:
846
+ from ccproxy.cli.helpers import get_plugin_cli_args
509
847
 
510
- Examples:
511
- ccproxy auth renew
512
- ccproxy auth renew --docker
513
- ccproxy auth renew --credential-file /path/to/credentials.json
514
- """
848
+ _ = get_plugin_cli_args()
849
+ # Currently not used directly here, but available to providers
850
+ except Exception:
851
+ pass
515
852
  toolkit = get_rich_toolkit()
516
- toolkit.print("[bold cyan]Claude Credentials Renewal[/bold cyan]", centered=True)
517
- toolkit.print_line()
518
853
 
519
- console = Console()
854
+ if force and output_file is None:
855
+ toolkit.print("--force can only be used together with --file", tag="error")
856
+ raise typer.Exit(1)
520
857
 
521
- try:
522
- # Get credential paths based on options
523
- custom_paths = None
524
- if credential_file:
525
- custom_paths = [Path(credential_file)]
526
- elif docker:
527
- custom_paths = get_docker_credential_paths()
528
-
529
- # Create credentials manager
530
- manager = get_credentials_manager(custom_paths)
531
-
532
- # Check if credentials exist
533
- validation_result = asyncio.run(manager.validate())
534
- if not validation_result.valid:
535
- toolkit.print("[red]✗[/red] No credentials found to renew", tag="error")
536
- console.print("\n[dim]Please login first:[/dim]")
537
- console.print("[cyan]ccproxy auth login[/cyan]")
538
- raise typer.Exit(1)
858
+ custom_path: Path | None = None
859
+ if output_file is not None:
860
+ custom_path = output_file.expanduser()
861
+ try:
862
+ custom_path = custom_path.resolve()
863
+ except FileNotFoundError:
864
+ # Path.resolve() on some platforms raises when parents missing; fallback to absolute()
865
+ custom_path = custom_path.absolute()
539
866
 
540
- # Force refresh the token
541
- console.print("[yellow]Refreshing access token...[/yellow]")
542
- refreshed_credentials = asyncio.run(manager.refresh_token())
867
+ if custom_path.exists() and custom_path.is_dir():
868
+ toolkit.print(
869
+ f"Target path '{custom_path}' is a directory. Provide a file path.",
870
+ tag="error",
871
+ )
872
+ raise typer.Exit(1)
543
873
 
544
- if refreshed_credentials:
874
+ if custom_path.exists() and not force:
545
875
  toolkit.print(
546
- "[green]✓[/green] Successfully renewed credentials!", tag="success"
876
+ f"Credential file '{custom_path}' already exists. Use --force to overwrite.",
877
+ tag="error",
547
878
  )
879
+ raise typer.Exit(1)
548
880
 
549
- # Show updated credential info
550
- oauth_token = refreshed_credentials.claude_ai_oauth
551
- console.print("\n[dim]Updated credential information:[/dim]")
552
- console.print(
553
- f" Subscription: {oauth_token.subscription_type or 'Unknown'}"
881
+ try:
882
+ custom_path.parent.mkdir(parents=True, exist_ok=True)
883
+ except Exception as exc:
884
+ toolkit.print(
885
+ f"Failed to create directory '{custom_path.parent}': {exc}",
886
+ tag="error",
554
887
  )
555
- if oauth_token.scopes:
556
- console.print(f" Scopes: {', '.join(oauth_token.scopes)}")
557
- exp_dt = oauth_token.expires_at_datetime
558
- console.print(f" Expires: {exp_dt.strftime('%Y-%m-%d %H:%M:%S UTC')}")
559
- else:
560
- toolkit.print("[red]✗[/red] Failed to renew credentials", tag="error")
561
888
  raise typer.Exit(1)
562
889
 
563
- except KeyboardInterrupt:
564
- console.print("\n[yellow]Renewal cancelled by user.[/yellow]")
565
- raise typer.Exit(1) from None
566
- except Exception as e:
567
- toolkit.print(f"Error during renewal: {e}", tag="error")
568
- raise typer.Exit(1) from e
890
+ provider = provider.strip().lower()
891
+ display_name = provider.replace("_", "-").title()
569
892
 
893
+ toolkit.print(
894
+ f"[bold cyan]OAuth Login - {display_name}[/bold cyan]",
895
+ centered=True,
896
+ )
897
+ toolkit.print_line()
570
898
 
571
- # OpenAI Codex Authentication Commands
899
+ custom_path_str = str(custom_path) if custom_path else None
572
900
 
901
+ try:
902
+ container = _get_service_container()
903
+ registry = container.get_oauth_registry()
904
+ oauth_provider = asyncio.run(
905
+ get_oauth_provider_for_name(provider, registry, container)
906
+ )
573
907
 
574
- def get_openai_token_manager() -> "OpenAITokenManager":
575
- """Get OpenAI token manager dependency."""
576
- from ccproxy.auth.openai import OpenAITokenManager
908
+ if not oauth_provider:
909
+ providers = asyncio.run(discover_oauth_providers(container))
910
+ available = ", ".join(providers.keys()) if providers else "none"
911
+ toolkit.print(
912
+ f"Provider '{provider}' not found. Available: {available}",
913
+ tag="error",
914
+ )
915
+ raise typer.Exit(1)
577
916
 
578
- return OpenAITokenManager()
917
+ # Get CLI configuration from provider
918
+ cli_config = oauth_provider.cli
579
919
 
920
+ # Flow engine selection with fallback logic
921
+ flow_engine: ManualCodeFlow | DeviceCodeFlow | BrowserFlow
922
+ try:
923
+ with _temporary_disable_provider_storage(
924
+ oauth_provider, disable=custom_path is not None
925
+ ):
926
+ if manual:
927
+ # Manual mode requested
928
+ if not cli_config.supports_manual_code:
929
+ raise AuthProviderError(
930
+ f"Provider '{provider}' doesn't support manual code entry"
931
+ )
932
+ flow_engine = ManualCodeFlow()
933
+ success = asyncio.run(
934
+ flow_engine.run(oauth_provider, save_path=custom_path_str)
935
+ )
580
936
 
581
- def get_openai_oauth_client(settings: "CodexSettings") -> "OpenAIOAuthClient":
582
- """Get OpenAI OAuth client dependency."""
583
- from ccproxy.auth.openai import OpenAIOAuthClient
937
+ elif (
938
+ cli_config.preferred_flow == FlowType.device
939
+ and cli_config.supports_device_flow
940
+ ):
941
+ # Device flow preferred and supported
942
+ flow_engine = DeviceCodeFlow()
943
+ success = asyncio.run(
944
+ flow_engine.run(oauth_provider, save_path=custom_path_str)
945
+ )
584
946
 
585
- token_manager = get_openai_token_manager()
586
- return OpenAIOAuthClient(settings, token_manager)
947
+ else:
948
+ # Browser flow (default)
949
+ flow_engine = BrowserFlow()
950
+ success = asyncio.run(
951
+ flow_engine.run(
952
+ oauth_provider,
953
+ no_browser=no_browser,
954
+ save_path=custom_path_str,
955
+ )
956
+ )
587
957
 
958
+ except PortBindError as e:
959
+ # Port binding failed - offer manual fallback
960
+ if cli_config.supports_manual_code:
961
+ console.print(
962
+ "[yellow]Port binding failed. Falling back to manual mode.[/yellow]"
963
+ )
964
+ with _temporary_disable_provider_storage(
965
+ oauth_provider, disable=custom_path is not None
966
+ ):
967
+ flow_engine = ManualCodeFlow()
968
+ success = asyncio.run(
969
+ flow_engine.run(oauth_provider, save_path=custom_path_str)
970
+ )
971
+ else:
972
+ console.print(
973
+ f"[red]Port {cli_config.callback_port} unavailable and manual mode not supported[/red]"
974
+ )
975
+ raise typer.Exit(1) from e
588
976
 
589
- @app.command(name="login-openai")
590
- def login_openai_command(
591
- no_browser: Annotated[
592
- bool,
593
- typer.Option(
594
- "--no-browser",
595
- help="Don't automatically open browser for authentication",
596
- ),
597
- ] = False,
598
- ) -> None:
599
- """Login to OpenAI using OAuth authentication.
977
+ except AuthTimedOutError:
978
+ console.print("[red]Authentication timed out[/red]")
979
+ raise typer.Exit(1)
600
980
 
601
- This command will start a local callback server and open your web browser
602
- to authenticate with OpenAI. The credentials will be saved to ~/.codex/auth.json.
981
+ except AuthUserAbortedError:
982
+ console.print("[yellow]Authentication cancelled by user[/yellow]")
983
+ raise typer.Exit(1)
603
984
 
604
- Examples:
605
- ccproxy auth login-openai
606
- ccproxy auth login-openai --no-browser
607
- """
608
- import asyncio
985
+ except AuthProviderError as e:
986
+ console.print(f"[red]Authentication failed: {e}[/red]")
987
+ raise typer.Exit(1) from e
609
988
 
610
- from ccproxy.config.codex import CodexSettings
989
+ except NetworkError as e:
990
+ console.print(f"[red]Network error: {e}[/red]")
991
+ raise typer.Exit(1) from e
611
992
 
612
- toolkit = get_rich_toolkit()
613
- toolkit.print("[bold cyan]OpenAI OAuth Login[/bold cyan]", centered=True)
614
- toolkit.print_line()
993
+ if success:
994
+ console.print("[green][/green] Authentication successful!")
995
+ if custom_path:
996
+ console.print(
997
+ f"[dim]Credentials saved to {custom_path}[/dim]",
998
+ )
999
+ else:
1000
+ console.print("[red]✗[/red] Authentication failed")
1001
+ raise typer.Exit(1)
615
1002
 
616
- try:
617
- # Get Codex settings
618
- settings = CodexSettings()
1003
+ except KeyboardInterrupt:
1004
+ console.print("\n[yellow]Login cancelled by user.[/yellow]")
1005
+ raise typer.Exit(2) from None
1006
+ except ImportError as e:
1007
+ toolkit.print(f"Plugin import error: {e}", tag="error")
1008
+ raise typer.Exit(1) from e
1009
+ except typer.Exit:
1010
+ # Re-raise typer exits
1011
+ raise
1012
+ except Exception as e:
1013
+ toolkit.print(f"Error during login: {e}", tag="error")
1014
+ logger.error("login_command_error", error=str(e), exc_info=e)
1015
+ raise typer.Exit(1) from e
619
1016
 
620
- # Check if already logged in
621
- token_manager = get_openai_token_manager()
622
- existing_creds = asyncio.run(token_manager.load_credentials())
623
1017
 
624
- if existing_creds and not existing_creds.is_expired():
625
- console.print(
626
- "[yellow]You are already logged in with valid OpenAI credentials.[/yellow]"
627
- )
628
- console.print(
629
- "Use [cyan]ccproxy auth openai-info[/cyan] to view current credentials."
630
- )
1018
+ def _refresh_provider_tokens(provider: str, custom_path: Path | None = None) -> None:
1019
+ """Shared implementation for refresh/renew commands."""
1020
+ toolkit = get_rich_toolkit()
1021
+ provider_key = provider.strip().lower()
1022
+ display_name = provider_key.replace("_", "-").title()
631
1023
 
632
- overwrite = typer.confirm(
633
- "Do you want to login again and overwrite existing credentials?"
634
- )
635
- if not overwrite:
636
- console.print("Login cancelled.")
637
- return
1024
+ toolkit.print(
1025
+ f"[bold cyan]{display_name} Token Refresh[/bold cyan]",
1026
+ centered=True,
1027
+ )
1028
+ toolkit.print_line()
638
1029
 
639
- # Create OAuth client and perform login
640
- oauth_client = get_openai_oauth_client(settings)
1030
+ credential_path = _normalize_credentials_file_option(
1031
+ toolkit, custom_path, require_exists=True
1032
+ )
1033
+ load_kwargs: dict[str, Any] = {}
1034
+ save_kwargs: dict[str, Any] = {}
1035
+ if credential_path is not None:
1036
+ load_kwargs["custom_path"] = credential_path
1037
+ save_kwargs["custom_path"] = credential_path
641
1038
 
642
- console.print("Starting OpenAI OAuth login process...")
643
- console.print(
644
- "A temporary server will start on port 1455 for the OAuth callback..."
1039
+ try:
1040
+ container = _get_service_container()
1041
+ registry = container.get_oauth_registry()
1042
+ oauth_provider = asyncio.run(
1043
+ get_oauth_provider_for_name(provider_key, registry, container)
645
1044
  )
646
1045
 
647
- if no_browser:
648
- console.print("Browser will NOT be opened automatically.")
649
- else:
650
- console.print("Your browser will open for authentication.")
651
-
652
- try:
653
- credentials = asyncio.run(
654
- oauth_client.authenticate(open_browser=not no_browser)
1046
+ if not oauth_provider:
1047
+ providers = asyncio.run(discover_oauth_providers(container))
1048
+ available = ", ".join(providers.keys()) if providers else "none"
1049
+ toolkit.print(
1050
+ f"Provider '{provider_key}' not found. Available: {available}",
1051
+ tag="error",
655
1052
  )
1053
+ raise typer.Exit(1)
656
1054
 
657
- toolkit.print("Successfully logged in to OpenAI!", tag="success")
658
-
659
- # Show credential info
660
- console.print("\n[dim]Credential information:[/dim]")
661
- console.print(f" Account ID: {credentials.account_id}")
662
- console.print(
663
- f" Expires: {credentials.expires_at.strftime('%Y-%m-%d %H:%M:%S UTC')}"
1055
+ if not bool(getattr(oauth_provider, "supports_refresh", False)):
1056
+ toolkit.print(
1057
+ f"Provider '{provider_key}' does not support token refresh.",
1058
+ tag="warning",
664
1059
  )
665
- console.print(f" Active: {'Yes' if credentials.active else 'No'}")
666
-
667
- except Exception as e:
668
- logger.error(f"OpenAI login failed: {e}")
669
- toolkit.print(f"Login failed: {e}", tag="error")
670
- raise typer.Exit(1) from e
1060
+ raise typer.Exit(1)
671
1061
 
672
- except KeyboardInterrupt:
673
- console.print("\n[yellow]Login cancelled by user.[/yellow]")
674
- raise typer.Exit(1) from None
675
- except Exception as e:
676
- toolkit.print(f"Error during OpenAI login: {e}", tag="error")
677
- raise typer.Exit(1) from e
1062
+ credentials = asyncio.run(oauth_provider.load_credentials(**load_kwargs))
1063
+ if not credentials:
1064
+ toolkit.print(
1065
+ (
1066
+ f"No credentials found at '{credential_path}'."
1067
+ if credential_path
1068
+ else "No credentials found. Run 'ccproxy auth login' first."
1069
+ ),
1070
+ tag="warning",
1071
+ )
1072
+ raise typer.Exit(1)
678
1073
 
1074
+ snapshot = _token_snapshot_from_credentials(credentials, provider_key)
679
1075
 
680
- @app.command(name="logout-openai")
681
- def logout_openai_command() -> None:
682
- """Logout from OpenAI and remove saved credentials.
1076
+ manager = None
1077
+ if credential_path is None:
1078
+ manager = _resolve_token_manager(provider_key, oauth_provider, container)
683
1079
 
684
- This command will remove the OpenAI credentials file (~/.codex/auth.json)
685
- and invalidate the current session.
1080
+ refreshed_credentials: Any | None = None
1081
+ try:
1082
+ if (
1083
+ credential_path is None
1084
+ and manager
1085
+ and hasattr(manager, "refresh_token")
1086
+ ):
1087
+ refreshed_credentials = asyncio.run(manager.refresh_token())
1088
+ else:
1089
+ refresh_token = snapshot.refresh_token if snapshot else None
1090
+ if not refresh_token:
1091
+ toolkit.print(
1092
+ "Stored credentials do not include a refresh token; "
1093
+ "re-authentication is required.",
1094
+ tag="warning",
1095
+ )
1096
+ raise typer.Exit(1)
686
1097
 
687
- Examples:
688
- ccproxy auth logout-openai
689
- """
690
- import asyncio
1098
+ with _temporary_disable_provider_storage(
1099
+ oauth_provider, disable=credential_path is not None
1100
+ ):
1101
+ refreshed_credentials = asyncio.run(
1102
+ oauth_provider.refresh_access_token(refresh_token)
1103
+ )
1104
+ if credential_path and refreshed_credentials:
1105
+ saved = asyncio.run(
1106
+ oauth_provider.save_credentials(
1107
+ refreshed_credentials, **save_kwargs
1108
+ )
1109
+ )
1110
+ if not saved:
1111
+ toolkit.print(
1112
+ f"Refreshed credentials could not be saved to '{credential_path}'.",
1113
+ tag="warning",
1114
+ )
1115
+ except Exception as exc:
1116
+ toolkit.print(f"Token refresh failed: {exc}", tag="error")
1117
+ logger.error(
1118
+ "token_refresh_failed",
1119
+ provider=provider_key,
1120
+ error=str(exc),
1121
+ exc_info=exc,
1122
+ )
1123
+ raise typer.Exit(1) from exc
691
1124
 
692
- toolkit = get_rich_toolkit()
693
- toolkit.print("[bold cyan]OpenAI Logout[/bold cyan]", centered=True)
694
- toolkit.print_line()
1125
+ if refreshed_credentials is None:
1126
+ with contextlib.suppress(Exception):
1127
+ refreshed_credentials = asyncio.run(
1128
+ oauth_provider.load_credentials(**load_kwargs)
1129
+ )
1130
+ if (
1131
+ not refreshed_credentials
1132
+ and manager
1133
+ and hasattr(manager, "load_credentials")
1134
+ ):
1135
+ with contextlib.suppress(Exception):
1136
+ refreshed_credentials = asyncio.run(manager.load_credentials())
1137
+
1138
+ refreshed_snapshot = None
1139
+ if refreshed_credentials:
1140
+ refreshed_snapshot = _token_snapshot_from_credentials(
1141
+ refreshed_credentials, provider_key
1142
+ )
695
1143
 
696
- try:
697
- token_manager = get_openai_token_manager()
1144
+ if not refreshed_snapshot and snapshot:
1145
+ refreshed_snapshot = snapshot
698
1146
 
699
- # Check if credentials exist
700
- existing_creds = asyncio.run(token_manager.load_credentials())
701
- if not existing_creds:
702
- console.print(
703
- "[yellow]No OpenAI credentials found. Already logged out.[/yellow]"
1147
+ if not refreshed_snapshot:
1148
+ toolkit.print(
1149
+ "Token refresh completed but updated credentials could not be loaded. "
1150
+ "Check logs for details.",
1151
+ tag="warning",
704
1152
  )
705
1153
  return
706
1154
 
707
- # Confirm logout
708
- confirm = typer.confirm(
709
- "Are you sure you want to logout and remove OpenAI credentials?"
1155
+ account_display = refreshed_snapshot.account_id or "—"
1156
+ expires_at = (
1157
+ refreshed_snapshot.expires_at.isoformat()
1158
+ if refreshed_snapshot.expires_at
1159
+ else "Unknown"
1160
+ )
1161
+ expires_in = _format_seconds(refreshed_snapshot.expires_in_seconds())
1162
+ access_preview = refreshed_snapshot.access_token_preview() or "(hidden)"
1163
+ refresh_preview = (
1164
+ refreshed_snapshot.refresh_token_preview()
1165
+ if refreshed_snapshot.refresh_token
1166
+ else None
710
1167
  )
711
- if not confirm:
712
- console.print("Logout cancelled.")
713
- return
714
-
715
- # Delete credentials
716
- success = asyncio.run(token_manager.delete_credentials())
717
1168
 
718
- if success:
719
- toolkit.print("Successfully logged out from OpenAI!", tag="success")
720
- console.print("OpenAI credentials have been removed.")
721
- else:
722
- toolkit.print("Failed to remove OpenAI credentials", tag="error")
723
- raise typer.Exit(1)
1169
+ toolkit.print("Tokens refreshed successfully", tag="success")
724
1170
 
725
- except Exception as e:
726
- toolkit.print(f"Error during OpenAI logout: {e}", tag="error")
727
- raise typer.Exit(1) from e
1171
+ summary = Table(show_header=False, box=box.SIMPLE)
1172
+ summary.add_column("Field", style="bold")
1173
+ summary.add_column("Value")
1174
+ summary.add_row("Account", account_display)
1175
+ summary.add_row("Expires At", expires_at)
1176
+ summary.add_row("Expires In", expires_in)
1177
+ summary.add_row("Access Token", access_preview)
1178
+ if refresh_preview:
1179
+ summary.add_row("Refresh Token", refresh_preview)
1180
+ if refreshed_snapshot.scopes:
1181
+ summary.add_row("Scopes", ", ".join(refreshed_snapshot.scopes))
728
1182
 
1183
+ console.print(summary)
729
1184
 
730
- @app.command(name="openai-info")
731
- def openai_info_command() -> None:
732
- """Display OpenAI credential information.
1185
+ except typer.Exit:
1186
+ raise
1187
+ except Exception as exc:
1188
+ toolkit.print(f"Unexpected error during refresh: {exc}", tag="error")
1189
+ logger.error(
1190
+ "refresh_command_error", provider=provider_key, error=str(exc), exc_info=exc
1191
+ )
1192
+ raise typer.Exit(1) from exc
733
1193
 
734
- Shows detailed information about the current OpenAI credentials including
735
- account ID, token expiration, and storage location.
736
1194
 
737
- Examples:
738
- ccproxy auth openai-info
739
- """
740
- import asyncio
741
- import base64
742
- import json
743
- from datetime import UTC, datetime
1195
+ @app.command(name="refresh")
1196
+ def refresh_command(
1197
+ provider: Annotated[
1198
+ str,
1199
+ typer.Argument(help="Provider to refresh (claude-api, codex, copilot)"),
1200
+ ],
1201
+ credential_file: Annotated[
1202
+ Path | None,
1203
+ typer.Option(
1204
+ "--file",
1205
+ help=(
1206
+ "Refresh credentials stored at this path instead of the default storage"
1207
+ ),
1208
+ ),
1209
+ ] = None,
1210
+ ) -> None:
1211
+ """Refresh stored credentials using the provider's refresh token."""
1212
+ _ensure_logging_configured()
1213
+ _refresh_provider_tokens(provider, credential_file)
744
1214
 
745
- from rich import box
746
- from rich.table import Table
747
1215
 
1216
+ @app.command(name="renew")
1217
+ def renew_command(
1218
+ provider: Annotated[
1219
+ str,
1220
+ typer.Argument(help="Alias for refresh command"),
1221
+ ],
1222
+ credential_file: Annotated[
1223
+ Path | None,
1224
+ typer.Option(
1225
+ "--file",
1226
+ help=(
1227
+ "Refresh credentials stored at this path instead of the default storage"
1228
+ ),
1229
+ ),
1230
+ ] = None,
1231
+ ) -> None:
1232
+ """Alias for refresh."""
1233
+ _ensure_logging_configured()
1234
+ _refresh_provider_tokens(provider, credential_file)
1235
+
1236
+
1237
+ @app.command(name="status")
1238
+ def status_command(
1239
+ provider: Annotated[
1240
+ str,
1241
+ typer.Argument(help="Provider to check status (claude-api, codex)"),
1242
+ ],
1243
+ detailed: Annotated[
1244
+ bool,
1245
+ typer.Option("--detailed", "-d", help="Show detailed credential information"),
1246
+ ] = False,
1247
+ credential_file: Annotated[
1248
+ Path | None,
1249
+ typer.Option(
1250
+ "--file",
1251
+ help=("Read credentials from this path instead of the default storage"),
1252
+ ),
1253
+ ] = None,
1254
+ ) -> None:
1255
+ """Check authentication status and info for specified provider."""
1256
+ _ensure_logging_configured()
748
1257
  toolkit = get_rich_toolkit()
749
- toolkit.print("[bold cyan]OpenAI Credential Information[/bold cyan]", centered=True)
1258
+
1259
+ credential_path = _normalize_credentials_file_option(
1260
+ toolkit, credential_file, require_exists=False
1261
+ )
1262
+ credential_missing = bool(credential_path and not credential_path.exists())
1263
+ load_kwargs: dict[str, Any] = {}
1264
+ if credential_path is not None:
1265
+ load_kwargs["custom_path"] = credential_path
1266
+
1267
+ provider = provider.strip().lower()
1268
+ display_name = provider.replace("_", "-").title()
1269
+
1270
+ toolkit.print(
1271
+ f"[bold cyan]{display_name} Authentication Status[/bold cyan]",
1272
+ centered=True,
1273
+ )
750
1274
  toolkit.print_line()
751
1275
 
752
1276
  try:
753
- token_manager = get_openai_token_manager()
754
- credentials = asyncio.run(token_manager.load_credentials())
755
-
756
- if not credentials:
757
- toolkit.print("No OpenAI credentials found", tag="error")
758
- console.print("\n[dim]Expected location:[/dim]")
759
- storage_location = token_manager.storage.get_location()
760
- console.print(f" - {storage_location}")
761
- console.print("\n[dim]To login:[/dim]")
762
- console.print(" ccproxy auth login-openai")
1277
+ container = _get_service_container()
1278
+ registry = container.get_oauth_registry()
1279
+ oauth_provider = asyncio.run(
1280
+ get_oauth_provider_for_name(provider, registry, container)
1281
+ )
1282
+ if not oauth_provider:
1283
+ providers = asyncio.run(discover_oauth_providers(container))
1284
+ available = ", ".join(providers.keys()) if providers else "none"
1285
+ expected = _expected_plugin_class_name(provider)
1286
+ toolkit.print(
1287
+ f"Provider '{provider}' not found. Available: {available}. Expected plugin class '{expected}'.",
1288
+ tag="error",
1289
+ )
763
1290
  raise typer.Exit(1)
764
1291
 
765
- # Decode JWT token to extract additional information
766
- jwt_payload = {}
767
- jwt_header = {}
768
- if credentials.access_token:
769
- try:
770
- # Split JWT into parts
771
- parts = credentials.access_token.split(".")
772
- if len(parts) == 3:
773
- # Decode header and payload (add padding if needed)
774
- header_b64 = parts[0] + "=" * (4 - len(parts[0]) % 4)
775
- payload_b64 = parts[1] + "=" * (4 - len(parts[1]) % 4)
776
-
777
- jwt_header = json.loads(base64.urlsafe_b64decode(header_b64))
778
- jwt_payload = json.loads(base64.urlsafe_b64decode(payload_b64))
779
- except Exception as decode_error:
780
- logger.debug(f"Failed to decode JWT token: {decode_error}")
781
-
782
- # Display account section
783
- console.print("\n[bold]OpenAI Account[/bold]")
784
- console.print(f" L Account ID: {credentials.account_id}")
785
- console.print(f" L Status: {'Active' if credentials.active else 'Inactive'}")
786
-
787
- # Extract additional info from JWT payload
788
- if jwt_payload:
789
- # Get OpenAI auth info from the JWT
790
- openai_auth = jwt_payload.get("https://api.openai.com/auth", {})
791
- if openai_auth:
792
- if "email" in jwt_payload:
793
- console.print(f" L Email: {jwt_payload['email']}")
794
- if jwt_payload.get("email_verified"):
795
- console.print(" L Email Verified: Yes")
796
-
797
- if openai_auth.get("chatgpt_plan_type"):
798
- console.print(
799
- f" L Plan Type: {openai_auth['chatgpt_plan_type'].upper()}"
800
- )
1292
+ profile_info = None
1293
+ credentials = None
1294
+ snapshot: TokenSnapshot | None = None
801
1295
 
802
- if openai_auth.get("chatgpt_user_id"):
803
- console.print(f" L User ID: {openai_auth['chatgpt_user_id']}")
1296
+ if oauth_provider:
1297
+ try:
1298
+ # Delegate to provider; providers may internally use their managers
1299
+ credentials = asyncio.run(
1300
+ oauth_provider.load_credentials(**load_kwargs)
1301
+ )
804
1302
 
805
- # Subscription info
806
- if openai_auth.get("chatgpt_subscription_active_start"):
807
- console.print(
808
- f" L Subscription Start: {openai_auth['chatgpt_subscription_active_start']}"
809
- )
810
- if openai_auth.get("chatgpt_subscription_active_until"):
811
- console.print(
812
- f" L Subscription Until: {openai_auth['chatgpt_subscription_active_until']}"
1303
+ if credential_missing and not credentials:
1304
+ toolkit.print(
1305
+ f"Credential file '{credential_path}' not found.",
1306
+ tag="warning",
813
1307
  )
814
1308
 
815
- # Organizations
816
- orgs = openai_auth.get("organizations", [])
817
- if orgs:
818
- for org in orgs:
819
- if org.get("is_default"):
820
- console.print(
821
- f" L Organization: {org.get('title', 'Unknown')} ({org.get('role', 'member')})"
1309
+ # Optionally obtain a token manager via provider API (if exposed)
1310
+ manager = None
1311
+ if credential_path is None:
1312
+ try:
1313
+ if hasattr(oauth_provider, "create_token_manager"):
1314
+ manager = asyncio.run(oauth_provider.create_token_manager())
1315
+ elif hasattr(oauth_provider, "get_token_manager"):
1316
+ mgr = oauth_provider.get_token_manager() # may be sync
1317
+ # If coroutine, run it; else use directly
1318
+ if hasattr(mgr, "__await__"):
1319
+ manager = asyncio.run(mgr)
1320
+ else:
1321
+ manager = mgr
1322
+ except Exception as e:
1323
+ logger.debug("token_manager_unavailable", error=str(e))
1324
+
1325
+ if manager and hasattr(manager, "get_token_snapshot"):
1326
+ with contextlib.suppress(Exception):
1327
+ result = manager.get_token_snapshot()
1328
+ if asyncio.iscoroutine(result):
1329
+ snapshot = asyncio.run(result)
1330
+ else:
1331
+ snapshot = cast(TokenSnapshot | None, result)
1332
+
1333
+ if not snapshot and credentials:
1334
+ snapshot = _token_snapshot_from_credentials(credentials, provider)
1335
+
1336
+ if credentials:
1337
+ if provider == "codex":
1338
+ standard_profile = None
1339
+ if hasattr(oauth_provider, "get_standard_profile"):
1340
+ with contextlib.suppress(Exception):
1341
+ standard_profile = asyncio.run(
1342
+ oauth_provider.get_standard_profile(credentials)
1343
+ )
1344
+ if not standard_profile and hasattr(
1345
+ oauth_provider,
1346
+ "_extract_standard_profile",
1347
+ ):
1348
+ with contextlib.suppress(Exception):
1349
+ standard_profile = (
1350
+ oauth_provider._extract_standard_profile(
1351
+ credentials
1352
+ )
1353
+ )
1354
+ if standard_profile is not None:
1355
+ try:
1356
+ profile_info = standard_profile.model_dump(
1357
+ exclude={"raw_profile_data"}
1358
+ )
1359
+ except Exception:
1360
+ profile_info = {
1361
+ "provider": provider,
1362
+ "authenticated": True,
1363
+ }
1364
+ else:
1365
+ profile_info = {"provider": provider, "authenticated": True}
1366
+ else:
1367
+ quick = None
1368
+ # Prefer provider-supplied quick profile methods if available
1369
+ if credential_path is None and hasattr(
1370
+ oauth_provider, "get_unified_profile_quick"
1371
+ ):
1372
+ with contextlib.suppress(Exception):
1373
+ quick = asyncio.run(
1374
+ oauth_provider.get_unified_profile_quick()
1375
+ )
1376
+ if (
1377
+ credential_path is None
1378
+ and (not quick or quick == {})
1379
+ and hasattr(oauth_provider, "get_unified_profile")
1380
+ ):
1381
+ with contextlib.suppress(Exception):
1382
+ quick = asyncio.run(
1383
+ oauth_provider.get_unified_profile()
1384
+ )
1385
+ if quick and isinstance(quick, dict) and quick != {}:
1386
+ profile_info = quick
1387
+ try:
1388
+ prov = (
1389
+ profile_info.get("provider_type")
1390
+ or profile_info.get("provider")
1391
+ or ""
1392
+ ).lower()
1393
+ extras = (
1394
+ profile_info.get("extras")
1395
+ if isinstance(profile_info.get("extras"), dict)
1396
+ else None
1397
+ )
1398
+ if (
1399
+ prov in {"claude-api", "claude_api", "claude"}
1400
+ and extras
1401
+ ):
1402
+ account = (
1403
+ extras.get("account", {})
1404
+ if isinstance(extras.get("account"), dict)
1405
+ else {}
1406
+ )
1407
+ org = (
1408
+ extras.get("organization", {})
1409
+ if isinstance(extras.get("organization"), dict)
1410
+ else {}
1411
+ )
1412
+ if account.get("has_claude_max") is True:
1413
+ profile_info["subscription_type"] = "max"
1414
+ profile_info["subscription_status"] = "active"
1415
+ elif account.get("has_claude_pro") is True:
1416
+ profile_info["subscription_type"] = "pro"
1417
+ profile_info["subscription_status"] = "active"
1418
+ features = {}
1419
+ if isinstance(account.get("has_claude_max"), bool):
1420
+ features["claude_max"] = account.get(
1421
+ "has_claude_max"
1422
+ )
1423
+ if isinstance(account.get("has_claude_pro"), bool):
1424
+ features["claude_pro"] = account.get(
1425
+ "has_claude_pro"
1426
+ )
1427
+ if features:
1428
+ profile_info["features"] = {
1429
+ **features,
1430
+ **(profile_info.get("features") or {}),
1431
+ }
1432
+ if org.get("name") and not profile_info.get(
1433
+ "organization_name"
1434
+ ):
1435
+ profile_info["organization_name"] = org.get(
1436
+ "name"
1437
+ )
1438
+ if not profile_info.get("organization_role"):
1439
+ profile_info["organization_role"] = "member"
1440
+ except Exception:
1441
+ pass
1442
+ else:
1443
+ standard_profile = None
1444
+ if hasattr(oauth_provider, "get_standard_profile"):
1445
+ with contextlib.suppress(Exception):
1446
+ standard_profile = asyncio.run(
1447
+ oauth_provider.get_standard_profile(credentials)
1448
+ )
1449
+ if standard_profile is not None:
1450
+ try:
1451
+ profile_info = standard_profile.model_dump(
1452
+ exclude={"raw_profile_data"}
1453
+ )
1454
+ except Exception:
1455
+ profile_info = {
1456
+ "provider": provider,
1457
+ "authenticated": True,
1458
+ }
1459
+ else:
1460
+ profile_info = {
1461
+ "provider": provider,
1462
+ "authenticated": True,
1463
+ }
1464
+
1465
+ if profile_info is not None and "provider" not in profile_info:
1466
+ profile_info["provider"] = provider
1467
+
1468
+ try:
1469
+ prov_dbg = (
1470
+ profile_info.get("provider_type")
1471
+ or profile_info.get("provider")
1472
+ or ""
1473
+ ).lower()
1474
+ missing = []
1475
+ for f in (
1476
+ "subscription_type",
1477
+ "organization_name",
1478
+ "display_name",
1479
+ ):
1480
+ if not profile_info.get(f):
1481
+ missing.append(f)
1482
+ if missing:
1483
+ reasons: list[str] = []
1484
+ qextra = (
1485
+ quick.get("extras") if isinstance(quick, dict) else None
822
1486
  )
823
- console.print(f" L Org ID: {org.get('id', 'Unknown')}")
1487
+ if prov_dbg in {"codex", "openai"}:
1488
+ auth_claims = None
1489
+ if isinstance(qextra, dict):
1490
+ auth_claims = qextra.get(
1491
+ "https://api.openai.com/auth"
1492
+ )
1493
+ if not auth_claims:
1494
+ reasons.append("missing_openai_auth_claims")
1495
+ else:
1496
+ if "chatgpt_plan_type" not in auth_claims:
1497
+ reasons.append("plan_type_not_in_claims")
1498
+ orgs = (
1499
+ auth_claims.get("organizations")
1500
+ if isinstance(auth_claims, dict)
1501
+ else None
1502
+ )
1503
+ if not orgs:
1504
+ reasons.append("no_organizations_in_claims")
1505
+ has_id_token = bool(
1506
+ snapshot and snapshot.extras.get("id_token_present")
1507
+ )
1508
+ if not has_id_token:
1509
+ reasons.append("no_id_token_available")
1510
+ elif prov_dbg in {"claude", "claude-api", "claude_api"}:
1511
+ if not (
1512
+ isinstance(qextra, dict) and qextra.get("account")
1513
+ ):
1514
+ reasons.append("missing_claude_account_extras")
1515
+ if reasons:
1516
+ logger.debug(
1517
+ "profile_fields_missing",
1518
+ provider=prov_dbg,
1519
+ missing_fields=missing,
1520
+ reasons=reasons,
1521
+ )
1522
+ except Exception:
1523
+ pass
824
1524
 
825
- # Create details table
826
- console.print()
827
- table = Table(
828
- show_header=True,
829
- header_style="bold cyan",
830
- box=box.ROUNDED,
831
- title="Token Details",
832
- title_style="bold white",
833
- )
834
- table.add_column("Property", style="cyan")
835
- table.add_column("Value", style="white")
836
-
837
- # File location
838
- storage_location = token_manager.storage.get_location()
839
- table.add_row("Storage Location", storage_location)
840
-
841
- # Token algorithm and type from JWT header
842
- if jwt_header:
843
- table.add_row("Algorithm", jwt_header.get("alg", "Unknown"))
844
- table.add_row("Token Type", jwt_header.get("typ", "Unknown"))
845
- if jwt_header.get("kid"):
846
- table.add_row("Key ID", jwt_header["kid"])
847
-
848
- # Token status
849
- table.add_row(
850
- "Token Expired",
851
- "[red]Yes[/red]" if credentials.is_expired() else "[green]No[/green]",
852
- )
1525
+ except Exception as e:
1526
+ logger.debug(f"{provider}_status_error", error=str(e), exc_info=e)
853
1527
 
854
- # Expiration details
855
- exp_dt = credentials.expires_at
856
- table.add_row("Expires At", exp_dt.strftime("%Y-%m-%d %H:%M:%S UTC"))
857
-
858
- # Time until expiration
859
- now = datetime.now(UTC)
860
- time_diff = exp_dt - now
861
- if time_diff.total_seconds() > 0:
862
- days = time_diff.days
863
- hours = (time_diff.seconds % 86400) // 3600
864
- minutes = (time_diff.seconds % 3600) // 60
865
- table.add_row(
866
- "Time Remaining", f"{days} days, {hours} hours, {minutes} minutes"
867
- )
868
- else:
869
- table.add_row("Time Remaining", "[red]Expired[/red]")
870
-
871
- # JWT timestamps if available
872
- if jwt_payload:
873
- if "iat" in jwt_payload:
874
- iat_dt = datetime.fromtimestamp(jwt_payload["iat"], tz=UTC)
875
- table.add_row("Issued At", iat_dt.strftime("%Y-%m-%d %H:%M:%S UTC"))
876
-
877
- if "auth_time" in jwt_payload:
878
- auth_dt = datetime.fromtimestamp(jwt_payload["auth_time"], tz=UTC)
879
- table.add_row("Auth Time", auth_dt.strftime("%Y-%m-%d %H:%M:%S UTC"))
880
-
881
- # JWT issuer and audience
882
- if jwt_payload:
883
- if "iss" in jwt_payload:
884
- table.add_row("Issuer", jwt_payload["iss"])
885
- if "aud" in jwt_payload:
886
- audience = jwt_payload["aud"]
887
- if isinstance(audience, list):
888
- audience = ", ".join(audience)
889
- table.add_row("Audience", audience)
890
- if "jti" in jwt_payload:
891
- table.add_row("JWT ID", jwt_payload["jti"])
892
- if "sid" in jwt_payload:
893
- table.add_row("Session ID", jwt_payload["sid"])
894
-
895
- # Token preview (first and last 8 chars)
896
- if credentials.access_token:
897
- token_preview = (
898
- f"{credentials.access_token[:12]}...{credentials.access_token[-8:]}"
1528
+ token_snapshot = snapshot
1529
+ if not token_snapshot and credentials:
1530
+ token_snapshot = _token_snapshot_from_credentials(credentials, provider)
1531
+
1532
+ if token_snapshot:
1533
+ # Ensure we surface token metadata in the rendered profile table
1534
+ if not profile_info:
1535
+ profile_info = {
1536
+ "provider_type": token_snapshot.provider or provider,
1537
+ "authenticated": True,
1538
+ }
1539
+
1540
+ if token_snapshot.expires_at:
1541
+ profile_info["token_expires_at"] = token_snapshot.expires_at
1542
+
1543
+ profile_info["has_refresh_token"] = token_snapshot.has_refresh_token()
1544
+ profile_info["has_access_token"] = token_snapshot.has_access_token()
1545
+
1546
+ has_id_token = bool(
1547
+ token_snapshot.extras.get("id_token_present")
1548
+ or token_snapshot.extras.get("has_id_token")
899
1549
  )
900
- table.add_row("Access Token", f"[dim]{token_preview}[/dim]")
901
-
902
- # Refresh token status
903
- has_refresh = bool(credentials.refresh_token)
904
- table.add_row(
905
- "Refresh Token",
906
- "[green]Available[/green]"
907
- if has_refresh
908
- else "[yellow]Not available[/yellow]",
909
- )
1550
+ if not has_id_token and credentials and hasattr(credentials, "id_token"):
1551
+ with contextlib.suppress(Exception):
1552
+ has_id_token = bool(credentials.id_token)
1553
+ profile_info["has_id_token"] = has_id_token
1554
+
1555
+ if token_snapshot.scopes and not profile_info.get("scopes"):
1556
+ profile_info["scopes"] = list(token_snapshot.scopes)
1557
+
1558
+ if profile_info:
1559
+ console.print("[green]✓[/green] Authenticated with valid credentials")
1560
+
1561
+ if "provider_type" not in profile_info and "provider" in profile_info:
1562
+ try:
1563
+ profile_info["provider_type"] = str(
1564
+ profile_info["provider"]
1565
+ ).replace("_", "-")
1566
+ except Exception:
1567
+ profile_info["provider_type"] = (
1568
+ str(profile_info["provider"])
1569
+ if profile_info.get("provider")
1570
+ else None
1571
+ )
910
1572
 
911
- console.print(table)
1573
+ _render_profile_table(profile_info, title="Account Information")
1574
+ _render_profile_features(profile_info)
912
1575
 
913
- # Show usage instructions
914
- console.print("\n[dim]Commands:[/dim]")
915
- console.print(" ccproxy auth login-openai - Re-authenticate")
916
- console.print(" ccproxy auth logout-openai - Remove credentials")
1576
+ if detailed and token_snapshot:
1577
+ preview = token_snapshot.access_token_preview()
1578
+ if preview:
1579
+ console.print(f"\n Token: [dim]{preview}[/dim]")
1580
+ else:
1581
+ console.print("[red]✗[/red] Not authenticated or provider not found")
1582
+ console.print(f" Run 'ccproxy auth login {provider}' to authenticate")
917
1583
 
1584
+ except ImportError as e:
1585
+ console.print(f"[red]✗[/red] Failed to import required modules: {e}")
1586
+ raise typer.Exit(1) from e
1587
+ except AttributeError as e:
1588
+ console.print(f"[red]✗[/red] Configuration or plugin error: {e}")
1589
+ raise typer.Exit(1) from e
918
1590
  except Exception as e:
919
- toolkit.print(f"Error getting OpenAI credential info: {e}", tag="error")
1591
+ console.print(f"[red]✗[/red] Error checking status: {e}")
920
1592
  raise typer.Exit(1) from e
921
1593
 
922
1594
 
923
- @app.command(name="openai-status")
924
- def openai_status_command() -> None:
925
- """Check OpenAI authentication status.
1595
+ @app.command(name="logout")
1596
+ def logout_command(
1597
+ provider: Annotated[
1598
+ str, typer.Argument(help="Provider to logout from (claude-api, codex)")
1599
+ ],
1600
+ ) -> None:
1601
+ """Logout and remove stored credentials for specified provider."""
1602
+ _ensure_logging_configured()
1603
+ toolkit = get_rich_toolkit()
926
1604
 
927
- Quick status check for OpenAI credentials without detailed information.
928
- Useful for scripts and automation.
1605
+ provider = provider.strip().lower()
929
1606
 
930
- Examples:
931
- ccproxy auth openai-status
932
- """
933
- import asyncio
1607
+ toolkit.print(f"[bold cyan]{provider.title()} Logout[/bold cyan]", centered=True)
1608
+ toolkit.print_line()
934
1609
 
935
1610
  try:
936
- token_manager = get_openai_token_manager()
937
- credentials = asyncio.run(token_manager.load_credentials())
938
-
939
- if not credentials:
940
- console.print("[red]✗[/red] Not logged in to OpenAI")
941
- raise typer.Exit(1)
1611
+ container = _get_service_container()
1612
+ registry = container.get_oauth_registry()
1613
+ oauth_provider = asyncio.run(
1614
+ get_oauth_provider_for_name(provider, registry, container)
1615
+ )
942
1616
 
943
- if credentials.is_expired():
944
- console.print("[yellow]⚠[/yellow] OpenAI credentials expired")
945
- console.print(
946
- f" Expired: {credentials.expires_at.strftime('%Y-%m-%d %H:%M:%S UTC')}"
1617
+ if not oauth_provider:
1618
+ providers = asyncio.run(discover_oauth_providers(container))
1619
+ available = ", ".join(providers.keys()) if providers else "none"
1620
+ expected = _expected_plugin_class_name(provider)
1621
+ toolkit.print(
1622
+ f"Provider '{provider}' not found. Available: {available}. Expected plugin class '{expected}'.",
1623
+ tag="error",
947
1624
  )
948
1625
  raise typer.Exit(1)
949
1626
 
950
- console.print("[green]✓[/green] OpenAI credentials valid")
951
- console.print(f" Account: {credentials.account_id}")
952
- console.print(
953
- f" Expires: {credentials.expires_at.strftime('%Y-%m-%d %H:%M:%S UTC')}"
1627
+ existing_creds = None
1628
+ with contextlib.suppress(Exception):
1629
+ existing_creds = asyncio.run(oauth_provider.load_credentials())
1630
+
1631
+ if not existing_creds:
1632
+ console.print("[yellow]No credentials found. Already logged out.[/yellow]")
1633
+ return
1634
+
1635
+ confirm = typer.confirm(
1636
+ "Are you sure you want to logout and remove credentials?"
954
1637
  )
1638
+ if not confirm:
1639
+ console.print("Logout cancelled.")
1640
+ return
955
1641
 
956
- except SystemExit:
957
- raise
1642
+ success = False
1643
+ try:
1644
+ storage = oauth_provider.get_storage()
1645
+ if storage and hasattr(storage, "delete"):
1646
+ success = asyncio.run(storage.delete())
1647
+ elif storage and hasattr(storage, "clear"):
1648
+ success = asyncio.run(storage.clear())
1649
+ else:
1650
+ success = asyncio.run(oauth_provider.save_credentials(None))
1651
+ except Exception as e:
1652
+ logger.debug("logout_error", error=str(e), exc_info=e)
1653
+
1654
+ if success:
1655
+ toolkit.print(f"Successfully logged out from {provider}!", tag="success")
1656
+ console.print("Credentials have been removed.")
1657
+ else:
1658
+ toolkit.print("Failed to remove credentials", tag="error")
1659
+ raise typer.Exit(1)
1660
+
1661
+ except FileNotFoundError:
1662
+ toolkit.print("No credentials found to remove.", tag="warning")
1663
+ except OSError as e:
1664
+ toolkit.print(f"Failed to remove credential files: {e}", tag="error")
1665
+ raise typer.Exit(1) from e
1666
+ except ImportError as e:
1667
+ toolkit.print(f"Failed to import required modules: {e}", tag="error")
1668
+ raise typer.Exit(1) from e
958
1669
  except Exception as e:
959
- console.print(f"[red]✗[/red] Error checking OpenAI status: {e}")
1670
+ toolkit.print(f"Error during logout: {e}", tag="error")
960
1671
  raise typer.Exit(1) from e
961
1672
 
962
1673
 
963
- if __name__ == "__main__":
964
- app()
1674
+ async def get_oauth_provider_for_name(
1675
+ provider: str,
1676
+ registry: OAuthRegistry,
1677
+ container: ServiceContainer,
1678
+ ) -> Any:
1679
+ """Get OAuth provider instance for the specified provider name."""
1680
+ existing = registry.get(provider)
1681
+ if existing:
1682
+ return existing
1683
+
1684
+ provider_instance = await _lazy_register_oauth_provider(
1685
+ provider, registry, container
1686
+ )
1687
+ if provider_instance:
1688
+ return provider_instance
1689
+
1690
+ return None