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,587 @@
1
+ """Credential rotation manager for the credential balancer plugin."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import time
7
+ import uuid
8
+ from dataclasses import dataclass, field
9
+ from datetime import UTC, datetime
10
+ from types import TracebackType
11
+ from typing import TYPE_CHECKING, Any, cast
12
+
13
+ from ccproxy.auth.exceptions import AuthenticationError
14
+ from ccproxy.auth.manager import AuthManager
15
+ from ccproxy.auth.managers.token_snapshot import TokenSnapshot
16
+ from ccproxy.auth.models.credentials import BaseCredentials
17
+ from ccproxy.auth.oauth.protocol import StandardProfileFields
18
+ from ccproxy.core.logging import TraceBoundLogger, get_plugin_logger
19
+ from ccproxy.core.request_context import RequestContext
20
+
21
+ from .config import CredentialPoolConfig, CredentialSource, RotationStrategy
22
+
23
+
24
+ if TYPE_CHECKING:
25
+ from .factory import AuthManagerFactory
26
+
27
+
28
+ logger = get_plugin_logger(__name__)
29
+
30
+ SNAPSHOT_REFRESH_GRACE_SECONDS = 120.0
31
+
32
+
33
+ @dataclass(slots=True)
34
+ class CredentialEntry:
35
+ """Wrapper for an AuthManager with failure tracking and cooldown logic."""
36
+
37
+ config: CredentialSource
38
+ manager: AuthManager
39
+ max_failures: int
40
+ cooldown_seconds: float
41
+ logger: TraceBoundLogger
42
+ _failure_count: int = 0
43
+ _disabled_until: float | None = None
44
+ _lock: asyncio.Lock = field(default_factory=asyncio.Lock, init=False)
45
+
46
+ @property
47
+ def label(self) -> str:
48
+ """Return a stable label for this credential entry."""
49
+ return self.config.resolved_label
50
+
51
+ async def get_access_token(self) -> str:
52
+ """Get access token from the composed manager.
53
+
54
+ Returns:
55
+ Access token string
56
+
57
+ Raises:
58
+ AuthenticationError: If no valid token available
59
+ """
60
+ async with self._lock:
61
+ return await self.manager.get_access_token()
62
+
63
+ async def get_access_token_with_refresh(self) -> str:
64
+ """Get access token with automatic refresh if supported.
65
+
66
+ Returns:
67
+ Access token string
68
+
69
+ Raises:
70
+ AuthenticationError: If no valid token available
71
+ """
72
+ async with self._lock:
73
+ # Try to use enhanced refresh if available
74
+ if hasattr(self.manager, "get_access_token_with_refresh"):
75
+ return await self.manager.get_access_token_with_refresh() # type: ignore
76
+ # Fallback to basic get_access_token
77
+ return await self.manager.get_access_token()
78
+
79
+ async def is_authenticated(self) -> bool:
80
+ """Check if manager has valid authentication.
81
+
82
+ Returns:
83
+ True if authenticated, False otherwise
84
+ """
85
+ try:
86
+ async with self._lock:
87
+ return await self.manager.is_authenticated()
88
+ except Exception:
89
+ return False
90
+
91
+ def mark_failure(self) -> None:
92
+ """Record a failure and potentially disable this credential."""
93
+ self._failure_count += 1
94
+ self.logger.debug(
95
+ "credential_balancer_failure_recorded",
96
+ credential=self.label,
97
+ failures=self._failure_count,
98
+ )
99
+ if self._failure_count >= self.max_failures:
100
+ if self.cooldown_seconds > 0:
101
+ self._disabled_until = time.monotonic() + self.cooldown_seconds
102
+ else:
103
+ self._disabled_until = float("inf")
104
+ self.logger.warning(
105
+ "credential_balancer_credential_disabled",
106
+ credential=self.label,
107
+ cooldown_seconds=self.cooldown_seconds,
108
+ failures=self._failure_count,
109
+ )
110
+
111
+ def reset_failures(self) -> None:
112
+ """Reset failure count and re-enable this credential."""
113
+ if self._failure_count or self._disabled_until:
114
+ self.logger.debug(
115
+ "credential_balancer_failure_reset",
116
+ credential=self.label,
117
+ )
118
+ self._failure_count = 0
119
+ self._disabled_until = None
120
+
121
+ def is_disabled(self, now: float) -> bool:
122
+ """Check if this credential is currently disabled.
123
+
124
+ Args:
125
+ now: Current monotonic time
126
+
127
+ Returns:
128
+ True if disabled, False if available
129
+ """
130
+ if self._disabled_until is None:
131
+ return False
132
+ if self._disabled_until == float("inf"):
133
+ return True
134
+ if now >= self._disabled_until:
135
+ self.logger.debug(
136
+ "credential_balancer_cooldown_expired",
137
+ credential=self.label,
138
+ )
139
+ self._disabled_until = None
140
+ self._failure_count = 0
141
+ return False
142
+ return True
143
+
144
+
145
+ @dataclass(slots=True)
146
+ class _RequestState:
147
+ entry: CredentialEntry
148
+ renew_attempted: bool = False
149
+ created_at: float = field(default_factory=time.monotonic)
150
+
151
+
152
+ class CredentialBalancerTokenManager(AuthManager):
153
+ """Auth manager that rotates across multiple credential sources."""
154
+
155
+ def __init__(
156
+ self,
157
+ config: CredentialPoolConfig,
158
+ entries: list[CredentialEntry],
159
+ *,
160
+ logger: TraceBoundLogger | None = None,
161
+ ) -> None:
162
+ """Initialize credential balancer with pre-created entries.
163
+
164
+ Args:
165
+ config: Pool configuration
166
+ entries: List of credential entries with composed managers
167
+ logger: Optional logger for this manager
168
+ """
169
+ self._config = config
170
+ self._logger = (logger or get_plugin_logger(__name__)).bind(
171
+ manager=config.manager_name,
172
+ provider=config.provider,
173
+ )
174
+ self._entries = entries
175
+ self._strategy = config.strategy
176
+ self._failure_codes = set(config.failure_status_codes)
177
+ self._lock = asyncio.Lock()
178
+ self._state_lock = asyncio.Lock()
179
+ self._request_states: dict[str, _RequestState] = {}
180
+ self._active_index = 0
181
+ self._next_index = 0
182
+
183
+ @classmethod
184
+ async def create(
185
+ cls,
186
+ config: CredentialPoolConfig,
187
+ factory: AuthManagerFactory | None = None,
188
+ *,
189
+ logger: TraceBoundLogger | None = None,
190
+ ) -> CredentialBalancerTokenManager:
191
+ """Async factory to create balancer with composed managers.
192
+
193
+ Args:
194
+ config: Pool configuration
195
+ factory: Auth manager factory for creating managers from sources
196
+ logger: Optional logger for this manager
197
+
198
+ Returns:
199
+ Initialized CredentialBalancerTokenManager instance
200
+ """
201
+ from ccproxy.plugins.credential_balancer.factory import AuthManagerFactory
202
+
203
+ if factory is None:
204
+ factory = AuthManagerFactory(logger=logger)
205
+
206
+ bound_logger = (logger or get_plugin_logger(__name__)).bind(
207
+ manager=config.manager_name,
208
+ provider=config.provider,
209
+ )
210
+
211
+ # Create entries with composed managers
212
+ entries: list[CredentialEntry] = []
213
+ failed_credentials: list[str] = []
214
+
215
+ for credential in config.credentials:
216
+ try:
217
+ manager = await factory.create_from_source(credential, config.provider)
218
+ entry = CredentialEntry(
219
+ config=credential,
220
+ manager=manager,
221
+ max_failures=config.max_failures_before_disable,
222
+ cooldown_seconds=config.cooldown_seconds,
223
+ logger=bound_logger.bind(credential=credential.resolved_label),
224
+ )
225
+ entries.append(entry)
226
+ except AuthenticationError as e:
227
+ # Log clean warning for failed credential without stack trace
228
+ label = credential.resolved_label
229
+ bound_logger.warning(
230
+ "credential_balancer_credential_skipped",
231
+ credential=label,
232
+ reason=str(e),
233
+ category="auth",
234
+ )
235
+ failed_credentials.append(label)
236
+ continue
237
+ except Exception as e:
238
+ # Unexpected errors still get logged with type info
239
+ label = credential.resolved_label
240
+ bound_logger.error(
241
+ "credential_balancer_credential_failed",
242
+ credential=label,
243
+ error=str(e),
244
+ error_type=type(e).__name__,
245
+ category="auth",
246
+ )
247
+ failed_credentials.append(label)
248
+ continue
249
+
250
+ # Warn if some credentials failed
251
+ if failed_credentials:
252
+ bound_logger.warning(
253
+ "credential_balancer_partial_initialization",
254
+ total=len(config.credentials),
255
+ failed=len(failed_credentials),
256
+ succeeded=len(entries),
257
+ failed_labels=failed_credentials,
258
+ )
259
+
260
+ # Ensure we have at least one valid credential
261
+ if not entries:
262
+ raise AuthenticationError(
263
+ f"No valid credentials available for {config.manager_name}. "
264
+ f"All {len(config.credentials)} credential(s) failed to load."
265
+ )
266
+
267
+ return cls(config, entries, logger=logger)
268
+
269
+ async def get_access_token(self) -> str:
270
+ """Get access token from selected credential entry.
271
+
272
+ Returns:
273
+ Access token string
274
+
275
+ Raises:
276
+ AuthenticationError: If no valid token available
277
+ """
278
+ entry = await self._select_entry()
279
+ try:
280
+ token = await entry.get_access_token()
281
+ request_id = await self._register_request(entry)
282
+ self._logger.debug(
283
+ "credential_balancer_token_selected",
284
+ credential=entry.label,
285
+ request_id=request_id,
286
+ )
287
+ return token
288
+ except AuthenticationError:
289
+ entry.mark_failure()
290
+ await self._handle_entry_failure(entry)
291
+ raise
292
+
293
+ async def get_access_token_with_refresh(self) -> str:
294
+ """Get access token with automatic refresh if supported.
295
+
296
+ Returns:
297
+ Access token string
298
+
299
+ Raises:
300
+ AuthenticationError: If no valid token available
301
+ """
302
+ try:
303
+ return await self.get_access_token()
304
+ except AuthenticationError as exc:
305
+ # Try to refresh the active entry's token
306
+ entry = await self._select_entry(require_active=True)
307
+ try:
308
+ token = await entry.get_access_token_with_refresh()
309
+ request_id = await self._register_request(entry)
310
+ self._logger.debug(
311
+ "credential_balancer_manual_refresh_succeeded",
312
+ credential=entry.label,
313
+ request_id=request_id,
314
+ )
315
+ return token
316
+ except AuthenticationError:
317
+ self._logger.debug(
318
+ "credential_balancer_manual_refresh_failed",
319
+ credential=entry.label,
320
+ )
321
+ raise exc
322
+
323
+ async def get_credentials(self) -> BaseCredentials:
324
+ raise AuthenticationError(
325
+ "Credential balancer does not expose provider-specific credential models"
326
+ )
327
+
328
+ async def is_authenticated(self) -> bool:
329
+ """Check if any credential is authenticated.
330
+
331
+ Returns:
332
+ True if at least one credential is authenticated, False otherwise
333
+ """
334
+ try:
335
+ entry = await self._select_entry()
336
+ except AuthenticationError:
337
+ return False
338
+ return await entry.is_authenticated()
339
+
340
+ async def get_user_profile(self) -> StandardProfileFields | None:
341
+ """Get user profile (not available for balancer).
342
+
343
+ Returns:
344
+ None, as balancer aggregates multiple credentials
345
+ """
346
+ return None
347
+
348
+ async def get_profile_quick(self) -> Any:
349
+ """Get profile information without I/O (for compatibility).
350
+
351
+ Returns:
352
+ None, as balancer doesn't maintain profile cache
353
+ """
354
+ return None
355
+
356
+ async def validate_credentials(self) -> bool:
357
+ """Validate that credentials are available and valid.
358
+
359
+ Returns:
360
+ True if valid credentials available, False otherwise
361
+ """
362
+ return await self.is_authenticated()
363
+
364
+ def get_provider_name(self) -> str:
365
+ """Get the provider name for this balancer.
366
+
367
+ Returns:
368
+ Provider name string
369
+ """
370
+ return self._config.provider
371
+
372
+ async def __aenter__(self) -> CredentialBalancerTokenManager:
373
+ """Async context manager entry."""
374
+ return self
375
+
376
+ async def __aexit__(
377
+ self,
378
+ exc_type: type[BaseException] | None,
379
+ exc: BaseException | None,
380
+ tb: TracebackType | None,
381
+ ) -> None:
382
+ """Async context manager exit."""
383
+ return None
384
+
385
+ async def load_credentials(self) -> dict[str, TokenSnapshot | None]:
386
+ """Load token snapshots from all credential entries.
387
+
388
+ Returns:
389
+ Dictionary mapping credential labels to their token snapshots
390
+ """
391
+ results: dict[str, TokenSnapshot | None] = {}
392
+ for entry in self._entries:
393
+ # Try to get token snapshot from manager if supported
394
+ if hasattr(entry.manager, "get_token_snapshot"):
395
+ try:
396
+ # Cast to avoid mypy errors with protocol
397
+ get_snapshot = cast(Any, entry.manager).get_token_snapshot
398
+ snapshot = cast(TokenSnapshot | None, await get_snapshot())
399
+ results[entry.label] = snapshot
400
+ except Exception:
401
+ results[entry.label] = None
402
+ else:
403
+ results[entry.label] = None
404
+ return results
405
+
406
+ async def get_token_snapshot(self) -> TokenSnapshot | None:
407
+ """Get token snapshot from selected credential entry.
408
+
409
+ Returns:
410
+ TokenSnapshot if available, None otherwise
411
+ """
412
+ entry = await self._select_entry()
413
+ if hasattr(entry.manager, "get_token_snapshot"):
414
+ try:
415
+ # Cast to avoid mypy errors with protocol
416
+ get_snapshot = cast(Any, entry.manager).get_token_snapshot
417
+ return cast(TokenSnapshot | None, await get_snapshot())
418
+ except Exception:
419
+ return None
420
+ return None
421
+
422
+ def should_refresh(
423
+ self, credentials: object, grace_seconds: float | None = None
424
+ ) -> bool:
425
+ snapshots: list[TokenSnapshot] = []
426
+ if isinstance(credentials, dict):
427
+ for value in credentials.values():
428
+ if value is None:
429
+ return True
430
+ if isinstance(value, TokenSnapshot):
431
+ snapshots.append(value)
432
+ elif isinstance(credentials, TokenSnapshot):
433
+ snapshots = [credentials]
434
+ else:
435
+ return False
436
+
437
+ if not snapshots:
438
+ return False
439
+
440
+ threshold = (
441
+ SNAPSHOT_REFRESH_GRACE_SECONDS
442
+ if grace_seconds is None
443
+ else max(grace_seconds, 0.0)
444
+ )
445
+
446
+ now = datetime.now(UTC)
447
+ for snapshot in snapshots:
448
+ expires_at = snapshot.expires_at
449
+ if expires_at is None:
450
+ continue
451
+ if expires_at.tzinfo is None:
452
+ expires_at = expires_at.replace(tzinfo=UTC)
453
+ remaining = (expires_at - now).total_seconds()
454
+ if remaining <= threshold:
455
+ return True
456
+
457
+ return any(not snapshot.access_token for snapshot in snapshots)
458
+
459
+ async def handle_response_event(
460
+ self, request_id: str | None, status_code: int | None
461
+ ) -> bool:
462
+ if not request_id:
463
+ return False
464
+
465
+ async with self._state_lock:
466
+ state = self._request_states.pop(request_id, None)
467
+ if state is None:
468
+ return False
469
+
470
+ entry = state.entry
471
+ if status_code is None:
472
+ self._logger.debug(
473
+ "credential_balancer_event_without_status",
474
+ credential=entry.label,
475
+ request_id=request_id,
476
+ )
477
+ return True
478
+
479
+ if status_code < 400:
480
+ entry.reset_failures()
481
+ return True
482
+
483
+ if status_code not in self._failure_codes:
484
+ return True
485
+
486
+ self._logger.warning(
487
+ "credential_balancer_failure_detected",
488
+ credential=entry.label,
489
+ request_id=request_id,
490
+ status_code=status_code,
491
+ )
492
+
493
+ entry.mark_failure()
494
+ await self._handle_entry_failure(entry)
495
+ return True
496
+
497
+ async def cleanup_expired_requests(self, max_age_seconds: float = 120.0) -> None:
498
+ cutoff = time.monotonic() - max_age_seconds
499
+ async with self._state_lock:
500
+ stale = [
501
+ key
502
+ for key, value in self._request_states.items()
503
+ if value.created_at < cutoff
504
+ ]
505
+ for key in stale:
506
+ del self._request_states[key]
507
+
508
+ async def _register_request(self, entry: CredentialEntry) -> str:
509
+ request_id: str | None = None
510
+ context = RequestContext.get_current()
511
+ if context is not None:
512
+ request_id = getattr(context, "request_id", None)
513
+ if not request_id:
514
+ request_id = f"cred-{uuid.uuid4()}"
515
+
516
+ state = _RequestState(entry=entry)
517
+ async with self._state_lock:
518
+ self._request_states[request_id] = state
519
+ return request_id
520
+
521
+ async def _select_entry(self, *, require_active: bool = False) -> CredentialEntry:
522
+ """Select an available credential entry based on strategy.
523
+
524
+ Args:
525
+ require_active: If True, start with the active entry (for failover)
526
+
527
+ Returns:
528
+ Selected CredentialEntry
529
+
530
+ Raises:
531
+ AuthenticationError: If no credentials available
532
+ """
533
+ if not self._entries:
534
+ raise AuthenticationError("No credentials configured")
535
+
536
+ async with self._lock:
537
+ total = len(self._entries)
538
+ if require_active and self._strategy == RotationStrategy.FAILOVER:
539
+ indices = [self._active_index] + [
540
+ (self._active_index + offset) % total for offset in range(1, total)
541
+ ]
542
+ elif self._strategy == RotationStrategy.ROUND_ROBIN:
543
+ start = self._next_index
544
+ self._next_index = (self._next_index + 1) % total
545
+ indices = [(start + offset) % total for offset in range(total)]
546
+ else:
547
+ start = self._active_index
548
+ indices = [(start + offset) % total for offset in range(total)]
549
+
550
+ now = time.monotonic()
551
+ last_error: Exception | None = None
552
+ for idx in indices:
553
+ entry = self._entries[idx]
554
+ if entry.is_disabled(now):
555
+ continue
556
+
557
+ # Check if entry is authenticated using composed manager
558
+ is_auth = await entry.is_authenticated()
559
+ if not is_auth:
560
+ entry.mark_failure()
561
+ last_error = AuthenticationError("Credential not authenticated")
562
+ continue
563
+
564
+ if self._strategy == RotationStrategy.FAILOVER:
565
+ async with self._lock:
566
+ self._active_index = idx
567
+ return entry
568
+
569
+ if last_error:
570
+ raise last_error
571
+ raise AuthenticationError("No credential is currently available")
572
+
573
+ async def _handle_entry_failure(self, entry: CredentialEntry) -> None:
574
+ if self._strategy != RotationStrategy.FAILOVER:
575
+ return
576
+ async with self._lock:
577
+ current = self._active_index
578
+ if self._entries[current] is entry:
579
+ self._active_index = (current + 1) % len(self._entries)
580
+ self._logger.info(
581
+ "credential_balancer_failover",
582
+ previous=entry.label,
583
+ next=self._entries[self._active_index].label,
584
+ )
585
+
586
+
587
+ __all__ = ["CredentialBalancerTokenManager", "CredentialEntry"]