ccproxy-api 0.1.7__py3-none-any.whl → 0.2.0a4__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 +434 -219
  3. ccproxy/api/bootstrap.py +30 -0
  4. ccproxy/api/decorators.py +85 -0
  5. ccproxy/api/dependencies.py +144 -168
  6. ccproxy/api/format_validation.py +54 -0
  7. ccproxy/api/middleware/cors.py +6 -3
  8. ccproxy/api/middleware/errors.py +388 -524
  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 +540 -19
  97. ccproxy/data/codex_headers_fallback.json +114 -7
  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 +61 -105
  329. ccproxy/scheduler/registry.py +6 -32
  330. ccproxy/scheduler/tasks.py +268 -276
  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 +68 -446
  387. ccproxy/utils/version_checker.py +273 -6
  388. ccproxy_api-0.2.0a4.dist-info/METADATA +212 -0
  389. ccproxy_api-0.2.0a4.dist-info/RECORD +417 -0
  390. {ccproxy_api-0.1.7.dist-info → ccproxy_api-0.2.0a4.dist-info}/WHEEL +1 -1
  391. ccproxy_api-0.2.0a4.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 -1251
  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 -243
  458. ccproxy/services/codex_detection_service.py +0 -252
  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.7.dist-info/METADATA +0 -615
  472. ccproxy_api-0.1.7.dist-info/RECORD +0 -191
  473. ccproxy_api-0.1.7.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.7.dist-info → ccproxy_api-0.2.0a4.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,65 @@
1
+ """Base models for authentication across all providers."""
2
+
3
+ from datetime import UTC, datetime
4
+ from typing import Any
5
+
6
+ from pydantic import BaseModel, Field, computed_field
7
+
8
+
9
+ class BaseTokenInfo(BaseModel):
10
+ """Base model for token information across all providers.
11
+
12
+ This abstract base provides a common interface for token operations
13
+ while allowing each provider to maintain its specific implementation.
14
+ """
15
+
16
+ @computed_field
17
+ def access_token_value(self) -> str:
18
+ """Get the actual access token string.
19
+ Must be implemented by provider-specific subclasses.
20
+ """
21
+ raise NotImplementedError
22
+
23
+ @computed_field
24
+ def is_expired(self) -> bool:
25
+ """Check if token is expired.
26
+ Uses the expires_at_datetime property for comparison.
27
+ """
28
+ now = datetime.now(UTC)
29
+ return now >= self.expires_at_datetime
30
+
31
+ @property
32
+ def expires_at_datetime(self) -> datetime:
33
+ """Get expiration as datetime object.
34
+ Must be implemented by provider-specific subclasses.
35
+ """
36
+ raise NotImplementedError
37
+
38
+ @property
39
+ def refresh_token_value(self) -> str | None:
40
+ """Get refresh token if available.
41
+ Default returns None, override if provider supports refresh.
42
+ """
43
+ return None
44
+
45
+
46
+ class BaseProfileInfo(BaseModel):
47
+ """Base model for user profile information across all providers.
48
+
49
+ Provides common fields with a flexible extras dict for
50
+ provider-specific data.
51
+ """
52
+
53
+ account_id: str
54
+ provider_type: str
55
+
56
+ # Common fields with sensible defaults
57
+ email: str = ""
58
+ display_name: str | None = None
59
+
60
+ # All provider-specific data stored here
61
+ # This preserves all information for future use
62
+ extras: dict[str, Any] = Field(
63
+ default_factory=dict,
64
+ description="Provider-specific data (JWT claims, API responses, etc.)",
65
+ )
@@ -0,0 +1,40 @@
1
+ """Base credentials protocol for all authentication implementations."""
2
+
3
+ from typing import Any, Protocol, runtime_checkable
4
+
5
+
6
+ @runtime_checkable
7
+ class BaseCredentials(Protocol):
8
+ """Protocol that all credential implementations must follow.
9
+
10
+ This defines the contract for credentials without depending on
11
+ any specific provider implementation.
12
+ """
13
+
14
+ def is_expired(self) -> bool:
15
+ """Check if the credentials are expired.
16
+
17
+ Returns:
18
+ True if expired, False otherwise
19
+ """
20
+ ...
21
+
22
+ def to_dict(self) -> dict[str, Any]:
23
+ """Convert credentials to dictionary for storage.
24
+
25
+ Returns:
26
+ Dictionary representation of credentials
27
+ """
28
+ ...
29
+
30
+ @classmethod
31
+ def from_dict(cls, data: dict[str, Any]) -> "BaseCredentials":
32
+ """Create credentials from dictionary.
33
+
34
+ Args:
35
+ data: Dictionary containing credential data
36
+
37
+ Returns:
38
+ Credentials instance
39
+ """
40
+ ...
@@ -1,26 +1,12 @@
1
- """OAuth authentication module for Anthropic OAuth login."""
1
+ """Public router shim for OAuth flows."""
2
2
 
