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
@@ -0,0 +1,294 @@
1
+ "CopilotEmbeddingRequestAPI routes for GitHub Copilot plugin."
2
+
3
+ from typing import TYPE_CHECKING, Annotated, Any, Literal, cast
4
+
5
+ from fastapi import APIRouter, Body, Depends, Request
6
+ from fastapi.responses import JSONResponse, Response, StreamingResponse
7
+
8
+ from ccproxy.api.decorators import with_format_chain
9
+ from ccproxy.api.dependencies import (
10
+ get_plugin_adapter,
11
+ get_provider_config_dependency,
12
+ )
13
+ from ccproxy.core.constants import (
14
+ FORMAT_ANTHROPIC_MESSAGES,
15
+ FORMAT_OPENAI_CHAT,
16
+ FORMAT_OPENAI_RESPONSES,
17
+ UPSTREAM_ENDPOINT_COPILOT_INTERNAL_TOKEN,
18
+ UPSTREAM_ENDPOINT_COPILOT_INTERNAL_USER,
19
+ UPSTREAM_ENDPOINT_OPENAI_CHAT_COMPLETIONS,
20
+ UPSTREAM_ENDPOINT_OPENAI_EMBEDDINGS,
21
+ UPSTREAM_ENDPOINT_OPENAI_MODELS,
22
+ )
23
+ from ccproxy.core.logging import get_plugin_logger
24
+ from ccproxy.llms.models import anthropic as anthropic_models
25
+ from ccproxy.llms.models import openai as openai_models
26
+ from ccproxy.streaming import DeferredStreaming
27
+
28
+ from .config import CopilotProviderConfig
29
+ from .models import (
30
+ CopilotHealthResponse,
31
+ CopilotTokenStatus,
32
+ CopilotUserInternalResponse,
33
+ )
34
+
35
+
36
+ if TYPE_CHECKING:
37
+ pass
38
+
39
+ logger = get_plugin_logger()
40
+
41
+ CopilotAdapterDep = Annotated[Any, Depends(get_plugin_adapter("copilot"))]
42
+ CopilotConfigDep = Annotated[
43
+ CopilotProviderConfig,
44
+ Depends(get_provider_config_dependency("copilot", CopilotProviderConfig)),
45
+ ]
46
+
47
+ APIResponse = Response | StreamingResponse | DeferredStreaming
48
+ OpenAIResponse = APIResponse | openai_models.ErrorResponse
49
+
50
+ # V1 API Router - OpenAI/Anthropic compatible endpoints
51
+ router_v1 = APIRouter()
52
+
53
+ # GitHub Copilot specific router - usage, token, health endpoints
54
+ router_github = APIRouter()
55
+
56
+
57
+ def _cast_result(result: object) -> OpenAIResponse:
58
+ return cast(APIResponse, result)
59
+
60
+
61
+ async def _handle_adapter_request(
62
+ request: Request,
63
+ adapter: Any,
64
+ ) -> OpenAIResponse:
65
+ result = await adapter.handle_request(request)
66
+ return _cast_result(result)
67
+
68
+
69
+ def _get_request_body(request: Request) -> Any:
70
+ """Hidden dependency to get raw body."""
71
+
72
+ async def _inner() -> Any:
73
+ return await request.json()
74
+
75
+ return _inner
76
+
77
+
78
+ @router_v1.post(
79
+ "/chat/completions",
80
+ response_model=openai_models.ChatCompletionResponse,
81
+ )
82
+ async def create_openai_chat_completion(
83
+ request: Request,
84
+ adapter: CopilotAdapterDep,
85
+ _: openai_models.ChatCompletionRequest = Body(..., include_in_schema=True),
86
+ body: dict[str, Any] = Depends(_get_request_body, use_cache=False),
87
+ ) -> openai_models.ChatCompletionResponse | OpenAIResponse:
88
+ """Create a chat completion using Copilot with OpenAI-compatible format."""
89
+ request.state.context.metadata["endpoint"] = (
90
+ UPSTREAM_ENDPOINT_OPENAI_CHAT_COMPLETIONS
91
+ )
92
+ return await _handle_adapter_request(request, adapter)
93
+
94
+
95
+ @router_v1.post(
96
+ "/messages",
97
+ response_model=anthropic_models.MessageResponse,
98
+ )
99
+ @with_format_chain(
100
+ [FORMAT_ANTHROPIC_MESSAGES, FORMAT_OPENAI_CHAT],
101
+ endpoint=UPSTREAM_ENDPOINT_OPENAI_CHAT_COMPLETIONS,
102
+ )
103
+ async def create_anthropic_message(
104
+ request: Request,
105
+ _: anthropic_models.CreateMessageRequest,
106
+ adapter: CopilotAdapterDep,
107
+ ) -> anthropic_models.MessageResponse | OpenAIResponse:
108
+ return await _handle_adapter_request(request, adapter)
109
+
110
+
111
+ @with_format_chain(
112
+ [FORMAT_OPENAI_RESPONSES, FORMAT_OPENAI_CHAT],
113
+ endpoint=UPSTREAM_ENDPOINT_OPENAI_CHAT_COMPLETIONS,
114
+ )
115
+ @router_v1.post(
116
+ "/responses",
117
+ response_model=anthropic_models.MessageResponse,
118
+ )
119
+ async def create_responses_message(
120
+ request: Request,
121
+ _: openai_models.ResponseRequest,
122
+ adapter: CopilotAdapterDep,
123
+ ) -> anthropic_models.MessageResponse | OpenAIResponse:
124
+ """Create a message using Response API with OpenAI provider."""
125
+ # Ensure format chain is present in context even if decorator injection is bypassed
126
+ request.state.context.metadata["endpoint"] = (
127
+ UPSTREAM_ENDPOINT_OPENAI_CHAT_COMPLETIONS
128
+ )
129
+ # Explicitly set format_chain so BaseHTTPAdapter applies request conversion
130
+ try:
131
+ prev_chain = getattr(request.state.context, "format_chain", None)
132
+ new_chain = [FORMAT_OPENAI_RESPONSES, FORMAT_OPENAI_CHAT]
133
+ request.state.context.format_chain = new_chain
134
+ logger.debug(
135
+ "copilot_responses_route_enter",
136
+ prev_chain=prev_chain,
137
+ applied_chain=new_chain,
138
+ category="format",
139
+ )
140
+ # Peek at incoming body keys for debugging
141
+ try:
142
+ body_json = await request.json()
143
+ stream_flag = (
144
+ body_json.get("stream") if isinstance(body_json, dict) else None
145
+ )
146
+ logger.debug(
147
+ "copilot_responses_request_body_inspect",
148
+ keys=list(body_json.keys()) if isinstance(body_json, dict) else None,
149
+ stream=stream_flag,
150
+ category="format",
151
+ )
152
+ except Exception as exc: # best-effort logging only
153
+ logger.debug("copilot_responses_request_body_parse_failed", error=str(exc))
154
+ except Exception as exc: # defensive
155
+ logger.debug("copilot_responses_set_chain_failed", error=str(exc))
156
+ return await _handle_adapter_request(request, adapter)
157
+
158
+
159
+ @router_v1.post(
160
+ "/embeddings",
161
+ response_model=openai_models.EmbeddingResponse,
162
+ )
163
+ async def create_embeddings(
164
+ request: Request, _: openai_models.EmbeddingRequest, adapter: CopilotAdapterDep
165
+ ) -> openai_models.EmbeddingResponse | OpenAIResponse:
166
+ request.state.context.metadata["endpoint"] = UPSTREAM_ENDPOINT_OPENAI_EMBEDDINGS
167
+ return await _handle_adapter_request(request, adapter)
168
+
169
+
170
+ @router_v1.get("/models", response_model=openai_models.ModelList)
171
+ async def list_models_v1(
172
+ request: Request,
173
+ adapter: CopilotAdapterDep,
174
+ config: CopilotConfigDep,
175
+ ) -> OpenAIResponse:
176
+ """List available Copilot models."""
177
+ # if config.models_endpoint:
178
+ # models = [card.model_dump(mode="json") for card in config.models_endpoint]
179
+ # return JSONResponse(content={"object": "list", "data": models})
180
+
181
+ # Forward request to upstream Copilot API when no override configured
182
+ request.state.context.metadata["endpoint"] = UPSTREAM_ENDPOINT_OPENAI_MODELS
183
+ return await _handle_adapter_request(request, adapter)
184
+
185
+
186
+ @router_github.get("/usage", response_model=CopilotUserInternalResponse)
187
+ async def get_usage_stats(adapter: CopilotAdapterDep, request: Request) -> Response:
188
+ """Get Copilot usage statistics."""
189
+ request.state.context.metadata["endpoint"] = UPSTREAM_ENDPOINT_COPILOT_INTERNAL_USER
190
+ request.state.context.metadata["method"] = "get"
191
+ result = await adapter.handle_request_gh_api(request)
192
+ return cast(Response, result)
193
+
194
+
195
+ @router_github.get("/token", response_model=CopilotTokenStatus)
196
+ async def get_token_status(adapter: CopilotAdapterDep, request: Request) -> Response:
197
+ """Get Copilot usage statistics."""
198
+ request.state.context.metadata["endpoint"] = (
199
+ UPSTREAM_ENDPOINT_COPILOT_INTERNAL_TOKEN
200
+ )
201
+ request.state.context.metadata["method"] = "get"
202
+ result = await adapter.handle_request_gh_api(request)
203
+ return cast(Response, result)
204
+
205
+
206
+ @router_github.get("/health", response_model=CopilotHealthResponse)
207
+ async def health_check(adapter: CopilotAdapterDep) -> JSONResponse:
208
+ """Check Copilot plugin health."""
209
+ try:
210
+ logger.debug("performing_health_check")
211
+
212
+ # Check components
213
+ details: dict[str, Any] = {}
214
+
215
+ # Check OAuth provider
216
+ oauth_healthy = True
217
+ if adapter.oauth_provider:
218
+ try:
219
+ oauth_healthy = await adapter.oauth_provider.is_authenticated()
220
+ details["oauth"] = {
221
+ "authenticated": oauth_healthy,
222
+ "provider": "github_copilot",
223
+ }
224
+ except Exception as e:
225
+ oauth_healthy = False
226
+ details["oauth"] = {
227
+ "authenticated": False,
228
+ "error": str(e),
229
+ }
230
+ else:
231
+ oauth_healthy = False
232
+ details["oauth"] = {"error": "OAuth provider not initialized"}
233
+
234
+ # Check detection service
235
+ detection_healthy = True
236
+ if adapter.detection_service:
237
+ try:
238
+ cli_info = adapter.detection_service.get_cli_health_info()
239
+ details["github_cli"] = {
240
+ "available": cli_info.available,
241
+ "version": cli_info.version,
242
+ "authenticated": cli_info.authenticated,
243
+ "username": cli_info.username,
244
+ "error": cli_info.error,
245
+ }
246
+ detection_healthy = cli_info.available and cli_info.authenticated
247
+ except Exception as e:
248
+ detection_healthy = False
249
+ details["github_cli"] = {"error": str(e)}
250
+ else:
251
+ details["github_cli"] = {"error": "Detection service not initialized"}
252
+
253
+ # Overall health
254
+ overall_status: Literal["healthy", "unhealthy"] = (
255
+ "healthy" if oauth_healthy and detection_healthy else "unhealthy"
256
+ )
257
+
258
+ health_response = CopilotHealthResponse(
259
+ status=overall_status,
260
+ provider="copilot",
261
+ details=details,
262
+ )
263
+
264
+ status_code = 200 if overall_status == "healthy" else 503
265
+
266
+ logger.info(
267
+ "health_check_completed",
268
+ status=overall_status,
269
+ oauth_healthy=oauth_healthy,
270
+ detection_healthy=detection_healthy,
271
+ )
272
+
273
+ return JSONResponse(
274
+ content=health_response.model_dump(),
275
+ status_code=status_code,
276
+ )
277
+
278
+ except Exception as e:
279
+ logger.error(
280
+ "health_check_failed",
281
+ error=str(e),
282
+ exc_info=e,
283
+ )
284
+
285
+ health_response = CopilotHealthResponse(
286
+ status="unhealthy",
287
+ provider="copilot",
288
+ details={"error": str(e)},
289
+ )
290
+
291
+ return JSONResponse(
292
+ content=health_response.model_dump(),
293
+ status_code=503,
294
+ )
@@ -0,0 +1,124 @@
1
+ # Credential Balancer (system plugin)
2
+
3
+ The credential balancer manages pools of upstream credentials (API keys, OAuth tokens, etc.) for a given provider and rotates between them based on health. It integrates as a system plugin and exposes a registry key (auth manager) that provider plugins can use to fetch a currently healthy credential at request time.
4
+
5
+ - Balances across multiple credential files per provider.
6
+ - Detects failures from HTTP responses and temporarily disables bad credentials with cooldowns.
7
+ - Supports manual refresh, proportional selection, sticky-on-success, and backoff.
8
+ - Exposes a named auth manager registry key (defaults to `<provider>_credential_balancer`).
9
+
10
+ ## When to use
11
+
12
+ Use the balancer when you have multiple tokens for the same provider and want resilient failover and automatic rotation without changing application code or secrets storage.
13
+
14
+ ## Quick Start (minimal)
15
+
16
+ The following minimal example configures CCproxy with the Codex provider to use a pool of Codex OAuth tokens.
17
+
18
+ ```toml
19
+ [plugins]
20
+ # Enable the credential balancer system plugin
21
+ enabled_plugins = [
22
+ "codex",
23
+ "oauth_codex",
24
+ "credential_balancer"
25
+ ]
26
+
27
+ # Point the codex provider at the balancer-managed auth manager
28
+ [plugins.codex]
29
+ auth_manager = "codex_credential_balancer"
30
+
31
+ [[plugins.credential_balancer.providers]]
32
+ provider = "codex"
33
+ strategy = "round_robin" # or "failover"
34
+
35
+ manager_class = "ccproxy.plugins.oauth_codex.manager.CodexTokenManager"
36
+ storage_class = "ccproxy.plugins.oauth_codex.storage.CodexTokenStorage"
37
+
38
+ credentials = [
39
+ { path = "~/.config/ccproxy/codex_plus.json" },
40
+ { path = "~/.config/ccproxy/codex_pro.json" },
41
+ ]
42
+
43
+ ```
44
+ ## Full Configuration Reference
45
+
46
+ Enable the system plugin and define one or more provider pools. Each pool declares where to read credentials from and optional tuning parameters. See `config.example.toml` for full, commented examples.
47
+
48
+ ```toml
49
+ [[plugins.credential_balancer.providers]]
50
+ # Provider identifier, e.g. "claude-api", "openai", "codex".
51
+ provider = "claude-api"
52
+ strategy = "round_robin" # or "failover"
53
+ max_failures_before_disable = 2
54
+ cooldown_seconds = 120.0
55
+ failure_status_codes = [401, 403]
56
+
57
+ # Pool defaults (example: Claude OAuth manager/storage)
58
+ manager_class = "ccproxy.plugins.oauth_claude.manager.ClaudeApiTokenManager"
59
+ storage_class = "ccproxy.plugins.oauth_claude.storage.ClaudeOAuthStorage"
60
+
61
+ credentials = [
62
+ { type = "manager", file = "~/.config/ccproxy/claude_primary.json", label = "primary" },
63
+ { type = "manager", file = "~/.config/ccproxy/claude_backup.json", label = "backup" },
64
+ ]
65
+ ```
66
+
67
+ After defining a pool, point the corresponding provider plugin at the balancer by overriding its auth manager to the registry key:
68
+
69
+ ```toml
70
+ [plugins.claude-api]
71
+ # Use the balancer-provided registry entry instead of a static key file
72
+ auth_manager = "claude-api_credential_balancer"
73
+ ```
74
+
75
+ If you set a custom `manager_name` in the balancer configuration, use that value for `auth_manager` instead.
76
+
77
+ ## How it works
78
+
79
+ - Startup: for each entry in `[[plugins.credential_balancer.providers]]`, the plugin constructs a Manager that loads credentials from the declared files and registers it under `manager_name`.
80
+ - Request path: provider adapters ask the registry for a credential via the `auth_manager` key; the balancer selects a currently healthy token.
81
+ - Feedback loop: the `credential_balancer` hook observes provider HTTP responses and records failures/successes to update health, handle cooldowns, and trigger failover when necessary.
82
+
83
+ ## TODO
84
+
85
+ - Extract cooldown period from provider error responses and apply dynamic per-credential cooldowns.
86
+ - Collect and parse HTTP error payloads/headers in the hook (e.g., Retry-After or equivalent fields).
87
+ - Pass an optional cooldown override with the failure event to the manager.
88
+ - Ensure logs include the derived cooldown value for observability.
89
+
90
+ ## Logs and observability
91
+
92
+ The plugin emits structured events to aid troubleshooting, including (non-exhaustive):
93
+ - `credential_balancer_manager_registered`
94
+ - `credential_balancer_token_selected`
95
+ - `credential_balancer_failure_detected`
96
+ - `credential_balancer_failover`
97
+ - `credential_balancer_manual_refresh_succeeded`
98
+
99
+ During development, server logs stream to `/tmp/ccproxy/ccproxy.log` when running `ccproxy serve`.
100
+
101
+ ## Files and APIs
102
+
103
+ - Runtime code: `ccproxy/plugins/credential_balancer/`
104
+ - `plugin.py`: plugin factory and lifecycle wiring
105
+ - `manager.py`: rotation, health, selection, and feedback processing
106
+ - `hook.py`: HTTP lifecycle hook that feeds response outcomes back to the manager
107
+ - `config.py`: Pydantic models for pool configuration and defaults
108
+ - Enable via `pyproject.toml` entry point `credential_balancer` (already wired).
109
+
110
+ ## Testing
111
+
112
+ - Unit tests: `tests/plugins/credential_balancer/unit/`
113
+ - Run fast tests: `./Taskfile test-unit`
114
+ - Full suite: `./Taskfile test`
115
+
116
+ Follow the project’s testing markers and async patterns as described in `TESTING.md`.
117
+
118
+ ## Further reading
119
+
120
+ - Authentication overview: `docs/user-guide/authentication.md`
121
+ - Example configuration: `config.example.toml`
122
+
123
+ Commands
124
+ - `uv run ccproxy serve` (logs at `/tmp/ccproxy/ccproxy.log`)
@@ -0,0 +1,6 @@
1
+ """Credential balancer plugin."""
2
+
3
+ from .plugin import factory
4
+
5
+
6
+ __all__ = ["factory"]
@@ -0,0 +1,270 @@
1
+ """Configuration models for the credential balancer plugin."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from enum import Enum
7
+ from pathlib import Path
8
+ from typing import Any, Literal
9
+
10
+ from pydantic import BaseModel, Field, ValidationInfo, field_validator, model_validator
11
+
12
+
13
+ class RotationStrategy(str, Enum):
14
+ """Supported credential selection strategies."""
15
+
16
+ ROUND_ROBIN = "round_robin"
17
+ FAILOVER = "failover"
18
+
19
+
20
+ class CredentialSource(BaseModel):
21
+ """Base model for credential sources."""
22
+
23
+ type: Literal["manager"] = Field(
24
+ default="manager", description="Type of credential source"
25
+ )
26
+ label: str | None = Field(
27
+ default=None,
28
+ description="Optional friendly name used for logging and metrics",
29
+ )
30
+
31
+ @property
32
+ def resolved_label(self) -> str:
33
+ """Return a non-empty label for this credential source."""
34
+ return self.label or "unlabeled"
35
+
36
+
37
+ class CredentialManager(CredentialSource):
38
+ """Configuration for a manager-based credential source with provider-specific logic.
39
+
40
+ Specify either manager_key (registry lookup) or manager_class (direct import).
41
+
42
+ The config dict supports additional options:
43
+
44
+ **Storage options:**
45
+ - `enable_backups` (bool): Create timestamped backups before overwriting credentials (default: True)
46
+
47
+ **Manager options:**
48
+ - `credentials_ttl` (float): Seconds to cache credentials before rechecking storage (default: 30.0)
49
+ - `refresh_grace_seconds` (float): Seconds before expiry to trigger proactive token refresh (default: 120.0)
50
+
51
+ Example:
52
+ ```toml
53
+ { type = "manager",
54
+ file = "~/.config/ccproxy/codex_pro.json",
55
+ config = {
56
+ enable_backups = true,
57
+ credentials_ttl = 60.0,
58
+ refresh_grace_seconds = 300.0
59
+ }
60
+ }
61
+ ```
62
+ """
63
+
64
+ type: Literal["manager"] = "manager"
65
+ file: Path | None = Field(
66
+ default=None,
67
+ description="Path to custom credential file (overrides default storage location)",
68
+ )
69
+ manager_key: str | None = Field(
70
+ default=None,
71
+ description="Auth manager registry key (e.g., 'codex', 'claude-api'). Mutually exclusive with manager_class.",
72
+ )
73
+ manager_class: str | None = Field(
74
+ default=None,
75
+ description="Fully qualified manager class name (e.g., 'ccproxy.plugins.oauth_codex.manager.CodexTokenManager'). Mutually exclusive with manager_key.",
76
+ )
77
+ storage_class: str | None = Field(
78
+ default=None,
79
+ description="Fully qualified storage class name (e.g., 'ccproxy.plugins.oauth_codex.storage.CodexTokenStorage'). Required when using manager_class with custom file.",
80
+ )
81
+ config: dict[str, Any] = Field(
82
+ default_factory=dict,
83
+ description="Additional manager and storage configuration options (see class docstring for supported keys)",
84
+ )
85
+ label: str | None = Field(
86
+ default=None,
87
+ description="Optional friendly name used for logging and metrics",
88
+ )
89
+
90
+ @field_validator("file", mode="before")
91
+ @classmethod
92
+ def _expand_file_path(cls, value: Path | str | None) -> Path | None:
93
+ """Expand environment variables and user home directory in file path."""
94
+ if value is None:
95
+ return None
96
+ raw_value = str(value)
97
+ expanded = os.path.expandvars(raw_value)
98
+ return Path(expanded).expanduser()
99
+
100
+ @model_validator(mode="after")
101
+ def _validate_manager_specification(self) -> CredentialManager:
102
+ # Allow both to be None - they may be inherited from pool-level defaults
103
+ # But if both are specified, that's an error
104
+ if self.manager_key and self.manager_class:
105
+ raise ValueError(
106
+ "manager_key and manager_class are mutually exclusive, specify only one"
107
+ )
108
+ # If using manager_class with custom file, storage_class is required
109
+ # (unless it will be inherited from pool defaults)
110
+ if self.manager_class and self.file and not self.storage_class:
111
+ raise ValueError(
112
+ "storage_class is required when using manager_class with custom file path"
113
+ )
114
+ return self
115
+
116
+ @model_validator(mode="after")
117
+ def _populate_default_label(self) -> CredentialManager:
118
+ if self.label is None:
119
+ if self.manager_key:
120
+ self.label = self.manager_key
121
+ elif self.manager_class:
122
+ # Extract class name from fully qualified path
123
+ self.label = self.manager_class.rsplit(".", 1)[-1]
124
+ else:
125
+ self.label = "unlabeled"
126
+ return self
127
+
128
+ @property
129
+ def resolved_label(self) -> str:
130
+ """Return a non-empty label for this credential manager."""
131
+ if self.label:
132
+ return self.label
133
+ if self.manager_key:
134
+ return self.manager_key
135
+ if self.manager_class:
136
+ return self.manager_class.rsplit(".", 1)[-1]
137
+ return "unlabeled"
138
+
139
+
140
+ class CredentialPoolConfig(BaseModel):
141
+ """Configuration for an individual credential pool."""
142
+
143
+ provider: str = Field(..., description="Internal provider identifier")
144
+ manager_name: str | None = Field(
145
+ default=None,
146
+ description="Registry key to expose this balancer (defaults to '<provider>_credential_balancer')",
147
+ )
148
+ strategy: RotationStrategy = Field(
149
+ default=RotationStrategy.FAILOVER,
150
+ description="How credentials are selected for new requests",
151
+ )
152
+ manager_class: str | None = Field(
153
+ default=None,
154
+ description="Default manager class for all credentials in this pool (can be overridden per credential)",
155
+ )
156
+ storage_class: str | None = Field(
157
+ default=None,
158
+ description="Default storage class for all credentials in this pool (can be overridden per credential)",
159
+ )
160
+ credentials: list[CredentialManager] = Field(
161
+ default_factory=list,
162
+ description="Ordered list of manager-based credential sources participating in the pool",
163
+ )
164
+ max_failures_before_disable: int = Field(
165
+ default=2,
166
+ ge=1,
167
+ description="Number of failed responses tolerated before disabling a credential",
168
+ )
169
+ cooldown_seconds: float = Field(
170
+ default=60.0,
171
+ ge=0.0,
172
+ description="Cooldown window before a failed credential becomes eligible again",
173
+ )
174
+ failure_status_codes: list[int] = Field(
175
+ default_factory=lambda: [401, 403],
176
+ description="HTTP status codes that indicate credential failure",
177
+ )
178
+
179
+ @field_validator("credentials")
180
+ @classmethod
181
+ def _ensure_credentials_present(
182
+ cls, value: list[CredentialManager], _info: ValidationInfo
183
+ ) -> list[CredentialManager]:
184
+ if not value:
185
+ raise ValueError(
186
+ "credential pool must contain at least one credential file"
187
+ )
188
+ return value
189
+
190
+ @field_validator("failure_status_codes")
191
+ @classmethod
192
+ def _validate_status_codes(cls, codes: list[int]) -> list[int]:
193
+ normalised = sorted({code for code in codes if code >= 400})
194
+ if not normalised:
195
+ raise ValueError("at least one failure status code is required")
196
+ return normalised
197
+
198
+ @model_validator(mode="after")
199
+ def _apply_default_manager_name(self) -> CredentialPoolConfig:
200
+ if not self.manager_name:
201
+ self.manager_name = f"{self.provider}_credential_balancer"
202
+ return self
203
+
204
+ @model_validator(mode="after")
205
+ def _apply_pool_defaults_to_credentials(self) -> CredentialPoolConfig:
206
+ """Apply pool-level manager_class and storage_class to credentials that don't specify them."""
207
+ if not self.manager_class and not self.storage_class:
208
+ # No pool-level defaults to apply
209
+ return self
210
+
211
+ for cred in self.credentials:
212
+ # Only apply to CredentialManager type
213
+ if isinstance(cred, CredentialManager):
214
+ # Apply pool-level manager_class if credential doesn't specify one
215
+ if (
216
+ self.manager_class
217
+ and not cred.manager_class
218
+ and not cred.manager_key
219
+ ):
220
+ cred.manager_class = self.manager_class
221
+
222
+ # Apply pool-level storage_class if credential doesn't specify one
223
+ if self.storage_class and not cred.storage_class:
224
+ cred.storage_class = self.storage_class
225
+
226
+ return self
227
+
228
+ @model_validator(mode="after")
229
+ def _validate_credentials_after_defaults(self) -> CredentialPoolConfig:
230
+ """Validate that all credentials have required manager information after applying defaults."""
231
+ for idx, cred in enumerate(self.credentials):
232
+ if isinstance(cred, CredentialManager):
233
+ # After applying defaults, each credential must have either manager_key or manager_class
234
+ if not cred.manager_key and not cred.manager_class:
235
+ raise ValueError(
236
+ f"Credential at index {idx} missing manager specification. "
237
+ f"Either set manager_key/manager_class on the credential, "
238
+ f"or set manager_class at pool level."
239
+ )
240
+ # If using manager_class with file, storage_class is required
241
+ if cred.manager_class and cred.file and not cred.storage_class:
242
+ raise ValueError(
243
+ f"Credential at index {idx} with manager_class and file path "
244
+ f"requires storage_class (either on credential or at pool level)"
245
+ )
246
+ return self
247
+
248
+
249
+ class CredentialBalancerSettings(BaseModel):
250
+ """Top-level plugin settings."""
251
+
252
+ enabled: bool = Field(default=True, description="Enable credential balancer")
253
+ providers: list[CredentialPoolConfig] = Field(
254
+ default_factory=list, description="Pools managed by the balancer"
255
+ )
256
+
257
+ @field_validator("providers")
258
+ @classmethod
259
+ def _ensure_unique_manager_names(
260
+ cls, value: list[CredentialPoolConfig]
261
+ ) -> list[CredentialPoolConfig]:
262
+ seen: set[str] = set()
263
+ for pool in value:
264
+ manager_name = pool.manager_name
265
+ if manager_name is None:
266
+ raise ValueError("manager name resolution failed")
267
+ if manager_name in seen:
268
+ raise ValueError(f"duplicate manager name detected: {manager_name}")
269
+ seen.add(manager_name)
270
+ return value