ccproxy-api 0.1.7__py3-none-any.whl → 0.2.0a4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (481) hide show
  1. ccproxy/api/__init__.py +1 -15
  2. ccproxy/api/app.py +434 -219
  3. ccproxy/api/bootstrap.py +30 -0
  4. ccproxy/api/decorators.py +85 -0
  5. ccproxy/api/dependencies.py +144 -168
  6. ccproxy/api/format_validation.py +54 -0
  7. ccproxy/api/middleware/cors.py +6 -3
  8. ccproxy/api/middleware/errors.py +388 -524
  9. ccproxy/api/middleware/hooks.py +563 -0
  10. ccproxy/api/middleware/normalize_headers.py +59 -0
  11. ccproxy/api/middleware/request_id.py +35 -16
  12. ccproxy/api/middleware/streaming_hooks.py +292 -0
  13. ccproxy/api/routes/__init__.py +5 -14
  14. ccproxy/api/routes/health.py +39 -672
  15. ccproxy/api/routes/plugins.py +277 -0
  16. ccproxy/auth/__init__.py +2 -19
  17. ccproxy/auth/bearer.py +25 -15
  18. ccproxy/auth/dependencies.py +123 -157
  19. ccproxy/auth/exceptions.py +0 -12
  20. ccproxy/auth/manager.py +35 -49
  21. ccproxy/auth/managers/__init__.py +10 -0
  22. ccproxy/auth/managers/base.py +523 -0
  23. ccproxy/auth/managers/base_enhanced.py +63 -0
  24. ccproxy/auth/managers/token_snapshot.py +77 -0
  25. ccproxy/auth/models/base.py +65 -0
  26. ccproxy/auth/models/credentials.py +40 -0
  27. ccproxy/auth/oauth/__init__.py +4 -18
  28. ccproxy/auth/oauth/base.py +533 -0
  29. ccproxy/auth/oauth/cli_errors.py +37 -0
  30. ccproxy/auth/oauth/flows.py +430 -0
  31. ccproxy/auth/oauth/protocol.py +366 -0
  32. ccproxy/auth/oauth/registry.py +408 -0
  33. ccproxy/auth/oauth/router.py +396 -0
  34. ccproxy/auth/oauth/routes.py +186 -113
  35. ccproxy/auth/oauth/session.py +151 -0
  36. ccproxy/auth/oauth/templates.py +342 -0
  37. ccproxy/auth/storage/__init__.py +2 -5
  38. ccproxy/auth/storage/base.py +279 -5
  39. ccproxy/auth/storage/generic.py +134 -0
  40. ccproxy/cli/__init__.py +1 -2
  41. ccproxy/cli/_settings_help.py +351 -0
  42. ccproxy/cli/commands/auth.py +1519 -793
  43. ccproxy/cli/commands/config/commands.py +209 -276
  44. ccproxy/cli/commands/plugins.py +669 -0
  45. ccproxy/cli/commands/serve.py +75 -810
  46. ccproxy/cli/commands/status.py +254 -0
  47. ccproxy/cli/decorators.py +83 -0
  48. ccproxy/cli/helpers.py +22 -60
  49. ccproxy/cli/main.py +359 -10
  50. ccproxy/cli/options/claude_options.py +0 -25
  51. ccproxy/config/__init__.py +7 -11
  52. ccproxy/config/core.py +227 -0
  53. ccproxy/config/env_generator.py +232 -0
  54. ccproxy/config/runtime.py +67 -0
  55. ccproxy/config/security.py +36 -3
  56. ccproxy/config/settings.py +382 -441
  57. ccproxy/config/toml_generator.py +299 -0
  58. ccproxy/config/utils.py +452 -0
  59. ccproxy/core/__init__.py +7 -271
  60. ccproxy/{_version.py → core/_version.py} +16 -3
  61. ccproxy/core/async_task_manager.py +516 -0
  62. ccproxy/core/async_utils.py +47 -14
  63. ccproxy/core/auth/__init__.py +6 -0
  64. ccproxy/core/constants.py +16 -50
  65. ccproxy/core/errors.py +53 -0
  66. ccproxy/core/id_utils.py +20 -0
  67. ccproxy/core/interfaces.py +16 -123
  68. ccproxy/core/logging.py +473 -18
  69. ccproxy/core/plugins/__init__.py +77 -0
  70. ccproxy/core/plugins/cli_discovery.py +211 -0
  71. ccproxy/core/plugins/declaration.py +455 -0
  72. ccproxy/core/plugins/discovery.py +604 -0
  73. ccproxy/core/plugins/factories.py +967 -0
  74. ccproxy/core/plugins/hooks/__init__.py +30 -0
  75. ccproxy/core/plugins/hooks/base.py +58 -0
  76. ccproxy/core/plugins/hooks/events.py +46 -0
  77. ccproxy/core/plugins/hooks/implementations/__init__.py +16 -0
  78. ccproxy/core/plugins/hooks/implementations/formatters/__init__.py +11 -0
  79. ccproxy/core/plugins/hooks/implementations/formatters/json.py +552 -0
  80. ccproxy/core/plugins/hooks/implementations/formatters/raw.py +370 -0
  81. ccproxy/core/plugins/hooks/implementations/http_tracer.py +431 -0
  82. ccproxy/core/plugins/hooks/layers.py +44 -0
  83. ccproxy/core/plugins/hooks/manager.py +186 -0
  84. ccproxy/core/plugins/hooks/registry.py +139 -0
  85. ccproxy/core/plugins/hooks/thread_manager.py +203 -0
  86. ccproxy/core/plugins/hooks/types.py +22 -0
  87. ccproxy/core/plugins/interfaces.py +416 -0
  88. ccproxy/core/plugins/loader.py +166 -0
  89. ccproxy/core/plugins/middleware.py +233 -0
  90. ccproxy/core/plugins/models.py +59 -0
  91. ccproxy/core/plugins/protocol.py +180 -0
  92. ccproxy/core/plugins/runtime.py +519 -0
  93. ccproxy/{observability/context.py → core/request_context.py} +137 -94
  94. ccproxy/core/status_report.py +211 -0
  95. ccproxy/core/transformers.py +13 -8
  96. ccproxy/data/claude_headers_fallback.json +540 -19
  97. ccproxy/data/codex_headers_fallback.json +114 -7
  98. ccproxy/http/__init__.py +30 -0
  99. ccproxy/http/base.py +95 -0
  100. ccproxy/http/client.py +323 -0
  101. ccproxy/http/hooks.py +642 -0
  102. ccproxy/http/pool.py +279 -0
  103. ccproxy/llms/formatters/__init__.py +7 -0
  104. ccproxy/llms/formatters/anthropic_to_openai/__init__.py +55 -0
  105. ccproxy/llms/formatters/anthropic_to_openai/errors.py +65 -0
  106. ccproxy/llms/formatters/anthropic_to_openai/requests.py +356 -0
  107. ccproxy/llms/formatters/anthropic_to_openai/responses.py +153 -0
  108. ccproxy/llms/formatters/anthropic_to_openai/streams.py +1546 -0
  109. ccproxy/llms/formatters/base.py +140 -0
  110. ccproxy/llms/formatters/base_model.py +33 -0
  111. ccproxy/llms/formatters/common/__init__.py +51 -0
  112. ccproxy/llms/formatters/common/identifiers.py +48 -0
  113. ccproxy/llms/formatters/common/streams.py +254 -0
  114. ccproxy/llms/formatters/common/thinking.py +74 -0
  115. ccproxy/llms/formatters/common/usage.py +135 -0
  116. ccproxy/llms/formatters/constants.py +55 -0
  117. ccproxy/llms/formatters/context.py +116 -0
  118. ccproxy/llms/formatters/mapping.py +33 -0
  119. ccproxy/llms/formatters/openai_to_anthropic/__init__.py +55 -0
  120. ccproxy/llms/formatters/openai_to_anthropic/_helpers.py +141 -0
  121. ccproxy/llms/formatters/openai_to_anthropic/errors.py +53 -0
  122. ccproxy/llms/formatters/openai_to_anthropic/requests.py +674 -0
  123. ccproxy/llms/formatters/openai_to_anthropic/responses.py +285 -0
  124. ccproxy/llms/formatters/openai_to_anthropic/streams.py +530 -0
  125. ccproxy/llms/formatters/openai_to_openai/__init__.py +53 -0
  126. ccproxy/llms/formatters/openai_to_openai/_helpers.py +325 -0
  127. ccproxy/llms/formatters/openai_to_openai/errors.py +6 -0
  128. ccproxy/llms/formatters/openai_to_openai/requests.py +388 -0
  129. ccproxy/llms/formatters/openai_to_openai/responses.py +594 -0
  130. ccproxy/llms/formatters/openai_to_openai/streams.py +1832 -0
  131. ccproxy/llms/formatters/utils.py +306 -0
  132. ccproxy/llms/models/__init__.py +9 -0
  133. ccproxy/llms/models/anthropic.py +619 -0
  134. ccproxy/llms/models/openai.py +844 -0
  135. ccproxy/llms/streaming/__init__.py +26 -0
  136. ccproxy/llms/streaming/accumulators.py +1074 -0
  137. ccproxy/llms/streaming/formatters.py +251 -0
  138. ccproxy/{adapters/openai/streaming.py → llms/streaming/processors.py} +193 -240
  139. ccproxy/models/__init__.py +8 -159
  140. ccproxy/models/detection.py +92 -193
  141. ccproxy/models/provider.py +75 -0
  142. ccproxy/plugins/access_log/README.md +32 -0
  143. ccproxy/plugins/access_log/__init__.py +20 -0
  144. ccproxy/plugins/access_log/config.py +33 -0
  145. ccproxy/plugins/access_log/formatter.py +126 -0
  146. ccproxy/plugins/access_log/hook.py +763 -0
  147. ccproxy/plugins/access_log/logger.py +254 -0
  148. ccproxy/plugins/access_log/plugin.py +137 -0
  149. ccproxy/plugins/access_log/writer.py +109 -0
  150. ccproxy/plugins/analytics/README.md +24 -0
  151. ccproxy/plugins/analytics/__init__.py +1 -0
  152. ccproxy/plugins/analytics/config.py +5 -0
  153. ccproxy/plugins/analytics/ingest.py +85 -0
  154. ccproxy/plugins/analytics/models.py +97 -0
  155. ccproxy/plugins/analytics/plugin.py +121 -0
  156. ccproxy/plugins/analytics/routes.py +163 -0
  157. ccproxy/plugins/analytics/service.py +284 -0
  158. ccproxy/plugins/claude_api/README.md +29 -0
  159. ccproxy/plugins/claude_api/__init__.py +10 -0
  160. ccproxy/plugins/claude_api/adapter.py +829 -0
  161. ccproxy/plugins/claude_api/config.py +52 -0
  162. ccproxy/plugins/claude_api/detection_service.py +461 -0
  163. ccproxy/plugins/claude_api/health.py +175 -0
  164. ccproxy/plugins/claude_api/hooks.py +284 -0
  165. ccproxy/plugins/claude_api/models.py +256 -0
  166. ccproxy/plugins/claude_api/plugin.py +298 -0
  167. ccproxy/plugins/claude_api/routes.py +118 -0
  168. ccproxy/plugins/claude_api/streaming_metrics.py +68 -0
  169. ccproxy/plugins/claude_api/tasks.py +84 -0
  170. ccproxy/plugins/claude_sdk/README.md +35 -0
  171. ccproxy/plugins/claude_sdk/__init__.py +80 -0
  172. ccproxy/plugins/claude_sdk/adapter.py +749 -0
  173. ccproxy/plugins/claude_sdk/auth.py +57 -0
  174. ccproxy/{claude_sdk → plugins/claude_sdk}/client.py +63 -39
  175. ccproxy/plugins/claude_sdk/config.py +210 -0
  176. ccproxy/{claude_sdk → plugins/claude_sdk}/converter.py +6 -6
  177. ccproxy/plugins/claude_sdk/detection_service.py +163 -0
  178. ccproxy/{services/claude_sdk_service.py → plugins/claude_sdk/handler.py} +123 -304
  179. ccproxy/plugins/claude_sdk/health.py +113 -0
  180. ccproxy/plugins/claude_sdk/hooks.py +115 -0
  181. ccproxy/{claude_sdk → plugins/claude_sdk}/manager.py +42 -32
  182. ccproxy/{claude_sdk → plugins/claude_sdk}/message_queue.py +8 -8
  183. ccproxy/{models/claude_sdk.py → plugins/claude_sdk/models.py} +64 -16
  184. ccproxy/plugins/claude_sdk/options.py +154 -0
  185. ccproxy/{claude_sdk → plugins/claude_sdk}/parser.py +23 -5
  186. ccproxy/plugins/claude_sdk/plugin.py +269 -0
  187. ccproxy/plugins/claude_sdk/routes.py +104 -0
  188. ccproxy/{claude_sdk → plugins/claude_sdk}/session_client.py +124 -12
  189. ccproxy/plugins/claude_sdk/session_pool.py +700 -0
  190. ccproxy/{claude_sdk → plugins/claude_sdk}/stream_handle.py +48 -43
  191. ccproxy/{claude_sdk → plugins/claude_sdk}/stream_worker.py +22 -18
  192. ccproxy/{claude_sdk → plugins/claude_sdk}/streaming.py +50 -16
  193. ccproxy/plugins/claude_sdk/tasks.py +97 -0
  194. ccproxy/plugins/claude_shared/README.md +18 -0
  195. ccproxy/plugins/claude_shared/__init__.py +12 -0
  196. ccproxy/plugins/claude_shared/model_defaults.py +171 -0
  197. ccproxy/plugins/codex/README.md +35 -0
  198. ccproxy/plugins/codex/__init__.py +6 -0
  199. ccproxy/plugins/codex/adapter.py +635 -0
  200. ccproxy/{config/codex.py → plugins/codex/config.py} +78 -12
  201. ccproxy/plugins/codex/detection_service.py +544 -0
  202. ccproxy/plugins/codex/health.py +162 -0
  203. ccproxy/plugins/codex/hooks.py +263 -0
  204. ccproxy/plugins/codex/model_defaults.py +39 -0
  205. ccproxy/plugins/codex/models.py +263 -0
  206. ccproxy/plugins/codex/plugin.py +275 -0
  207. ccproxy/plugins/codex/routes.py +129 -0
  208. ccproxy/plugins/codex/streaming_metrics.py +324 -0
  209. ccproxy/plugins/codex/tasks.py +106 -0
  210. ccproxy/plugins/codex/utils/__init__.py +1 -0
  211. ccproxy/plugins/codex/utils/sse_parser.py +106 -0
  212. ccproxy/plugins/command_replay/README.md +34 -0
  213. ccproxy/plugins/command_replay/__init__.py +17 -0
  214. ccproxy/plugins/command_replay/config.py +133 -0
  215. ccproxy/plugins/command_replay/formatter.py +432 -0
  216. ccproxy/plugins/command_replay/hook.py +294 -0
  217. ccproxy/plugins/command_replay/plugin.py +161 -0
  218. ccproxy/plugins/copilot/README.md +39 -0
  219. ccproxy/plugins/copilot/__init__.py +11 -0
  220. ccproxy/plugins/copilot/adapter.py +465 -0
  221. ccproxy/plugins/copilot/config.py +155 -0
  222. ccproxy/plugins/copilot/data/copilot_fallback.json +41 -0
  223. ccproxy/plugins/copilot/detection_service.py +255 -0
  224. ccproxy/plugins/copilot/manager.py +275 -0
  225. ccproxy/plugins/copilot/model_defaults.py +284 -0
  226. ccproxy/plugins/copilot/models.py +148 -0
  227. ccproxy/plugins/copilot/oauth/__init__.py +16 -0
  228. ccproxy/plugins/copilot/oauth/client.py +494 -0
  229. ccproxy/plugins/copilot/oauth/models.py +385 -0
  230. ccproxy/plugins/copilot/oauth/provider.py +602 -0
  231. ccproxy/plugins/copilot/oauth/storage.py +170 -0
  232. ccproxy/plugins/copilot/plugin.py +360 -0
  233. ccproxy/plugins/copilot/routes.py +294 -0
  234. ccproxy/plugins/credential_balancer/README.md +124 -0
  235. ccproxy/plugins/credential_balancer/__init__.py +6 -0
  236. ccproxy/plugins/credential_balancer/config.py +270 -0
  237. ccproxy/plugins/credential_balancer/factory.py +415 -0
  238. ccproxy/plugins/credential_balancer/hook.py +51 -0
  239. ccproxy/plugins/credential_balancer/manager.py +587 -0
  240. ccproxy/plugins/credential_balancer/plugin.py +146 -0
  241. ccproxy/plugins/dashboard/README.md +25 -0
  242. ccproxy/plugins/dashboard/__init__.py +1 -0
  243. ccproxy/plugins/dashboard/config.py +8 -0
  244. ccproxy/plugins/dashboard/plugin.py +71 -0
  245. ccproxy/plugins/dashboard/routes.py +67 -0
  246. ccproxy/plugins/docker/README.md +32 -0
  247. ccproxy/{docker → plugins/docker}/__init__.py +3 -0
  248. ccproxy/{docker → plugins/docker}/adapter.py +108 -10
  249. ccproxy/plugins/docker/config.py +82 -0
  250. ccproxy/{docker → plugins/docker}/docker_path.py +4 -3
  251. ccproxy/{docker → plugins/docker}/middleware.py +2 -2
  252. ccproxy/plugins/docker/plugin.py +198 -0
  253. ccproxy/{docker → plugins/docker}/stream_process.py +3 -3
  254. ccproxy/plugins/duckdb_storage/README.md +26 -0
  255. ccproxy/plugins/duckdb_storage/__init__.py +1 -0
  256. ccproxy/plugins/duckdb_storage/config.py +22 -0
  257. ccproxy/plugins/duckdb_storage/plugin.py +128 -0
  258. ccproxy/plugins/duckdb_storage/routes.py +51 -0
  259. ccproxy/plugins/duckdb_storage/storage.py +633 -0
  260. ccproxy/plugins/max_tokens/README.md +38 -0
  261. ccproxy/plugins/max_tokens/__init__.py +12 -0
  262. ccproxy/plugins/max_tokens/adapter.py +235 -0
  263. ccproxy/plugins/max_tokens/config.py +86 -0
  264. ccproxy/plugins/max_tokens/models.py +53 -0
  265. ccproxy/plugins/max_tokens/plugin.py +200 -0
  266. ccproxy/plugins/max_tokens/service.py +271 -0
  267. ccproxy/plugins/max_tokens/token_limits.json +54 -0
  268. ccproxy/plugins/metrics/README.md +35 -0
  269. ccproxy/plugins/metrics/__init__.py +10 -0
  270. ccproxy/{observability/metrics.py → plugins/metrics/collector.py} +20 -153
  271. ccproxy/plugins/metrics/config.py +85 -0
  272. ccproxy/plugins/metrics/grafana/dashboards/ccproxy-dashboard.json +1720 -0
  273. ccproxy/plugins/metrics/hook.py +403 -0
  274. ccproxy/plugins/metrics/plugin.py +268 -0
  275. ccproxy/{observability → plugins/metrics}/pushgateway.py +57 -59
  276. ccproxy/plugins/metrics/routes.py +107 -0
  277. ccproxy/plugins/metrics/tasks.py +117 -0
  278. ccproxy/plugins/oauth_claude/README.md +35 -0
  279. ccproxy/plugins/oauth_claude/__init__.py +14 -0
  280. ccproxy/plugins/oauth_claude/client.py +270 -0
  281. ccproxy/plugins/oauth_claude/config.py +84 -0
  282. ccproxy/plugins/oauth_claude/manager.py +482 -0
  283. ccproxy/plugins/oauth_claude/models.py +266 -0
  284. ccproxy/plugins/oauth_claude/plugin.py +149 -0
  285. ccproxy/plugins/oauth_claude/provider.py +571 -0
  286. ccproxy/plugins/oauth_claude/storage.py +212 -0
  287. ccproxy/plugins/oauth_codex/README.md +38 -0
  288. ccproxy/plugins/oauth_codex/__init__.py +14 -0
  289. ccproxy/plugins/oauth_codex/client.py +224 -0
  290. ccproxy/plugins/oauth_codex/config.py +95 -0
  291. ccproxy/plugins/oauth_codex/manager.py +256 -0
  292. ccproxy/plugins/oauth_codex/models.py +239 -0
  293. ccproxy/plugins/oauth_codex/plugin.py +146 -0
  294. ccproxy/plugins/oauth_codex/provider.py +574 -0
  295. ccproxy/plugins/oauth_codex/storage.py +92 -0
  296. ccproxy/plugins/permissions/README.md +28 -0
  297. ccproxy/plugins/permissions/__init__.py +22 -0
  298. ccproxy/plugins/permissions/config.py +28 -0
  299. ccproxy/{cli/commands/permission_handler.py → plugins/permissions/handlers/cli.py} +49 -25
  300. ccproxy/plugins/permissions/handlers/protocol.py +33 -0
  301. ccproxy/plugins/permissions/handlers/terminal.py +675 -0
  302. ccproxy/{api/routes → plugins/permissions}/mcp.py +34 -7
  303. ccproxy/{models/permissions.py → plugins/permissions/models.py} +65 -1
  304. ccproxy/plugins/permissions/plugin.py +153 -0
  305. ccproxy/{api/routes/permissions.py → plugins/permissions/routes.py} +20 -16
  306. ccproxy/{api/services/permission_service.py → plugins/permissions/service.py} +65 -11
  307. ccproxy/{api → plugins/permissions}/ui/permission_handler_protocol.py +1 -1
  308. ccproxy/{api → plugins/permissions}/ui/terminal_permission_handler.py +66 -10
  309. ccproxy/plugins/pricing/README.md +34 -0
  310. ccproxy/plugins/pricing/__init__.py +6 -0
  311. ccproxy/{pricing → plugins/pricing}/cache.py +7 -6
  312. ccproxy/{config/pricing.py → plugins/pricing/config.py} +32 -6
  313. ccproxy/plugins/pricing/exceptions.py +35 -0
  314. ccproxy/plugins/pricing/loader.py +440 -0
  315. ccproxy/{pricing → plugins/pricing}/models.py +13 -23
  316. ccproxy/plugins/pricing/plugin.py +169 -0
  317. ccproxy/plugins/pricing/service.py +191 -0
  318. ccproxy/plugins/pricing/tasks.py +300 -0
  319. ccproxy/{pricing → plugins/pricing}/updater.py +86 -72
  320. ccproxy/plugins/pricing/utils.py +99 -0
  321. ccproxy/plugins/request_tracer/README.md +40 -0
  322. ccproxy/plugins/request_tracer/__init__.py +7 -0
  323. ccproxy/plugins/request_tracer/config.py +120 -0
  324. ccproxy/plugins/request_tracer/hook.py +415 -0
  325. ccproxy/plugins/request_tracer/plugin.py +255 -0
  326. ccproxy/scheduler/__init__.py +2 -14
  327. ccproxy/scheduler/core.py +26 -41
  328. ccproxy/scheduler/manager.py +61 -105
  329. ccproxy/scheduler/registry.py +6 -32
  330. ccproxy/scheduler/tasks.py +268 -276
  331. ccproxy/services/__init__.py +0 -1
  332. ccproxy/services/adapters/__init__.py +11 -0
  333. ccproxy/services/adapters/base.py +123 -0
  334. ccproxy/services/adapters/chain_composer.py +88 -0
  335. ccproxy/services/adapters/chain_validation.py +44 -0
  336. ccproxy/services/adapters/chat_accumulator.py +200 -0
  337. ccproxy/services/adapters/delta_utils.py +142 -0
  338. ccproxy/services/adapters/format_adapter.py +136 -0
  339. ccproxy/services/adapters/format_context.py +11 -0
  340. ccproxy/services/adapters/format_registry.py +158 -0
  341. ccproxy/services/adapters/http_adapter.py +1045 -0
  342. ccproxy/services/adapters/mock_adapter.py +118 -0
  343. ccproxy/services/adapters/protocols.py +35 -0
  344. ccproxy/services/adapters/simple_converters.py +571 -0
  345. ccproxy/services/auth_registry.py +180 -0
  346. ccproxy/services/cache/__init__.py +6 -0
  347. ccproxy/services/cache/response_cache.py +261 -0
  348. ccproxy/services/cli_detection.py +437 -0
  349. ccproxy/services/config/__init__.py +6 -0
  350. ccproxy/services/config/proxy_configuration.py +111 -0
  351. ccproxy/services/container.py +256 -0
  352. ccproxy/services/factories.py +380 -0
  353. ccproxy/services/handler_config.py +76 -0
  354. ccproxy/services/interfaces.py +298 -0
  355. ccproxy/services/mocking/__init__.py +6 -0
  356. ccproxy/services/mocking/mock_handler.py +291 -0
  357. ccproxy/services/tracing/__init__.py +7 -0
  358. ccproxy/services/tracing/interfaces.py +61 -0
  359. ccproxy/services/tracing/null_tracer.py +57 -0
  360. ccproxy/streaming/__init__.py +23 -0
  361. ccproxy/streaming/buffer.py +1056 -0
  362. ccproxy/streaming/deferred.py +897 -0
  363. ccproxy/streaming/handler.py +117 -0
  364. ccproxy/streaming/interfaces.py +77 -0
  365. ccproxy/streaming/simple_adapter.py +39 -0
  366. ccproxy/streaming/sse.py +109 -0
  367. ccproxy/streaming/sse_parser.py +127 -0
  368. ccproxy/templates/__init__.py +6 -0
  369. ccproxy/templates/plugin_scaffold.py +695 -0
  370. ccproxy/testing/endpoints/__init__.py +33 -0
  371. ccproxy/testing/endpoints/cli.py +215 -0
  372. ccproxy/testing/endpoints/config.py +874 -0
  373. ccproxy/testing/endpoints/console.py +57 -0
  374. ccproxy/testing/endpoints/models.py +100 -0
  375. ccproxy/testing/endpoints/runner.py +1903 -0
  376. ccproxy/testing/endpoints/tools.py +308 -0
  377. ccproxy/testing/mock_responses.py +70 -1
  378. ccproxy/testing/response_handlers.py +20 -0
  379. ccproxy/utils/__init__.py +0 -6
  380. ccproxy/utils/binary_resolver.py +476 -0
  381. ccproxy/utils/caching.py +327 -0
  382. ccproxy/utils/cli_logging.py +101 -0
  383. ccproxy/utils/command_line.py +251 -0
  384. ccproxy/utils/headers.py +228 -0
  385. ccproxy/utils/model_mapper.py +120 -0
  386. ccproxy/utils/startup_helpers.py +68 -446
  387. ccproxy/utils/version_checker.py +273 -6
  388. ccproxy_api-0.2.0a4.dist-info/METADATA +212 -0
  389. ccproxy_api-0.2.0a4.dist-info/RECORD +417 -0
  390. {ccproxy_api-0.1.7.dist-info → ccproxy_api-0.2.0a4.dist-info}/WHEEL +1 -1
  391. ccproxy_api-0.2.0a4.dist-info/entry_points.txt +24 -0
  392. ccproxy/__init__.py +0 -4
  393. ccproxy/adapters/__init__.py +0 -11
  394. ccproxy/adapters/base.py +0 -80
  395. ccproxy/adapters/codex/__init__.py +0 -11
  396. ccproxy/adapters/openai/__init__.py +0 -42
  397. ccproxy/adapters/openai/adapter.py +0 -953
  398. ccproxy/adapters/openai/models.py +0 -412
  399. ccproxy/adapters/openai/response_adapter.py +0 -355
  400. ccproxy/adapters/openai/response_models.py +0 -178
  401. ccproxy/api/middleware/headers.py +0 -49
  402. ccproxy/api/middleware/logging.py +0 -180
  403. ccproxy/api/middleware/request_content_logging.py +0 -297
  404. ccproxy/api/middleware/server_header.py +0 -58
  405. ccproxy/api/responses.py +0 -89
  406. ccproxy/api/routes/claude.py +0 -371
  407. ccproxy/api/routes/codex.py +0 -1251
  408. ccproxy/api/routes/metrics.py +0 -1029
  409. ccproxy/api/routes/proxy.py +0 -211
  410. ccproxy/api/services/__init__.py +0 -6
  411. ccproxy/auth/conditional.py +0 -84
  412. ccproxy/auth/credentials_adapter.py +0 -93
  413. ccproxy/auth/models.py +0 -118
  414. ccproxy/auth/oauth/models.py +0 -48
  415. ccproxy/auth/openai/__init__.py +0 -13
  416. ccproxy/auth/openai/credentials.py +0 -166
  417. ccproxy/auth/openai/oauth_client.py +0 -334
  418. ccproxy/auth/openai/storage.py +0 -184
  419. ccproxy/auth/storage/json_file.py +0 -158
  420. ccproxy/auth/storage/keyring.py +0 -189
  421. ccproxy/claude_sdk/__init__.py +0 -18
  422. ccproxy/claude_sdk/options.py +0 -194
  423. ccproxy/claude_sdk/session_pool.py +0 -550
  424. ccproxy/cli/docker/__init__.py +0 -34
  425. ccproxy/cli/docker/adapter_factory.py +0 -157
  426. ccproxy/cli/docker/params.py +0 -274
  427. ccproxy/config/auth.py +0 -153
  428. ccproxy/config/claude.py +0 -348
  429. ccproxy/config/cors.py +0 -79
  430. ccproxy/config/discovery.py +0 -95
  431. ccproxy/config/docker_settings.py +0 -264
  432. ccproxy/config/observability.py +0 -158
  433. ccproxy/config/reverse_proxy.py +0 -31
  434. ccproxy/config/scheduler.py +0 -108
  435. ccproxy/config/server.py +0 -86
  436. ccproxy/config/validators.py +0 -231
  437. ccproxy/core/codex_transformers.py +0 -389
  438. ccproxy/core/http.py +0 -328
  439. ccproxy/core/http_transformers.py +0 -812
  440. ccproxy/core/proxy.py +0 -143
  441. ccproxy/core/validators.py +0 -288
  442. ccproxy/models/errors.py +0 -42
  443. ccproxy/models/messages.py +0 -269
  444. ccproxy/models/requests.py +0 -107
  445. ccproxy/models/responses.py +0 -270
  446. ccproxy/models/types.py +0 -102
  447. ccproxy/observability/__init__.py +0 -51
  448. ccproxy/observability/access_logger.py +0 -457
  449. ccproxy/observability/sse_events.py +0 -303
  450. ccproxy/observability/stats_printer.py +0 -753
  451. ccproxy/observability/storage/__init__.py +0 -1
  452. ccproxy/observability/storage/duckdb_simple.py +0 -677
  453. ccproxy/observability/storage/models.py +0 -70
  454. ccproxy/observability/streaming_response.py +0 -107
  455. ccproxy/pricing/__init__.py +0 -19
  456. ccproxy/pricing/loader.py +0 -251
  457. ccproxy/services/claude_detection_service.py +0 -243
  458. ccproxy/services/codex_detection_service.py +0 -252
  459. ccproxy/services/credentials/__init__.py +0 -55
  460. ccproxy/services/credentials/config.py +0 -105
  461. ccproxy/services/credentials/manager.py +0 -561
  462. ccproxy/services/credentials/oauth_client.py +0 -481
  463. ccproxy/services/proxy_service.py +0 -1827
  464. ccproxy/static/.keep +0 -0
  465. ccproxy/utils/cost_calculator.py +0 -210
  466. ccproxy/utils/disconnection_monitor.py +0 -83
  467. ccproxy/utils/model_mapping.py +0 -199
  468. ccproxy/utils/models_provider.py +0 -150
  469. ccproxy/utils/simple_request_logger.py +0 -284
  470. ccproxy/utils/streaming_metrics.py +0 -199
  471. ccproxy_api-0.1.7.dist-info/METADATA +0 -615
  472. ccproxy_api-0.1.7.dist-info/RECORD +0 -191
  473. ccproxy_api-0.1.7.dist-info/entry_points.txt +0 -4
  474. /ccproxy/{api/middleware/auth.py → auth/models/__init__.py} +0 -0
  475. /ccproxy/{claude_sdk → plugins/claude_sdk}/exceptions.py +0 -0
  476. /ccproxy/{docker → plugins/docker}/models.py +0 -0
  477. /ccproxy/{docker → plugins/docker}/protocol.py +0 -0
  478. /ccproxy/{docker → plugins/docker}/validators.py +0 -0
  479. /ccproxy/{auth/oauth/storage.py → plugins/permissions/handlers/__init__.py} +0 -0
  480. /ccproxy/{api → plugins/permissions}/ui/__init__.py +0 -0
  481. {ccproxy_api-0.1.7.dist-info → ccproxy_api-0.2.0a4.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,633 @@
1
+ """Simplified DuckDB storage for low-traffic environments.
2
+
3
+ This module provides a simple, direct DuckDB storage implementation without
4
+ connection pooling or batch processing. Suitable for dev environments with
5
+ low request rates (< 10 req/s).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import contextlib
12
+ import time
13
+ from collections.abc import Mapping, Sequence
14
+ from datetime import datetime
15
+ from pathlib import Path
16
+ from typing import Any, cast
17
+
18
+ from sqlalchemy import delete, insert
19
+ from sqlalchemy import select as sa_select
20
+ from sqlalchemy.engine import Engine
21
+ from sqlalchemy.exc import IntegrityError, OperationalError, SQLAlchemyError
22
+ from sqlmodel import Session, SQLModel, create_engine, func
23
+
24
+ from ccproxy.core.async_task_manager import create_managed_task
25
+ from ccproxy.core.logging import get_plugin_logger
26
+
27
+
28
+ logger = get_plugin_logger(__name__)
29
+
30
+
31
+ class SimpleDuckDBStorage:
32
+ """Simple DuckDB storage with queue-based writes to prevent deadlocks."""
33
+
34
+ def __init__(self, database_path: str | Path = "data/metrics.duckdb"):
35
+ """Initialize simple DuckDB storage.
36
+
37
+ Args:
38
+ database_path: Path to DuckDB database file
39
+ """
40
+ self.database_path = Path(database_path)
41
+ self._engine: Engine | None = None
42
+ self._initialized: bool = False
43
+ self._write_queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
44
+ self._background_worker_task: asyncio.Task[None] | None = None
45
+ self._shutdown_event = asyncio.Event()
46
+ # Sentinel to wake the background worker immediately on shutdown
47
+ self._sentinel: object = object()
48
+
49
+ async def initialize(self) -> None:
50
+ """Initialize the storage backend."""
51
+ if self._initialized:
52
+ return
53
+
54
+ try:
55
+ # Ensure data directory exists
56
+ self.database_path.parent.mkdir(parents=True, exist_ok=True)
57
+
58
+ # Create SQLModel engine
59
+ self._engine = create_engine(f"duckdb:///{self.database_path}")
60
+
61
+ # Create schema using SQLModel (synchronous in main thread)
62
+ self._create_schema_sync()
63
+
64
+ # Start background worker for queue processing
65
+ self._background_worker_task = await create_managed_task(
66
+ self._background_worker(),
67
+ name="duckdb_background_worker",
68
+ creator="SimpleDuckDBStorage",
69
+ )
70
+
71
+ self._initialized = True
72
+ logger.debug(
73
+ "simple_duckdb_initialized", database_path=str(self.database_path)
74
+ )
75
+
76
+ except OSError as e:
77
+ logger.error("simple_duckdb_init_io_error", error=str(e), exc_info=e)
78
+ raise
79
+ except SQLAlchemyError as e:
80
+ logger.error("simple_duckdb_init_db_error", error=str(e), exc_info=e)
81
+ raise
82
+ except Exception as e:
83
+ logger.error("simple_duckdb_init_error", error=str(e), exc_info=e)
84
+ raise
85
+
86
+ def optimize(self) -> None:
87
+ """Run PRAGMA optimize on the database engine if available.
88
+
89
+ This is a lightweight maintenance step to improve performance and
90
+ reclaim space in DuckDB. Safe to call on file-backed databases.
91
+ """
92
+ if not self._engine:
93
+ return
94
+ try:
95
+ with self._engine.connect() as conn:
96
+ conn.exec_driver_sql("PRAGMA optimize")
97
+ logger.debug("duckdb_optimize_completed")
98
+ except Exception as e: # pragma: no cover - non-critical maintenance
99
+ logger.warning("duckdb_optimize_failed", error=str(e), exc_info=e)
100
+
101
+ def _create_schema_sync(self) -> None:
102
+ """Create database schema using SQLModel (synchronous)."""
103
+ if not self._engine:
104
+ return
105
+
106
+ try:
107
+ # Create tables using SQLModel metadata.
108
+ # Note: AccessLog model must be imported by the access_log plugin prior to this call.
109
+ SQLModel.metadata.create_all(self._engine)
110
+ logger.debug("duckdb_schema_created")
111
+
112
+ except SQLAlchemyError as e:
113
+ logger.error("simple_duckdb_schema_db_error", error=str(e), exc_info=e)
114
+ raise
115
+ except Exception as e:
116
+ logger.error("simple_duckdb_schema_error", error=str(e), exc_info=e)
117
+ raise
118
+
119
+ async def _ensure_query_column(self) -> None:
120
+ """Ensure query column exists in the access_logs table.
121
+
122
+ Note: This method uses schema introspection to safely check for columns.
123
+ The table schema is managed by SQLModel, so this is primarily for
124
+ backwards compatibility with existing databases.
125
+ """
126
+ if not self._engine:
127
+ return
128
+
129
+ try:
130
+ # SQLModel automatically handles schema creation through metadata.create_all()
131
+ # This method is kept for backwards compatibility but no longer uses raw SQL
132
+ logger.debug("query_column_ensured_via_sqlmodel_schema")
133
+
134
+ except Exception as e:
135
+ logger.warning("query_column_check_error", error=str(e), exc_info=e)
136
+ # Continue without failing - SQLModel handles schema management
137
+
138
+ async def store_request(self, data: Mapping[str, Any]) -> bool:
139
+ """Store a single request log entry asynchronously via queue.
140
+
141
+ Args:
142
+ data: Request data to store
143
+
144
+ Returns:
145
+ True if queued successfully
146
+ """
147
+ if not self._initialized:
148
+ return False
149
+
150
+ try:
151
+ # Add to queue for background processing
152
+ await self._write_queue.put(dict(data))
153
+ return True
154
+ except asyncio.QueueFull as e:
155
+ logger.error(
156
+ "queue_store_full_error",
157
+ error=str(e),
158
+ request_id=data.get("request_id"),
159
+ exc_info=e,
160
+ )
161
+ return False
162
+ except Exception as e:
163
+ logger.error(
164
+ "queue_store_error",
165
+ error=str(e),
166
+ request_id=data.get("request_id"),
167
+ exc_info=e,
168
+ )
169
+ return False
170
+
171
+ async def _background_worker(self) -> None:
172
+ """Background worker to process queued write operations sequentially."""
173
+ logger.debug("duckdb_background_worker_started")
174
+
175
+ while not self._shutdown_event.is_set():
176
+ try:
177
+ # Wait for either a queue item or shutdown with timeout
178
+ try:
179
+ data = await asyncio.wait_for(self._write_queue.get(), timeout=1.0)
180
+ except TimeoutError:
181
+ continue # Check shutdown event and continue
182
+
183
+ # We successfully got an item, so we need to mark it done
184
+ try:
185
+ # If we receive a sentinel item, break out quickly on shutdown
186
+ if data is self._sentinel:
187
+ self._write_queue.task_done()
188
+ break
189
+ success = self._store_request_sync(data)
190
+ if success:
191
+ logger.debug(
192
+ "queue_processed_successfully",
193
+ request_id=data.get("request_id"),
194
+ )
195
+ except SQLAlchemyError as e:
196
+ logger.error(
197
+ "background_worker_db_error",
198
+ error=str(e),
199
+ request_id=data.get("request_id"),
200
+ exc_info=e,
201
+ )
202
+ except Exception as e:
203
+ logger.error(
204
+ "background_worker_error",
205
+ error=str(e),
206
+ request_id=data.get("request_id"),
207
+ exc_info=e,
208
+ )
209
+
210
+ # Always mark the task as done for regular items, regardless of success/failure
211
+ if data is not self._sentinel:
212
+ self._write_queue.task_done()
213
+
214
+ except asyncio.CancelledError as e:
215
+ logger.info("background_worker_cancelled", exc_info=e)
216
+ break
217
+ except Exception as e:
218
+ logger.error(
219
+ "background_worker_unexpected_error",
220
+ error=str(e),
221
+ exc_info=e,
222
+ )
223
+ # Continue processing other items
224
+
225
+ # Process any remaining items in the queue during shutdown
226
+ logger.debug("processing_remaining_queue_items_on_shutdown")
227
+ while not self._write_queue.empty():
228
+ try:
229
+ # Get remaining items without timeout during shutdown
230
+ data = self._write_queue.get_nowait()
231
+
232
+ # Process the queued write operation synchronously
233
+ try:
234
+ success = self._store_request_sync(data)
235
+ if success:
236
+ logger.debug(
237
+ "shutdown_queue_processed_successfully",
238
+ request_id=data.get("request_id"),
239
+ )
240
+ except SQLAlchemyError as e:
241
+ logger.error(
242
+ "shutdown_background_worker_db_error",
243
+ error=str(e),
244
+ request_id=data.get("request_id"),
245
+ exc_info=e,
246
+ )
247
+ except Exception as e:
248
+ logger.error(
249
+ "shutdown_background_worker_error",
250
+ error=str(e),
251
+ request_id=data.get("request_id"),
252
+ exc_info=e,
253
+ )
254
+ # Note: No task_done() call needed for get_nowait() items
255
+
256
+ except asyncio.QueueEmpty:
257
+ # No more items to process
258
+ break
259
+ except Exception as e:
260
+ logger.error(
261
+ "shutdown_background_worker_unexpected_error",
262
+ error=str(e),
263
+ exc_info=e,
264
+ )
265
+ # Continue processing other items
266
+
267
+ logger.debug("duckdb_background_worker_stopped")
268
+
269
+ def _store_request_sync(self, data: dict[str, Any]) -> bool:
270
+ """Synchronous version of store_request for thread pool execution."""
271
+ try:
272
+ # Convert Unix timestamp to datetime if needed
273
+ timestamp_value = data.get("timestamp", time.time())
274
+ if isinstance(timestamp_value, int | float):
275
+ timestamp_dt = datetime.fromtimestamp(timestamp_value)
276
+ else:
277
+ timestamp_dt = timestamp_value
278
+
279
+ # Store using SQLAlchemy core insert via SQLModel metadata
280
+ values = {
281
+ "request_id": data.get("request_id", ""),
282
+ "timestamp": timestamp_dt,
283
+ "method": data.get("method", ""),
284
+ "endpoint": data.get("endpoint", ""),
285
+ "path": data.get("path", data.get("endpoint", "")),
286
+ "query": data.get("query", ""),
287
+ "client_ip": data.get("client_ip", ""),
288
+ "user_agent": data.get("user_agent", ""),
289
+ "service_type": data.get("service_type", ""),
290
+ "provider": data.get("provider", ""),
291
+ "model": data.get("model", ""),
292
+ "streaming": data.get("streaming", False),
293
+ "status_code": data.get("status_code", 200),
294
+ "duration_ms": data.get("duration_ms", 0.0),
295
+ "duration_seconds": data.get("duration_seconds", 0.0),
296
+ "tokens_input": data.get("tokens_input", 0),
297
+ "tokens_output": data.get("tokens_output", 0),
298
+ "cache_read_tokens": data.get("cache_read_tokens", 0),
299
+ "cache_write_tokens": data.get("cache_write_tokens", 0),
300
+ "cost_usd": data.get("cost_usd", 0.0),
301
+ "cost_sdk_usd": data.get("cost_sdk_usd", 0.0),
302
+ }
303
+
304
+ table = SQLModel.metadata.tables.get("access_logs")
305
+ if table is None:
306
+ raise RuntimeError(
307
+ "access_logs table not registered; ensure analytics plugin is enabled"
308
+ )
309
+ with Session(self._engine) as session:
310
+ try:
311
+ _ = cast(Any, session).exec(insert(table).values(values))
312
+ session.commit()
313
+ except (OperationalError, IntegrityError, SQLAlchemyError) as e:
314
+ # Fallback for older schemas without the 'provider' column
315
+ msg = str(e)
316
+ if "provider" in values and (
317
+ "provider" in msg.lower()
318
+ or "no column" in msg.lower()
319
+ or "unknown" in msg.lower()
320
+ ):
321
+ safe_values = {
322
+ k: v for k, v in values.items() if k != "provider"
323
+ }
324
+ session.rollback()
325
+ _ = cast(Any, session).exec(insert(table).values(safe_values))
326
+ session.commit()
327
+ else:
328
+ raise
329
+
330
+ logger.info(
331
+ "simple_duckdb_store_success",
332
+ request_id=data.get("request_id"),
333
+ service_type=data.get("service_type"),
334
+ model=data.get("model"),
335
+ )
336
+ return True
337
+
338
+ except IntegrityError as e:
339
+ logger.error(
340
+ "simple_duckdb_store_integrity_error",
341
+ error=str(e),
342
+ request_id=data.get("request_id"),
343
+ exc_info=e,
344
+ )
345
+ return False
346
+ except OperationalError as e:
347
+ logger.error(
348
+ "simple_duckdb_store_operational_error",
349
+ error=str(e),
350
+ request_id=data.get("request_id"),
351
+ exc_info=e,
352
+ )
353
+ return False
354
+ except SQLAlchemyError as e:
355
+ logger.error(
356
+ "simple_duckdb_store_db_error",
357
+ error=str(e),
358
+ request_id=data.get("request_id"),
359
+ exc_info=e,
360
+ )
361
+ return False
362
+ except Exception as e:
363
+ logger.error(
364
+ "simple_duckdb_store_error",
365
+ error=str(e),
366
+ request_id=data.get("request_id"),
367
+ exc_info=e,
368
+ )
369
+ return False
370
+
371
+ async def store_batch(self, metrics: Sequence[dict[str, Any]]) -> bool:
372
+ """Store a batch of request logs.
373
+
374
+ Args:
375
+ metrics: List of metric data entries
376
+
377
+ Returns:
378
+ True if stored successfully
379
+ """
380
+ if not self._initialized or not self._engine:
381
+ return False
382
+
383
+ try:
384
+ rows = []
385
+ for data in metrics:
386
+ timestamp_value = data.get("timestamp", time.time())
387
+ timestamp_dt = (
388
+ datetime.fromtimestamp(timestamp_value)
389
+ if isinstance(timestamp_value, int | float)
390
+ else timestamp_value
391
+ )
392
+ rows.append(
393
+ {
394
+ "request_id": data.get("request_id", ""),
395
+ "timestamp": timestamp_dt,
396
+ "method": data.get("method", ""),
397
+ "endpoint": data.get("endpoint", ""),
398
+ "path": data.get("path", data.get("endpoint", "")),
399
+ "query": data.get("query", ""),
400
+ "client_ip": data.get("client_ip", ""),
401
+ "user_agent": data.get("user_agent", ""),
402
+ "service_type": data.get("service_type", ""),
403
+ "provider": data.get("provider", ""),
404
+ "model": data.get("model", ""),
405
+ "streaming": data.get("streaming", False),
406
+ "status_code": data.get("status_code", 200),
407
+ "duration_ms": data.get("duration_ms", 0.0),
408
+ "duration_seconds": data.get("duration_seconds", 0.0),
409
+ "tokens_input": data.get("tokens_input", 0),
410
+ "tokens_output": data.get("tokens_output", 0),
411
+ "cache_read_tokens": data.get("cache_read_tokens", 0),
412
+ "cache_write_tokens": data.get("cache_write_tokens", 0),
413
+ "cost_usd": data.get("cost_usd", 0.0),
414
+ "cost_sdk_usd": data.get("cost_sdk_usd", 0.0),
415
+ }
416
+ )
417
+
418
+ table = SQLModel.metadata.tables.get("access_logs")
419
+ if table is None:
420
+ raise RuntimeError(
421
+ "access_logs table not registered; ensure analytics plugin is enabled"
422
+ )
423
+ with Session(self._engine) as session:
424
+ cast(Any, session).exec(insert(table), rows)
425
+ session.commit()
426
+
427
+ logger.info(
428
+ "simple_duckdb_batch_store_success",
429
+ batch_size=len(metrics),
430
+ service_types=[m.get("service_type", "") for m in metrics[:3]],
431
+ request_ids=[m.get("request_id", "") for m in metrics[:3]],
432
+ )
433
+ return True
434
+
435
+ except IntegrityError as e:
436
+ logger.error(
437
+ "simple_duckdb_store_batch_integrity_error",
438
+ error=str(e),
439
+ metric_count=len(metrics),
440
+ exc_info=e,
441
+ )
442
+ return False
443
+ except OperationalError as e:
444
+ logger.error(
445
+ "simple_duckdb_store_batch_operational_error",
446
+ error=str(e),
447
+ metric_count=len(metrics),
448
+ exc_info=e,
449
+ )
450
+ return False
451
+ except SQLAlchemyError as e:
452
+ logger.error(
453
+ "simple_duckdb_store_batch_db_error",
454
+ error=str(e),
455
+ metric_count=len(metrics),
456
+ exc_info=e,
457
+ )
458
+ return False
459
+ except Exception as e:
460
+ logger.error(
461
+ "simple_duckdb_store_batch_error",
462
+ error=str(e),
463
+ metric_count=len(metrics),
464
+ exc_info=e,
465
+ )
466
+ return False
467
+
468
+ async def store(self, metric: dict[str, Any]) -> bool:
469
+ """Store single metric.
470
+
471
+ Args:
472
+ metric: Metric data to store
473
+
474
+ Returns:
475
+ True if stored successfully
476
+ """
477
+ return await self.store_batch([metric])
478
+
479
+ async def close(self) -> None:
480
+ """Close the database connection and stop background worker."""
481
+ # Signal shutdown to background worker
482
+ self._shutdown_event.set()
483
+
484
+ # Wake up background worker immediately if it's waiting on queue.get()
485
+ with contextlib.suppress(Exception):
486
+ self._write_queue.put_nowait(self._sentinel) # type: ignore[arg-type]
487
+
488
+ # Wait for background worker to finish
489
+ if self._background_worker_task:
490
+ try:
491
+ await asyncio.wait_for(self._background_worker_task, timeout=5.0)
492
+ except TimeoutError:
493
+ logger.warning("background_worker_shutdown_timeout")
494
+ self._background_worker_task.cancel()
495
+ except asyncio.CancelledError:
496
+ logger.info("background_worker_shutdown_cancelled")
497
+ except Exception as e:
498
+ logger.error(
499
+ "background_worker_shutdown_error", error=str(e), exc_info=e
500
+ )
501
+
502
+ # Process remaining items in queue (with timeout)
503
+ try:
504
+ await asyncio.wait_for(self._write_queue.join(), timeout=2.0)
505
+ except TimeoutError:
506
+ logger.warning(
507
+ "queue_drain_timeout", remaining_items=self._write_queue.qsize()
508
+ )
509
+
510
+ if self._engine:
511
+ try:
512
+ self._engine.dispose()
513
+ except SQLAlchemyError as e:
514
+ logger.error(
515
+ "simple_duckdb_engine_close_db_error", error=str(e), exc_info=e
516
+ )
517
+ except Exception as e:
518
+ logger.error(
519
+ "simple_duckdb_engine_close_error", error=str(e), exc_info=e
520
+ )
521
+ finally:
522
+ self._engine = None
523
+
524
+ self._initialized = False
525
+
526
+ def is_enabled(self) -> bool:
527
+ """Check if storage is enabled and available."""
528
+ return self._initialized
529
+
530
+ async def health_check(self) -> dict[str, Any]:
531
+ """Get health status of the storage backend."""
532
+ if not self._initialized:
533
+ return {
534
+ "status": "not_initialized",
535
+ "enabled": False,
536
+ }
537
+
538
+ try:
539
+ if self._engine:
540
+ # Run the synchronous database operation in a thread pool
541
+ access_log_count = await asyncio.to_thread(self._health_check_sync)
542
+
543
+ return {
544
+ "status": "healthy",
545
+ "enabled": True,
546
+ "database_path": str(self.database_path),
547
+ "access_log_count": access_log_count,
548
+ "backend": "sqlmodel",
549
+ }
550
+ else:
551
+ return {
552
+ "status": "no_connection",
553
+ "enabled": False,
554
+ }
555
+
556
+ except SQLAlchemyError as e:
557
+ return {
558
+ "status": "unhealthy",
559
+ "enabled": False,
560
+ "error": str(e),
561
+ "error_type": "database",
562
+ }
563
+ except Exception as e:
564
+ return {
565
+ "status": "unhealthy",
566
+ "enabled": False,
567
+ "error": str(e),
568
+ "error_type": "unknown",
569
+ }
570
+
571
+ def _health_check_sync(self) -> int:
572
+ """Synchronous version of health check for thread pool execution."""
573
+ with Session(self._engine) as session:
574
+ table = SQLModel.metadata.tables.get("access_logs")
575
+ if table is None:
576
+ return 0
577
+ statement = sa_select(func.count()).select_from(table)
578
+ return cast(Any, session).exec(statement).first() or 0
579
+
580
+ async def reset_data(self) -> bool:
581
+ """Reset all data in the storage (useful for testing/debugging).
582
+
583
+ Returns:
584
+ True if reset was successful
585
+ """
586
+ if not self._initialized or not self._engine:
587
+ return False
588
+
589
+ try:
590
+ # Run the reset operation in a thread pool
591
+ return await asyncio.to_thread(self._reset_data_sync)
592
+ except SQLAlchemyError as e:
593
+ logger.error("simple_duckdb_reset_db_error", error=str(e), exc_info=e)
594
+ return False
595
+ except Exception as e:
596
+ logger.error("simple_duckdb_reset_error", error=str(e), exc_info=e)
597
+ return False
598
+
599
+ def _reset_data_sync(self) -> bool:
600
+ """Synchronous version of reset_data for thread pool execution.
601
+
602
+ Uses safe SQLModel ORM operations instead of raw SQL to prevent injection.
603
+ """
604
+ try:
605
+ table = SQLModel.metadata.tables.get("access_logs")
606
+ if table is None:
607
+ return True
608
+ with Session(self._engine) as session:
609
+ _ = cast(Any, session).exec(delete(table))
610
+ session.commit()
611
+
612
+ logger.info("simple_duckdb_reset_success")
613
+ return True
614
+ except SQLAlchemyError as e:
615
+ logger.error("simple_duckdb_reset_sync_db_error", error=str(e), exc_info=e)
616
+ return False
617
+ except Exception as e:
618
+ logger.error("simple_duckdb_reset_sync_error", error=str(e), exc_info=e)
619
+ return False
620
+
621
+ async def wait_for_queue_processing(self, timeout: float = 5.0) -> None:
622
+ """Wait for all queued items to be processed by the background worker.
623
+
624
+ Args:
625
+ timeout: Maximum time to wait in seconds
626
+
627
+ Raises:
628
+ asyncio.TimeoutError: If processing doesn't complete within timeout
629
+ """
630
+ if not self._initialized or self._shutdown_event.is_set():
631
+ return
632
+
633
+ await asyncio.wait_for(self._write_queue.join(), timeout=timeout)
@@ -0,0 +1,38 @@
1
+ # Max Tokens Plugin
2
+
3
+ Normalizes `max_tokens` fields so provider requests respect model limits.
4
+
5
+ ## Highlights
6
+ - Injects or corrects `max_tokens` / `max_output_tokens` before sending requests
7
+ - Supports enforce mode, provider filtering, and alias-aware model lookups
8
+ - Pulls limits from pricing cache with optional overrides via local JSON files
9
+
10
+ ## Configuration
11
+ - `MaxTokensConfig` toggles enablement, enforce mode, fallback values, and targets
12
+ - Environment variables follow the `MAX_TOKENS__*` pattern for quick overrides
13
+ - Generate defaults with `python3 scripts/generate_config_from_model.py \
14
+ --format toml --plugin max_tokens --config-class MaxTokensConfig`
15
+
16
+ ```toml
17
+ [plugins.max_tokens]
18
+ # enabled = true
19
+ # default_token_limits_file = "ccproxy/plugins/max_tokens/token_limits.json"
20
+ # fallback_max_tokens = 4096
21
+ # apply_to_all_providers = true
22
+ # target_providers = ["claude_api", "claude_sdk", "codex", "copilot"]
23
+ # require_pricing_data = false
24
+ # log_modifications = true
25
+ # enforce_mode = false
26
+ # prioritize_local_file = false
27
+
28
+ [plugins.max_tokens.modification_reasons]
29
+ # missing = "max_tokens was missing from request"
30
+ # invalid = "max_tokens was invalid or too high"
31
+ # exceeded = "max_tokens exceeded model limit"
32
+ # enforced = "max_tokens enforced to model limit (enforce mode)"
33
+ ```
34
+
35
+ ## Related Components
36
+ - `plugin.py`: runtime lifecycle and hook registration
37
+ - `adapter.py`: hook implementation that edits outbound payloads
38
+ - `service.py`: token limit lookup and caching helpers
@@ -0,0 +1,12 @@
1
+ """Max tokens plugin for automatic token limit enforcement.
2
+
3
+ This plugin intercepts requests and automatically sets max_tokens based on
4
+ model limits from the pricing data when no max_tokens is provided.
5
+ """
6
+
7
+ from .adapter import MaxTokensAdapter
8
+ from .config import MaxTokensConfig
9
+ from .plugin import factory
10
+
11
+
12
+ __all__ = ["MaxTokensAdapter", "MaxTokensConfig", "factory"]