3
- from ccproxy.auth.oauth.models import (
4
- OAuthCallbackRequest,
5
- OAuthState,
6
- OAuthTokenRequest,
7
- OAuthTokenResponse,
8
- )
9
- from ccproxy.auth.oauth.routes import (
10
- get_oauth_flow_result,
11
- register_oauth_flow,
12
- router,
13
- )
3
+ from ccproxy.auth.oauth.registry import OAuthProviderProtocol
4
+ from ccproxy.auth.oauth.routes import get_oauth_flow_result, register_oauth_flow, router
14
5
 
15
6
 
16
7
  __all__ = [
17
- # Router
18
8
  "router",
19
9
  "register_oauth_flow",
20
10
  "get_oauth_flow_result",
21
- # Models
22
- "OAuthState",
23
- "OAuthCallbackRequest",
24
- "OAuthTokenRequest",
25
- "OAuthTokenResponse",
11
+ "OAuthProviderProtocol",
26
12
  ]
@@ -0,0 +1,533 @@
1
+ """Base OAuth client with common PKCE flow implementation."""
2
+
3
+ import asyncio
4
+ import base64
5
+ import hashlib
6
+ import secrets
7
+ import urllib.parse
8
+ from abc import ABC, abstractmethod
9
+ from datetime import UTC, datetime, timedelta
10
+ from typing import Any, Generic, TypeVar
11
+
12
+ import httpx
13
+
14
+ from ccproxy.auth.exceptions import (
15
+ OAuthError,
16
+ OAuthTokenRefreshError,
17
+ )
18
+ from ccproxy.auth.models.credentials import BaseCredentials
19
+ from ccproxy.auth.storage.base import TokenStorage
20
+ from ccproxy.config.settings import Settings
21
+ from ccproxy.core.logging import get_logger
22
+ from ccproxy.http.client import HTTPClientFactory
23
+
24
+
25
+ logger = get_logger(__name__)
26
+
27
+ CredentialsT = TypeVar("CredentialsT", bound=BaseCredentials)
28
+
29
+
30
+ class BaseOAuthClient(ABC, Generic[CredentialsT]):
31
+ """Abstract base class for OAuth PKCE flow implementations."""
32
+
33
+ def __init__(
34
+ self,
35
+ client_id: str,
36
+ redirect_uri: str,
37
+ base_url: str,
38
+ scopes: list[str],
39
+ storage: TokenStorage[CredentialsT] | None = None,
40
+ http_client: httpx.AsyncClient | None = None,
41
+ hook_manager: Any | None = None,
42
+ settings: Settings | None = None,
43
+ ):
44
+ """Initialize OAuth client with common parameters.
45
+
46
+ Args:
47
+ client_id: OAuth client ID
48
+ redirect_uri: OAuth callback redirect URI
49
+ base_url: OAuth provider base URL
50
+ scopes: List of OAuth scopes to request
51
+ storage: Optional token storage backend
52
+ http_client: Optional HTTP client (for request tracing support)
53
+ hook_manager: Optional hook manager for emitting events
54
+ settings: Optional settings for HTTP client configuration
55
+ """
56
+ self.client_id = client_id
57
+ self.redirect_uri = redirect_uri
58
+ self.base_url = base_url
59
+ self.scopes = scopes
60
+ self.storage = storage
61
+ self.hook_manager = hook_manager
62
+
63
+ # Always have an HTTP client
64
+ if http_client:
65
+ self.http_client = http_client
66
+ self._owns_http_client = False # Don't close provided client
67
+ logger.debug(
68
+ "oauth_client_using_provided_http_client",
69
+ http_client_id=id(http_client),
70
+ has_hooks=hasattr(http_client, "hook_manager")
71
+ and http_client.hook_manager is not None,
72
+ hook_manager_id=id(hook_manager) if hook_manager else None,
73
+ )
74
+ else:
75
+ # Create client with hook support if hook_manager is provided
76
+ self.http_client = HTTPClientFactory.create_client(
77
+ settings=settings,
78
+ timeout_connect=10.0,
79
+ timeout_read=30.0,
80
+ http2=True,
81
+ hook_manager=hook_manager, # Pass hook manager to client
82
+ )
83
+ self._owns_http_client = True # We own it, close on cleanup
84
+ logger.debug(
85
+ "oauth_client_created_new_http_client",
86
+ http_client_id=id(self.http_client),
87
+ has_hooks=hasattr(self.http_client, "hook_manager")
88
+ and self.http_client.hook_manager is not None,
89
+ hook_manager_id=id(hook_manager) if hook_manager else None,
90
+ )
91
+
92
+ self._callback_server: asyncio.Task[None] | None = None
93
+ self._auth_complete = asyncio.Event()
94
+ self._auth_result: Any | None = None
95
+ self._auth_error: str | None = None
96
+
97
+ async def close(self) -> None:
98
+ """Close resources if we own them."""
99
+ if self._owns_http_client and self.http_client:
100
+ await self.http_client.aclose()
101
+
102
+ def __del__(self) -> None:
103
+ """Cleanup on deletion."""
104
+ if (
105
+ self._owns_http_client
106
+ and self.http_client
107
+ and not self.http_client.is_closed
108
+ ):
109
+ try:
110
+ # Try to get the current event loop
111
+ loop = asyncio.get_running_loop()
112
+ loop.create_task(self.http_client.aclose())
113
+ except RuntimeError:
114
+ # No running event loop, can't clean up async resources
115
+ pass
116
+
117
+ def _generate_pkce_pair(self) -> tuple[str, str]:
118
+ """Generate PKCE code verifier and challenge.
119
+
120
+ Returns:
121
+ Tuple of (code_verifier, code_challenge)
122
+ """
123
+ # Generate code verifier (43-128 characters, URL-safe)
124
+ code_verifier = (
125
+ base64.urlsafe_b64encode(secrets.token_bytes(32)).decode().rstrip("=")
126
+ )
127
+
128
+ # Generate code challenge using SHA256
129
+ challenge_bytes = hashlib.sha256(code_verifier.encode()).digest()
130
+ code_challenge = base64.urlsafe_b64encode(challenge_bytes).decode().rstrip("=")
131
+
132
+ logger.debug(
133
+ "pkce_pair_generated",
134
+ verifier_length=len(code_verifier),
135
+ challenge_length=len(code_challenge),
136
+ category="auth",
137
+ )
138
+ return code_verifier, code_challenge
139
+
140
+ def _generate_state(self) -> str:
141
+ """Generate secure random state parameter.
142
+
143
+ Returns:
144
+ URL-safe random state string
145
+ """
146
+ return secrets.token_urlsafe(32)
147
+
148
+ def _build_auth_url(self, code_challenge: str, state: str) -> str:
149
+ """Build OAuth authorization URL with PKCE parameters.
150
+
151
+ Args:
152
+ code_challenge: PKCE code challenge
153
+ state: Random state parameter
154
+
155
+ Returns:
156
+ Complete authorization URL
157
+ """
158
+ params = self._get_auth_params(code_challenge, state)
159
+ query_string = urllib.parse.urlencode(params)
160
+ auth_endpoint = self._get_auth_endpoint()
161
+ return f"{auth_endpoint}?{query_string}"
162
+
163
+ def _get_auth_params(self, code_challenge: str, state: str) -> dict[str, str]:
164
+ """Get authorization URL parameters.
165
+
166
+ Args:
167
+ code_challenge: PKCE code challenge
168
+ state: Random state parameter
169
+
170
+ Returns:
171
+ Dictionary of URL parameters
172
+ """
173
+ base_params = {
174
+ "response_type": "code",
175
+ "client_id": self.client_id,
176
+ "redirect_uri": self.redirect_uri,
177
+ "scope": " ".join(self.scopes),
178
+ "state": state,
179
+ "code_challenge": code_challenge,
180
+ "code_challenge_method": "S256",
181
+ }
182
+
183
+ # Allow providers to add custom parameters
184
+ custom_params = self.get_custom_auth_params()
185
+ base_params.update(custom_params)
186
+
187
+ return base_params
188
+
189
+ async def _exchange_code_for_tokens(
190
+ self, code: str, code_verifier: str, state: str | None = None
191
+ ) -> dict[str, Any]:
192
+ """Exchange authorization code for tokens.
193
+
194
+ Args:
195
+ code: Authorization code from OAuth callback
196
+ code_verifier: PKCE code verifier
197
+ state: OAuth state parameter
198
+
199
+ Returns:
200
+ Token response dictionary from provider
201
+
202
+ Raises:
203
+ OAuthTokenRefreshError: If token exchange fails
204
+ """
205
+ token_endpoint = self._get_token_endpoint()
206
+ token_data = self._get_token_exchange_data(code, code_verifier, state)
207
+ headers = self._get_token_exchange_headers()
208
+
209
+ try:
210
+ logger.debug(
211
+ "token_exchange_start",
212
+ endpoint=token_endpoint,
213
+ has_code=bool(code),
214
+ has_verifier=bool(code_verifier),
215
+ category="auth",
216
+ )
217
+
218
+ # No need for OAuth-specific hooks here - generic HTTP hooks will capture everything
219
+
220
+ # Just use self.http_client - it always exists!
221
+ response = await self.http_client.post(
222
+ token_endpoint,
223
+ data=token_data if not self._use_json_for_token_exchange() else None,
224
+ json=token_data if self._use_json_for_token_exchange() else None,
225
+ headers=headers,
226
+ timeout=30.0,
227
+ )
228
+ response.raise_for_status()
229
+
230
+ token_response = response.json()
231
+
232
+ # No need for OAuth-specific hooks here - generic HTTP hooks will capture everything
233
+ logger.debug(
234
+ "token_exchange_success",
235
+ has_access_token="access_token" in token_response,
236
+ has_refresh_token="refresh_token" in token_response,
237
+ expires_in=token_response.get("expires_in"),
238
+ )
239
+
240
+ from typing import cast
241
+
242
+ return cast(dict[str, Any], token_response)
243
+
244
+ except httpx.HTTPStatusError as e:
245
+ error_detail = self._extract_error_detail(e.response)
246
+ logger.error(
247
+ "token_exchange_http_error",
248
+ status_code=e.response.status_code,
249
+ error_detail=error_detail,
250
+ exc_info=e,
251
+ )
252
+
253
+ # No need for OAuth-specific hooks here - generic HTTP hooks will capture everything
254
+
255
+ raise OAuthTokenRefreshError(
256
+ f"Token exchange failed: {error_detail}"
257
+ ) from e
258
+
259
+ except httpx.TimeoutException as e:
260
+ logger.error(
261
+ "token_exchange_timeout", error=str(e), exc_info=e, category="auth"
262
+ )
263
+ raise OAuthTokenRefreshError("Token exchange timed out") from e
264
+
265
+ except httpx.HTTPError as e:
266
+ logger.error(
267
+ "token_exchange_http_error",
268
+ error=str(e),
269
+ exc_info=e,
270
+ category="auth",
271
+ )
272
+ raise OAuthTokenRefreshError(
273
+ f"HTTP error during token exchange: {e}"
274
+ ) from e
275
+
276
+ except Exception as e:
277
+ logger.error("token_exchange_unexpected_error", error=str(e), exc_info=e)
278
+ raise OAuthTokenRefreshError(
279
+ f"Unexpected error during token exchange: {e}"
280
+ ) from e
281
+
282
+ def _get_token_exchange_data(
283
+ self, code: str, code_verifier: str, state: str | None = None
284
+ ) -> dict[str, str]:
285
+ """Get token exchange request data.
286
+
287
+ Note: RFC 6749 Section 4.1.3 specifies that the state parameter should
288
+ NOT be included in token exchange requests. However, some providers
289
+ (like Claude) have non-standard implementations that require it.
290
+
291
+ Args:
292
+ code: Authorization code
293
+ code_verifier: PKCE code verifier
294
+ state: OAuth state parameter
295
+
296
+ Returns:
297
+ Dictionary of token exchange parameters
298
+ """
299
+ base_data = {
300
+ "grant_type": "authorization_code",
301
+ "code": code,
302
+ "redirect_uri": self.redirect_uri,
303
+ "client_id": self.client_id,
304
+ "code_verifier": code_verifier,
305
+ }
306
+
307
+ # RFC 6749 compliant: state parameter should be excluded
308
+ # Override in provider-specific clients if needed (e.g., Claude)
309
+
310
+ # Allow providers to add custom parameters
311
+ custom_data = self.get_custom_token_params()
312
+ base_data.update(custom_data)
313
+
314
+ return base_data
315
+
316
+ def _get_token_exchange_headers(self) -> dict[str, str]:
317
+ """Get headers for token exchange request.
318
+
319
+ Returns:
320
+ Dictionary of HTTP headers
321
+ """
322
+ base_headers = {
323
+ "Accept": "application/json",
324
+ }
325
+
326
+ # Use form encoding by default, unless provider uses JSON
327
+ if not self._use_json_for_token_exchange():
328
+ base_headers["Content-Type"] = "application/x-www-form-urlencoded"
329
+ else:
330
+ base_headers["Content-Type"] = "application/json"
331
+
332
+ # Allow providers to add custom headers
333
+ custom_headers = self.get_custom_headers()
334
+ base_headers.update(custom_headers)
335
+
336
+ return base_headers
337
+
338
+ def _extract_error_detail(self, response: httpx.Response) -> str:
339
+ """Extract error detail from HTTP response.
340
+
341
+ Args:
342
+ response: HTTP response object
343
+
344
+ Returns:
345
+ Human-readable error detail
346
+ """
347
+ try:
348
+ error_data = response.json()
349
+ return str(
350
+ error_data.get(
351
+ "error_description", error_data.get("error", str(response.text))
352
+ )
353
+ )
354
+ except Exception:
355
+ return response.text[:200] if len(response.text) > 200 else response.text
356
+
357
+ def _calculate_expiration(self, expires_in: int | None) -> datetime:
358
+ """Calculate token expiration timestamp.
359
+
360
+ Args:
361
+ expires_in: Seconds until token expires (None defaults to 1 hour)
362
+
363
+ Returns:
364
+ Expiration datetime in UTC
365
+ """
366
+ expires_in = expires_in or 3600 # Default to 1 hour
367
+ return datetime.now(UTC).replace(microsecond=0) + timedelta(seconds=expires_in)
368
+
369
+ # ==================== Abstract Methods ====================
370
+
371
+ @abstractmethod
372
+ async def parse_token_response(self, data: dict[str, Any]) -> CredentialsT:
373
+ """Parse provider-specific token response into credentials.
374
+
375
+ Args:
376
+ data: Raw token response from provider
377
+
378
+ Returns:
379
+ Provider-specific credentials object
380
+ """
381
+ pass
382
+
383
+ @abstractmethod
384
+ def _get_auth_endpoint(self) -> str:
385
+ """Get OAuth authorization endpoint URL.
386
+
387
+ Returns:
388
+ Full authorization endpoint URL
389
+ """
390
+ pass
391
+
392
+ @abstractmethod
393
+ def _get_token_endpoint(self) -> str:
394
+ """Get OAuth token exchange endpoint URL.
395
+
396
+ Returns:
397
+ Full token endpoint URL
398
+ """
399
+ pass
400
+
401
+ # ==================== Optional Override Methods ====================
402
+
403
+ def get_custom_auth_params(self) -> dict[str, str]:
404
+ """Get provider-specific authorization parameters.
405
+
406
+ Override this to add custom parameters to auth URL.
407
+
408
+ Returns:
409
+ Dictionary of custom parameters (empty by default)
410
+ """
411
+ return {}
412
+
413
+ def get_custom_token_params(self) -> dict[str, str]:
414
+ """Get provider-specific token exchange parameters.
415
+
416
+ Override this to add custom parameters to token request.
417
+
418
+ Returns:
419
+ Dictionary of custom parameters (empty by default)
420
+ """
421
+ return {}
422
+
423
+ def get_custom_headers(self) -> dict[str, str]:
424
+ """Get provider-specific HTTP headers.
425
+
426
+ Override this to add custom headers to requests.
427
+
428
+ Returns:
429
+ Dictionary of custom headers (empty by default)
430
+ """
431
+ return {}
432
+
433
+ def _use_json_for_token_exchange(self) -> bool:
434
+ """Whether to use JSON instead of form encoding for token exchange.
435
+
436
+ Override this if provider requires JSON body.
437
+
438
+ Returns:
439
+ False by default (uses form encoding)
440
+ """
441
+ return False
442
+
443
+ # ==================== Public Methods ====================
444
+
445
+ async def authenticate(
446
+ self, code_verifier: str | None = None, state: str | None = None
447
+ ) -> tuple[str, str, str]:
448
+ """Start OAuth authentication flow.
449
+
450
+ Args:
451
+ code_verifier: Optional pre-generated PKCE verifier
452
+ state: Optional pre-generated state parameter
453
+
454
+ Returns:
455
+ Tuple of (auth_url, code_verifier, state)
456
+ """
457
+ # Generate PKCE parameters if not provided
458
+ if not code_verifier:
459
+ code_verifier, code_challenge = self._generate_pkce_pair()
460
+ else:
461
+ # Calculate challenge from provided verifier
462
+ challenge_bytes = hashlib.sha256(code_verifier.encode()).digest()
463
+ code_challenge = (
464
+ base64.urlsafe_b64encode(challenge_bytes).decode().rstrip("=")
465
+ )
466
+
467
+ # Generate state if not provided
468
+ if not state:
469
+ state = self._generate_state()
470
+
471
+ # Build authorization URL
472
+ auth_url = self._build_auth_url(code_challenge, state)
473
+
474
+ logger.info(
475
+ "oauth_flow_started",
476
+ provider=self.__class__.__name__,
477
+ has_storage=bool(self.storage),
478
+ scopes=self.scopes,
479
+ )
480
+
481
+ return auth_url, code_verifier, state
482
+
483
+ async def handle_callback(
484
+ self, code: str, state: str, code_verifier: str
485
+ ) -> CredentialsT:
486
+ """Handle OAuth callback and exchange code for tokens.
487
+
488
+ Args:
489
+ code: Authorization code from callback
490
+ state: State parameter from callback
491
+ code_verifier: PKCE code verifier
492
+
493
+ Returns:
494
+ Provider-specific credentials object
495
+
496
+ Raises:
497
+ OAuthError: If callback handling fails
498
+ """
499
+ try:
500
+ # Exchange code for tokens
501
+ token_response = await self._exchange_code_for_tokens(
502
+ code, code_verifier, state
503
+ )
504
+
505
+ # Parse provider-specific response
506
+ credentials: CredentialsT = await self.parse_token_response(token_response)
507
+
508
+ # Save to storage if available
509
+ if self.storage:
510
+ success = await self.storage.save(credentials)
511
+ if not success:
512
+ logger.warning(
513
+ "credentials_save_failed", provider=self.__class__.__name__
514
+ )
515
+
516
+ logger.info(
517
+ "oauth_callback_success",
518
+ provider=self.__class__.__name__,
519
+ has_refresh_token=bool(token_response.get("refresh_token")),
520
+ )
521
+
522
+ return credentials
523
+
524
+ except OAuthTokenRefreshError:
525
+ raise
526
+ except Exception as e:
527
+ logger.error(
528
+ "oauth_callback_error",
529
+ provider=self.__class__.__name__,
530
+ error=str(e),
531
+ exc_info=e,
532
+ )
533
+ raise OAuthError(f"OAuth callback failed: {e}") from e
@@ -0,0 +1,37 @@
1
+ """Error taxonomy for CLI authentication flows."""
2
+
3
+
4
+ class AuthError(Exception):
5
+ """Base class for authentication errors."""
6
+
7
+ pass
8
+
9
+
10
+ class AuthTimedOutError(AuthError):
11
+ """Authentication process timed out."""
12
+
13
+ pass
14
+
15
+
16
+ class AuthUserAbortedError(AuthError):
17
+ """User cancelled authentication."""
18
+
19
+ pass
20
+
21
+
22
+ class AuthProviderError(AuthError):
23
+ """Provider-specific authentication error."""
24
+
25
+ pass
26
+
27
+
28
+ class NetworkError(AuthError):
29
+ """Network connectivity error."""
30
+
31
+ pass
32
+
33
+
34
+ class PortBindError(AuthError):
35
+ """Failed to bind to required port."""
36
+
37
+ pass