ccproxy-api 0.1.6__py3-none-any.whl → 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (481) hide show
  1. ccproxy/api/__init__.py +1 -15
  2. ccproxy/api/app.py +439 -212
  3. ccproxy/api/bootstrap.py +30 -0
  4. ccproxy/api/decorators.py +85 -0
  5. ccproxy/api/dependencies.py +145 -176
  6. ccproxy/api/format_validation.py +54 -0
  7. ccproxy/api/middleware/cors.py +6 -3
  8. ccproxy/api/middleware/errors.py +402 -530
  9. ccproxy/api/middleware/hooks.py +563 -0
  10. ccproxy/api/middleware/normalize_headers.py +59 -0
  11. ccproxy/api/middleware/request_id.py +35 -16
  12. ccproxy/api/middleware/streaming_hooks.py +292 -0
  13. ccproxy/api/routes/__init__.py +5 -14
  14. ccproxy/api/routes/health.py +39 -672
  15. ccproxy/api/routes/plugins.py +277 -0
  16. ccproxy/auth/__init__.py +2 -19
  17. ccproxy/auth/bearer.py +25 -15
  18. ccproxy/auth/dependencies.py +123 -157
  19. ccproxy/auth/exceptions.py +0 -12
  20. ccproxy/auth/manager.py +35 -49
  21. ccproxy/auth/managers/__init__.py +10 -0
  22. ccproxy/auth/managers/base.py +523 -0
  23. ccproxy/auth/managers/base_enhanced.py +63 -0
  24. ccproxy/auth/managers/token_snapshot.py +77 -0
  25. ccproxy/auth/models/base.py +65 -0
  26. ccproxy/auth/models/credentials.py +40 -0
  27. ccproxy/auth/oauth/__init__.py +4 -18
  28. ccproxy/auth/oauth/base.py +533 -0
  29. ccproxy/auth/oauth/cli_errors.py +37 -0
  30. ccproxy/auth/oauth/flows.py +430 -0
  31. ccproxy/auth/oauth/protocol.py +366 -0
  32. ccproxy/auth/oauth/registry.py +408 -0
  33. ccproxy/auth/oauth/router.py +396 -0
  34. ccproxy/auth/oauth/routes.py +186 -113
  35. ccproxy/auth/oauth/session.py +151 -0
  36. ccproxy/auth/oauth/templates.py +342 -0
  37. ccproxy/auth/storage/__init__.py +2 -5
  38. ccproxy/auth/storage/base.py +279 -5
  39. ccproxy/auth/storage/generic.py +134 -0
  40. ccproxy/cli/__init__.py +1 -2
  41. ccproxy/cli/_settings_help.py +351 -0
  42. ccproxy/cli/commands/auth.py +1519 -793
  43. ccproxy/cli/commands/config/commands.py +209 -276
  44. ccproxy/cli/commands/plugins.py +669 -0
  45. ccproxy/cli/commands/serve.py +75 -810
  46. ccproxy/cli/commands/status.py +254 -0
  47. ccproxy/cli/decorators.py +83 -0
  48. ccproxy/cli/helpers.py +22 -60
  49. ccproxy/cli/main.py +359 -10
  50. ccproxy/cli/options/claude_options.py +0 -25
  51. ccproxy/config/__init__.py +7 -11
  52. ccproxy/config/core.py +227 -0
  53. ccproxy/config/env_generator.py +232 -0
  54. ccproxy/config/runtime.py +67 -0
  55. ccproxy/config/security.py +36 -3
  56. ccproxy/config/settings.py +382 -441
  57. ccproxy/config/toml_generator.py +299 -0
  58. ccproxy/config/utils.py +452 -0
  59. ccproxy/core/__init__.py +7 -271
  60. ccproxy/{_version.py → core/_version.py} +16 -3
  61. ccproxy/core/async_task_manager.py +516 -0
  62. ccproxy/core/async_utils.py +47 -14
  63. ccproxy/core/auth/__init__.py +6 -0
  64. ccproxy/core/constants.py +16 -50
  65. ccproxy/core/errors.py +53 -0
  66. ccproxy/core/id_utils.py +20 -0
  67. ccproxy/core/interfaces.py +16 -123
  68. ccproxy/core/logging.py +473 -18
  69. ccproxy/core/plugins/__init__.py +77 -0
  70. ccproxy/core/plugins/cli_discovery.py +211 -0
  71. ccproxy/core/plugins/declaration.py +455 -0
  72. ccproxy/core/plugins/discovery.py +604 -0
  73. ccproxy/core/plugins/factories.py +967 -0
  74. ccproxy/core/plugins/hooks/__init__.py +30 -0
  75. ccproxy/core/plugins/hooks/base.py +58 -0
  76. ccproxy/core/plugins/hooks/events.py +46 -0
  77. ccproxy/core/plugins/hooks/implementations/__init__.py +16 -0
  78. ccproxy/core/plugins/hooks/implementations/formatters/__init__.py +11 -0
  79. ccproxy/core/plugins/hooks/implementations/formatters/json.py +552 -0
  80. ccproxy/core/plugins/hooks/implementations/formatters/raw.py +370 -0
  81. ccproxy/core/plugins/hooks/implementations/http_tracer.py +431 -0
  82. ccproxy/core/plugins/hooks/layers.py +44 -0
  83. ccproxy/core/plugins/hooks/manager.py +186 -0
  84. ccproxy/core/plugins/hooks/registry.py +139 -0
  85. ccproxy/core/plugins/hooks/thread_manager.py +203 -0
  86. ccproxy/core/plugins/hooks/types.py +22 -0
  87. ccproxy/core/plugins/interfaces.py +416 -0
  88. ccproxy/core/plugins/loader.py +166 -0
  89. ccproxy/core/plugins/middleware.py +233 -0
  90. ccproxy/core/plugins/models.py +59 -0
  91. ccproxy/core/plugins/protocol.py +180 -0
  92. ccproxy/core/plugins/runtime.py +519 -0
  93. ccproxy/{observability/context.py → core/request_context.py} +137 -94
  94. ccproxy/core/status_report.py +211 -0
  95. ccproxy/core/transformers.py +13 -8
  96. ccproxy/data/claude_headers_fallback.json +558 -0
  97. ccproxy/data/codex_headers_fallback.json +121 -0
  98. ccproxy/http/__init__.py +30 -0
  99. ccproxy/http/base.py +95 -0
  100. ccproxy/http/client.py +323 -0
  101. ccproxy/http/hooks.py +642 -0
  102. ccproxy/http/pool.py +279 -0
  103. ccproxy/llms/formatters/__init__.py +7 -0
  104. ccproxy/llms/formatters/anthropic_to_openai/__init__.py +55 -0
  105. ccproxy/llms/formatters/anthropic_to_openai/errors.py +65 -0
  106. ccproxy/llms/formatters/anthropic_to_openai/requests.py +356 -0
  107. ccproxy/llms/formatters/anthropic_to_openai/responses.py +153 -0
  108. ccproxy/llms/formatters/anthropic_to_openai/streams.py +1546 -0
  109. ccproxy/llms/formatters/base.py +140 -0
  110. ccproxy/llms/formatters/base_model.py +33 -0
  111. ccproxy/llms/formatters/common/__init__.py +51 -0
  112. ccproxy/llms/formatters/common/identifiers.py +48 -0
  113. ccproxy/llms/formatters/common/streams.py +254 -0
  114. ccproxy/llms/formatters/common/thinking.py +74 -0
  115. ccproxy/llms/formatters/common/usage.py +135 -0
  116. ccproxy/llms/formatters/constants.py +55 -0
  117. ccproxy/llms/formatters/context.py +116 -0
  118. ccproxy/llms/formatters/mapping.py +33 -0
  119. ccproxy/llms/formatters/openai_to_anthropic/__init__.py +55 -0
  120. ccproxy/llms/formatters/openai_to_anthropic/_helpers.py +141 -0
  121. ccproxy/llms/formatters/openai_to_anthropic/errors.py +53 -0
  122. ccproxy/llms/formatters/openai_to_anthropic/requests.py +674 -0
  123. ccproxy/llms/formatters/openai_to_anthropic/responses.py +285 -0
  124. ccproxy/llms/formatters/openai_to_anthropic/streams.py +530 -0
  125. ccproxy/llms/formatters/openai_to_openai/__init__.py +53 -0
  126. ccproxy/llms/formatters/openai_to_openai/_helpers.py +325 -0
  127. ccproxy/llms/formatters/openai_to_openai/errors.py +6 -0
  128. ccproxy/llms/formatters/openai_to_openai/requests.py +388 -0
  129. ccproxy/llms/formatters/openai_to_openai/responses.py +594 -0
  130. ccproxy/llms/formatters/openai_to_openai/streams.py +1832 -0
  131. ccproxy/llms/formatters/utils.py +306 -0
  132. ccproxy/llms/models/__init__.py +9 -0
  133. ccproxy/llms/models/anthropic.py +619 -0
  134. ccproxy/llms/models/openai.py +844 -0
  135. ccproxy/llms/streaming/__init__.py +26 -0
  136. ccproxy/llms/streaming/accumulators.py +1074 -0
  137. ccproxy/llms/streaming/formatters.py +251 -0
  138. ccproxy/{adapters/openai/streaming.py → llms/streaming/processors.py} +193 -240
  139. ccproxy/models/__init__.py +8 -159
  140. ccproxy/models/detection.py +92 -193
  141. ccproxy/models/provider.py +75 -0
  142. ccproxy/plugins/access_log/README.md +32 -0
  143. ccproxy/plugins/access_log/__init__.py +20 -0
  144. ccproxy/plugins/access_log/config.py +33 -0
  145. ccproxy/plugins/access_log/formatter.py +126 -0
  146. ccproxy/plugins/access_log/hook.py +763 -0
  147. ccproxy/plugins/access_log/logger.py +254 -0
  148. ccproxy/plugins/access_log/plugin.py +137 -0
  149. ccproxy/plugins/access_log/writer.py +109 -0
  150. ccproxy/plugins/analytics/README.md +24 -0
  151. ccproxy/plugins/analytics/__init__.py +1 -0
  152. ccproxy/plugins/analytics/config.py +5 -0
  153. ccproxy/plugins/analytics/ingest.py +85 -0
  154. ccproxy/plugins/analytics/models.py +97 -0
  155. ccproxy/plugins/analytics/plugin.py +121 -0
  156. ccproxy/plugins/analytics/routes.py +163 -0
  157. ccproxy/plugins/analytics/service.py +284 -0
  158. ccproxy/plugins/claude_api/README.md +29 -0
  159. ccproxy/plugins/claude_api/__init__.py +10 -0
  160. ccproxy/plugins/claude_api/adapter.py +829 -0
  161. ccproxy/plugins/claude_api/config.py +52 -0
  162. ccproxy/plugins/claude_api/detection_service.py +461 -0
  163. ccproxy/plugins/claude_api/health.py +175 -0
  164. ccproxy/plugins/claude_api/hooks.py +284 -0
  165. ccproxy/plugins/claude_api/models.py +256 -0
  166. ccproxy/plugins/claude_api/plugin.py +298 -0
  167. ccproxy/plugins/claude_api/routes.py +118 -0
  168. ccproxy/plugins/claude_api/streaming_metrics.py +68 -0
  169. ccproxy/plugins/claude_api/tasks.py +84 -0
  170. ccproxy/plugins/claude_sdk/README.md +35 -0
  171. ccproxy/plugins/claude_sdk/__init__.py +80 -0
  172. ccproxy/plugins/claude_sdk/adapter.py +749 -0
  173. ccproxy/plugins/claude_sdk/auth.py +57 -0
  174. ccproxy/{claude_sdk → plugins/claude_sdk}/client.py +63 -39
  175. ccproxy/plugins/claude_sdk/config.py +210 -0
  176. ccproxy/{claude_sdk → plugins/claude_sdk}/converter.py +6 -6
  177. ccproxy/plugins/claude_sdk/detection_service.py +163 -0
  178. ccproxy/{services/claude_sdk_service.py → plugins/claude_sdk/handler.py} +123 -304
  179. ccproxy/plugins/claude_sdk/health.py +113 -0
  180. ccproxy/plugins/claude_sdk/hooks.py +115 -0
  181. ccproxy/{claude_sdk → plugins/claude_sdk}/manager.py +42 -32
  182. ccproxy/{claude_sdk → plugins/claude_sdk}/message_queue.py +8 -8
  183. ccproxy/{models/claude_sdk.py → plugins/claude_sdk/models.py} +64 -16
  184. ccproxy/plugins/claude_sdk/options.py +154 -0
  185. ccproxy/{claude_sdk → plugins/claude_sdk}/parser.py +23 -5
  186. ccproxy/plugins/claude_sdk/plugin.py +269 -0
  187. ccproxy/plugins/claude_sdk/routes.py +104 -0
  188. ccproxy/{claude_sdk → plugins/claude_sdk}/session_client.py +124 -12
  189. ccproxy/plugins/claude_sdk/session_pool.py +700 -0
  190. ccproxy/{claude_sdk → plugins/claude_sdk}/stream_handle.py +48 -43
  191. ccproxy/{claude_sdk → plugins/claude_sdk}/stream_worker.py +22 -18
  192. ccproxy/{claude_sdk → plugins/claude_sdk}/streaming.py +50 -16
  193. ccproxy/plugins/claude_sdk/tasks.py +97 -0
  194. ccproxy/plugins/claude_shared/README.md +18 -0
  195. ccproxy/plugins/claude_shared/__init__.py +12 -0
  196. ccproxy/plugins/claude_shared/model_defaults.py +171 -0
  197. ccproxy/plugins/codex/README.md +35 -0
  198. ccproxy/plugins/codex/__init__.py +6 -0
  199. ccproxy/plugins/codex/adapter.py +635 -0
  200. ccproxy/{config/codex.py → plugins/codex/config.py} +78 -12
  201. ccproxy/plugins/codex/detection_service.py +544 -0
  202. ccproxy/plugins/codex/health.py +162 -0
  203. ccproxy/plugins/codex/hooks.py +263 -0
  204. ccproxy/plugins/codex/model_defaults.py +39 -0
  205. ccproxy/plugins/codex/models.py +263 -0
  206. ccproxy/plugins/codex/plugin.py +275 -0
  207. ccproxy/plugins/codex/routes.py +129 -0
  208. ccproxy/plugins/codex/streaming_metrics.py +324 -0
  209. ccproxy/plugins/codex/tasks.py +106 -0
  210. ccproxy/plugins/codex/utils/__init__.py +1 -0
  211. ccproxy/plugins/codex/utils/sse_parser.py +106 -0
  212. ccproxy/plugins/command_replay/README.md +34 -0
  213. ccproxy/plugins/command_replay/__init__.py +17 -0
  214. ccproxy/plugins/command_replay/config.py +133 -0
  215. ccproxy/plugins/command_replay/formatter.py +432 -0
  216. ccproxy/plugins/command_replay/hook.py +294 -0
  217. ccproxy/plugins/command_replay/plugin.py +161 -0
  218. ccproxy/plugins/copilot/README.md +39 -0
  219. ccproxy/plugins/copilot/__init__.py +11 -0
  220. ccproxy/plugins/copilot/adapter.py +465 -0
  221. ccproxy/plugins/copilot/config.py +155 -0
  222. ccproxy/plugins/copilot/data/copilot_fallback.json +41 -0
  223. ccproxy/plugins/copilot/detection_service.py +255 -0
  224. ccproxy/plugins/copilot/manager.py +275 -0
  225. ccproxy/plugins/copilot/model_defaults.py +284 -0
  226. ccproxy/plugins/copilot/models.py +148 -0
  227. ccproxy/plugins/copilot/oauth/__init__.py +16 -0
  228. ccproxy/plugins/copilot/oauth/client.py +494 -0
  229. ccproxy/plugins/copilot/oauth/models.py +385 -0
  230. ccproxy/plugins/copilot/oauth/provider.py +602 -0
  231. ccproxy/plugins/copilot/oauth/storage.py +170 -0
  232. ccproxy/plugins/copilot/plugin.py +360 -0
  233. ccproxy/plugins/copilot/routes.py +294 -0
  234. ccproxy/plugins/credential_balancer/README.md +124 -0
  235. ccproxy/plugins/credential_balancer/__init__.py +6 -0
  236. ccproxy/plugins/credential_balancer/config.py +270 -0
  237. ccproxy/plugins/credential_balancer/factory.py +415 -0
  238. ccproxy/plugins/credential_balancer/hook.py +51 -0
  239. ccproxy/plugins/credential_balancer/manager.py +587 -0
  240. ccproxy/plugins/credential_balancer/plugin.py +146 -0
  241. ccproxy/plugins/dashboard/README.md +25 -0
  242. ccproxy/plugins/dashboard/__init__.py +1 -0
  243. ccproxy/plugins/dashboard/config.py +8 -0
  244. ccproxy/plugins/dashboard/plugin.py +71 -0
  245. ccproxy/plugins/dashboard/routes.py +67 -0
  246. ccproxy/plugins/docker/README.md +32 -0
  247. ccproxy/{docker → plugins/docker}/__init__.py +3 -0
  248. ccproxy/{docker → plugins/docker}/adapter.py +108 -10
  249. ccproxy/plugins/docker/config.py +82 -0
  250. ccproxy/{docker → plugins/docker}/docker_path.py +4 -3
  251. ccproxy/{docker → plugins/docker}/middleware.py +2 -2
  252. ccproxy/plugins/docker/plugin.py +198 -0
  253. ccproxy/{docker → plugins/docker}/stream_process.py +3 -3
  254. ccproxy/plugins/duckdb_storage/README.md +26 -0
  255. ccproxy/plugins/duckdb_storage/__init__.py +1 -0
  256. ccproxy/plugins/duckdb_storage/config.py +22 -0
  257. ccproxy/plugins/duckdb_storage/plugin.py +128 -0
  258. ccproxy/plugins/duckdb_storage/routes.py +51 -0
  259. ccproxy/plugins/duckdb_storage/storage.py +633 -0
  260. ccproxy/plugins/max_tokens/README.md +38 -0
  261. ccproxy/plugins/max_tokens/__init__.py +12 -0
  262. ccproxy/plugins/max_tokens/adapter.py +235 -0
  263. ccproxy/plugins/max_tokens/config.py +86 -0
  264. ccproxy/plugins/max_tokens/models.py +53 -0
  265. ccproxy/plugins/max_tokens/plugin.py +200 -0
  266. ccproxy/plugins/max_tokens/service.py +271 -0
  267. ccproxy/plugins/max_tokens/token_limits.json +54 -0
  268. ccproxy/plugins/metrics/README.md +35 -0
  269. ccproxy/plugins/metrics/__init__.py +10 -0
  270. ccproxy/{observability/metrics.py → plugins/metrics/collector.py} +20 -153
  271. ccproxy/plugins/metrics/config.py +85 -0
  272. ccproxy/plugins/metrics/grafana/dashboards/ccproxy-dashboard.json +1720 -0
  273. ccproxy/plugins/metrics/hook.py +403 -0
  274. ccproxy/plugins/metrics/plugin.py +268 -0
  275. ccproxy/{observability → plugins/metrics}/pushgateway.py +57 -59
  276. ccproxy/plugins/metrics/routes.py +107 -0
  277. ccproxy/plugins/metrics/tasks.py +117 -0
  278. ccproxy/plugins/oauth_claude/README.md +35 -0
  279. ccproxy/plugins/oauth_claude/__init__.py +14 -0
  280. ccproxy/plugins/oauth_claude/client.py +270 -0
  281. ccproxy/plugins/oauth_claude/config.py +84 -0
  282. ccproxy/plugins/oauth_claude/manager.py +482 -0
  283. ccproxy/plugins/oauth_claude/models.py +266 -0
  284. ccproxy/plugins/oauth_claude/plugin.py +149 -0
  285. ccproxy/plugins/oauth_claude/provider.py +571 -0
  286. ccproxy/plugins/oauth_claude/storage.py +212 -0
  287. ccproxy/plugins/oauth_codex/README.md +38 -0
  288. ccproxy/plugins/oauth_codex/__init__.py +14 -0
  289. ccproxy/plugins/oauth_codex/client.py +224 -0
  290. ccproxy/plugins/oauth_codex/config.py +95 -0
  291. ccproxy/plugins/oauth_codex/manager.py +256 -0
  292. ccproxy/plugins/oauth_codex/models.py +239 -0
  293. ccproxy/plugins/oauth_codex/plugin.py +146 -0
  294. ccproxy/plugins/oauth_codex/provider.py +574 -0
  295. ccproxy/plugins/oauth_codex/storage.py +92 -0
  296. ccproxy/plugins/permissions/README.md +28 -0
  297. ccproxy/plugins/permissions/__init__.py +22 -0
  298. ccproxy/plugins/permissions/config.py +28 -0
  299. ccproxy/{cli/commands/permission_handler.py → plugins/permissions/handlers/cli.py} +49 -25
  300. ccproxy/plugins/permissions/handlers/protocol.py +33 -0
  301. ccproxy/plugins/permissions/handlers/terminal.py +675 -0
  302. ccproxy/{api/routes → plugins/permissions}/mcp.py +34 -7
  303. ccproxy/{models/permissions.py → plugins/permissions/models.py} +65 -1
  304. ccproxy/plugins/permissions/plugin.py +153 -0
  305. ccproxy/{api/routes/permissions.py → plugins/permissions/routes.py} +20 -16
  306. ccproxy/{api/services/permission_service.py → plugins/permissions/service.py} +65 -11
  307. ccproxy/{api → plugins/permissions}/ui/permission_handler_protocol.py +1 -1
  308. ccproxy/{api → plugins/permissions}/ui/terminal_permission_handler.py +66 -10
  309. ccproxy/plugins/pricing/README.md +34 -0
  310. ccproxy/plugins/pricing/__init__.py +6 -0
  311. ccproxy/{pricing → plugins/pricing}/cache.py +7 -6
  312. ccproxy/{config/pricing.py → plugins/pricing/config.py} +32 -6
  313. ccproxy/plugins/pricing/exceptions.py +35 -0
  314. ccproxy/plugins/pricing/loader.py +440 -0
  315. ccproxy/{pricing → plugins/pricing}/models.py +13 -23
  316. ccproxy/plugins/pricing/plugin.py +169 -0
  317. ccproxy/plugins/pricing/service.py +191 -0
  318. ccproxy/plugins/pricing/tasks.py +300 -0
  319. ccproxy/{pricing → plugins/pricing}/updater.py +86 -72
  320. ccproxy/plugins/pricing/utils.py +99 -0
  321. ccproxy/plugins/request_tracer/README.md +40 -0
  322. ccproxy/plugins/request_tracer/__init__.py +7 -0
  323. ccproxy/plugins/request_tracer/config.py +120 -0
  324. ccproxy/plugins/request_tracer/hook.py +415 -0
  325. ccproxy/plugins/request_tracer/plugin.py +255 -0
  326. ccproxy/scheduler/__init__.py +2 -14
  327. ccproxy/scheduler/core.py +26 -41
  328. ccproxy/scheduler/manager.py +63 -107
  329. ccproxy/scheduler/registry.py +6 -32
  330. ccproxy/scheduler/tasks.py +346 -314
  331. ccproxy/services/__init__.py +0 -1
  332. ccproxy/services/adapters/__init__.py +11 -0
  333. ccproxy/services/adapters/base.py +123 -0
  334. ccproxy/services/adapters/chain_composer.py +88 -0
  335. ccproxy/services/adapters/chain_validation.py +44 -0
  336. ccproxy/services/adapters/chat_accumulator.py +200 -0
  337. ccproxy/services/adapters/delta_utils.py +142 -0
  338. ccproxy/services/adapters/format_adapter.py +136 -0
  339. ccproxy/services/adapters/format_context.py +11 -0
  340. ccproxy/services/adapters/format_registry.py +158 -0
  341. ccproxy/services/adapters/http_adapter.py +1045 -0
  342. ccproxy/services/adapters/mock_adapter.py +118 -0
  343. ccproxy/services/adapters/protocols.py +35 -0
  344. ccproxy/services/adapters/simple_converters.py +571 -0
  345. ccproxy/services/auth_registry.py +180 -0
  346. ccproxy/services/cache/__init__.py +6 -0
  347. ccproxy/services/cache/response_cache.py +261 -0
  348. ccproxy/services/cli_detection.py +437 -0
  349. ccproxy/services/config/__init__.py +6 -0
  350. ccproxy/services/config/proxy_configuration.py +111 -0
  351. ccproxy/services/container.py +256 -0
  352. ccproxy/services/factories.py +380 -0
  353. ccproxy/services/handler_config.py +76 -0
  354. ccproxy/services/interfaces.py +298 -0
  355. ccproxy/services/mocking/__init__.py +6 -0
  356. ccproxy/services/mocking/mock_handler.py +291 -0
  357. ccproxy/services/tracing/__init__.py +7 -0
  358. ccproxy/services/tracing/interfaces.py +61 -0
  359. ccproxy/services/tracing/null_tracer.py +57 -0
  360. ccproxy/streaming/__init__.py +23 -0
  361. ccproxy/streaming/buffer.py +1056 -0
  362. ccproxy/streaming/deferred.py +897 -0
  363. ccproxy/streaming/handler.py +117 -0
  364. ccproxy/streaming/interfaces.py +77 -0
  365. ccproxy/streaming/simple_adapter.py +39 -0
  366. ccproxy/streaming/sse.py +109 -0
  367. ccproxy/streaming/sse_parser.py +127 -0
  368. ccproxy/templates/__init__.py +6 -0
  369. ccproxy/templates/plugin_scaffold.py +695 -0
  370. ccproxy/testing/endpoints/__init__.py +33 -0
  371. ccproxy/testing/endpoints/cli.py +215 -0
  372. ccproxy/testing/endpoints/config.py +874 -0
  373. ccproxy/testing/endpoints/console.py +57 -0
  374. ccproxy/testing/endpoints/models.py +100 -0
  375. ccproxy/testing/endpoints/runner.py +1903 -0
  376. ccproxy/testing/endpoints/tools.py +308 -0
  377. ccproxy/testing/mock_responses.py +70 -1
  378. ccproxy/testing/response_handlers.py +20 -0
  379. ccproxy/utils/__init__.py +0 -6
  380. ccproxy/utils/binary_resolver.py +476 -0
  381. ccproxy/utils/caching.py +327 -0
  382. ccproxy/utils/cli_logging.py +101 -0
  383. ccproxy/utils/command_line.py +251 -0
  384. ccproxy/utils/headers.py +228 -0
  385. ccproxy/utils/model_mapper.py +120 -0
  386. ccproxy/utils/startup_helpers.py +95 -342
  387. ccproxy/utils/version_checker.py +279 -6
  388. ccproxy_api-0.2.0.dist-info/METADATA +212 -0
  389. ccproxy_api-0.2.0.dist-info/RECORD +417 -0
  390. {ccproxy_api-0.1.6.dist-info → ccproxy_api-0.2.0.dist-info}/WHEEL +1 -1
  391. ccproxy_api-0.2.0.dist-info/entry_points.txt +24 -0
  392. ccproxy/__init__.py +0 -4
  393. ccproxy/adapters/__init__.py +0 -11
  394. ccproxy/adapters/base.py +0 -80
  395. ccproxy/adapters/codex/__init__.py +0 -11
  396. ccproxy/adapters/openai/__init__.py +0 -42
  397. ccproxy/adapters/openai/adapter.py +0 -953
  398. ccproxy/adapters/openai/models.py +0 -412
  399. ccproxy/adapters/openai/response_adapter.py +0 -355
  400. ccproxy/adapters/openai/response_models.py +0 -178
  401. ccproxy/api/middleware/headers.py +0 -49
  402. ccproxy/api/middleware/logging.py +0 -180
  403. ccproxy/api/middleware/request_content_logging.py +0 -297
  404. ccproxy/api/middleware/server_header.py +0 -58
  405. ccproxy/api/responses.py +0 -89
  406. ccproxy/api/routes/claude.py +0 -371
  407. ccproxy/api/routes/codex.py +0 -1231
  408. ccproxy/api/routes/metrics.py +0 -1029
  409. ccproxy/api/routes/proxy.py +0 -211
  410. ccproxy/api/services/__init__.py +0 -6
  411. ccproxy/auth/conditional.py +0 -84
  412. ccproxy/auth/credentials_adapter.py +0 -93
  413. ccproxy/auth/models.py +0 -118
  414. ccproxy/auth/oauth/models.py +0 -48
  415. ccproxy/auth/openai/__init__.py +0 -13
  416. ccproxy/auth/openai/credentials.py +0 -166
  417. ccproxy/auth/openai/oauth_client.py +0 -334
  418. ccproxy/auth/openai/storage.py +0 -184
  419. ccproxy/auth/storage/json_file.py +0 -158
  420. ccproxy/auth/storage/keyring.py +0 -189
  421. ccproxy/claude_sdk/__init__.py +0 -18
  422. ccproxy/claude_sdk/options.py +0 -194
  423. ccproxy/claude_sdk/session_pool.py +0 -550
  424. ccproxy/cli/docker/__init__.py +0 -34
  425. ccproxy/cli/docker/adapter_factory.py +0 -157
  426. ccproxy/cli/docker/params.py +0 -274
  427. ccproxy/config/auth.py +0 -153
  428. ccproxy/config/claude.py +0 -348
  429. ccproxy/config/cors.py +0 -79
  430. ccproxy/config/discovery.py +0 -95
  431. ccproxy/config/docker_settings.py +0 -264
  432. ccproxy/config/observability.py +0 -158
  433. ccproxy/config/reverse_proxy.py +0 -31
  434. ccproxy/config/scheduler.py +0 -108
  435. ccproxy/config/server.py +0 -86
  436. ccproxy/config/validators.py +0 -231
  437. ccproxy/core/codex_transformers.py +0 -389
  438. ccproxy/core/http.py +0 -328
  439. ccproxy/core/http_transformers.py +0 -812
  440. ccproxy/core/proxy.py +0 -143
  441. ccproxy/core/validators.py +0 -288
  442. ccproxy/models/errors.py +0 -42
  443. ccproxy/models/messages.py +0 -269
  444. ccproxy/models/requests.py +0 -107
  445. ccproxy/models/responses.py +0 -270
  446. ccproxy/models/types.py +0 -102
  447. ccproxy/observability/__init__.py +0 -51
  448. ccproxy/observability/access_logger.py +0 -457
  449. ccproxy/observability/sse_events.py +0 -303
  450. ccproxy/observability/stats_printer.py +0 -753
  451. ccproxy/observability/storage/__init__.py +0 -1
  452. ccproxy/observability/storage/duckdb_simple.py +0 -677
  453. ccproxy/observability/storage/models.py +0 -70
  454. ccproxy/observability/streaming_response.py +0 -107
  455. ccproxy/pricing/__init__.py +0 -19
  456. ccproxy/pricing/loader.py +0 -251
  457. ccproxy/services/claude_detection_service.py +0 -269
  458. ccproxy/services/codex_detection_service.py +0 -263
  459. ccproxy/services/credentials/__init__.py +0 -55
  460. ccproxy/services/credentials/config.py +0 -105
  461. ccproxy/services/credentials/manager.py +0 -561
  462. ccproxy/services/credentials/oauth_client.py +0 -481
  463. ccproxy/services/proxy_service.py +0 -1827
  464. ccproxy/static/.keep +0 -0
  465. ccproxy/utils/cost_calculator.py +0 -210
  466. ccproxy/utils/disconnection_monitor.py +0 -83
  467. ccproxy/utils/model_mapping.py +0 -199
  468. ccproxy/utils/models_provider.py +0 -150
  469. ccproxy/utils/simple_request_logger.py +0 -284
  470. ccproxy/utils/streaming_metrics.py +0 -199
  471. ccproxy_api-0.1.6.dist-info/METADATA +0 -615
  472. ccproxy_api-0.1.6.dist-info/RECORD +0 -189
  473. ccproxy_api-0.1.6.dist-info/entry_points.txt +0 -4
  474. /ccproxy/{api/middleware/auth.py → auth/models/__init__.py} +0 -0
  475. /ccproxy/{claude_sdk → plugins/claude_sdk}/exceptions.py +0 -0
  476. /ccproxy/{docker → plugins/docker}/models.py +0 -0
  477. /ccproxy/{docker → plugins/docker}/protocol.py +0 -0
  478. /ccproxy/{docker → plugins/docker}/validators.py +0 -0
  479. /ccproxy/{auth/oauth/storage.py → plugins/permissions/handlers/__init__.py} +0 -0
  480. /ccproxy/{api → plugins/permissions}/ui/__init__.py +0 -0
  481. {ccproxy_api-0.1.6.dist-info → ccproxy_api-0.2.0.dist-info}/licenses/LICENSE +0 -0
@@ -1,49 +1,51 @@
1
- """Settings configuration for Claude Proxy API Server."""
2
-
3
- import contextlib
4
- import json
5
1
  import os
6
2
  import tomllib
7
3
  from pathlib import Path
8
4
  from typing import Any
9
5
 
10
- import structlog
11
- from pydantic import Field, field_validator, model_validator
6
+ from pydantic import BaseModel, Field
12
7
  from pydantic_settings import BaseSettings, SettingsConfigDict
13
8
 
14
- from ccproxy.config.discovery import find_toml_config_file
15
-
16
- from .auth import AuthSettings
17
- from .claude import ClaudeSettings
18
- from .codex import CodexSettings
19
- from .cors import CORSSettings
20
- from .docker_settings import DockerSettings
21
- from .observability import ObservabilitySettings
22
- from .pricing import PricingSettings
23
- from .reverse_proxy import ReverseProxySettings
24
- from .scheduler import SchedulerSettings
25
- from .security import SecuritySettings
26
- from .server import ServerSettings
27
-
28
-
29
- __all__ = [
30
- "Settings",
31
- "ConfigurationError",
32
- "ConfigurationManager",
33
- "config_manager",
34
- "get_settings",
9
+ from ccproxy.core.logging import get_logger
10
+
11
+ from .core import (
12
+ CORSSettings,
13
+ HTTPSettings,
14
+ LoggingSettings,
15
+ PluginDiscoverySettings,
16
+ ServerSettings,
17
+ )
18
+ from .runtime import BinarySettings
19
+ from .security import AuthSettings, SecuritySettings
20
+ from .utils import SchedulerSettings, find_toml_config_file, get_ccproxy_config_dir
21
+
22
+
23
+ _CONFIG_MISSING_LOGGED = False
24
+
25
+ # Default plugins enabled when no config file exists
26
+ DEFAULT_ENABLED_PLUGINS = [
27
+ "codex",
28
+ "copilot",
29
+ "claude_api",
30
+ "claude_sdk",
31
+ "oauth_codex",
32
+ "oauth_claude",
35
33
  ]
36
34
 
37
35
 
36
+ def _auth_default() -> AuthSettings:
37
+ return AuthSettings(credentials_ttl_seconds=3600.0)
38
+
39
+
40
+ __all__ = ["Settings", "ConfigurationError"]
41
+
42
+
38
43
  class ConfigurationError(Exception):
39
44
  """Raised when configuration loading or validation fails."""
40
45
 
41
46
  pass
42
47
 
43
48
 
44
- # PoolSettings class removed - connection pooling functionality has been removed
45
-
46
-
47
49
  class Settings(BaseSettings):
48
50
  """
49
51
  Configuration settings for the Claude Proxy API Server.
@@ -64,12 +66,16 @@ class Settings(BaseSettings):
64
66
  env_nested_delimiter="__",
65
67
  )
66
68
 
67
- # Core application settings
68
69
  server: ServerSettings = Field(
69
70
  default_factory=ServerSettings,
70
71
  description="Server configuration settings",
71
72
  )
72
73
 
74
+ logging: LoggingSettings = Field(
75
+ default_factory=LoggingSettings,
76
+ description="Centralized logging configuration",
77
+ )
78
+
73
79
  security: SecuritySettings = Field(
74
80
  default_factory=SecuritySettings,
75
81
  description="Security configuration settings",
@@ -80,230 +86,75 @@ class Settings(BaseSettings):
80
86
  description="CORS configuration settings",
81
87
  )
82
88
 
83
- # Claude-specific settings
84
- claude: ClaudeSettings = Field(
85
- default_factory=ClaudeSettings,
86
- description="Claude-specific configuration settings",
87
- )
88
-
89
- # Codex-specific settings
90
- codex: CodexSettings = Field(
91
- default_factory=CodexSettings,
92
- description="OpenAI Codex-specific configuration settings",
93
- )
94
-
95
- # Proxy and authentication
96
- reverse_proxy: ReverseProxySettings = Field(
97
- default_factory=ReverseProxySettings,
98
- description="Reverse proxy configuration settings",
89
+ http: HTTPSettings = Field(
90
+ default_factory=HTTPSettings,
91
+ description="HTTP client configuration settings",
92
+ json_schema_extra={"config_example_hidden": True},
99
93
  )
100
94
 
101
95
  auth: AuthSettings = Field(
102
- default_factory=AuthSettings,
103
- description="Authentication and credentials configuration",
104
- )
105
-
106
- # Container settings
107
- docker: DockerSettings = Field(
108
- default_factory=DockerSettings,
109
- description="Docker configuration for running Claude commands in containers",
96
+ default_factory=_auth_default,
97
+ description="Authentication manager settings (e.g., credentials caching)",
110
98
  )
111
99
 
112
- # Observability settings
113
- observability: ObservabilitySettings = Field(
114
- default_factory=ObservabilitySettings,
115
- description="Observability configuration settings",
100
+ binary: BinarySettings = Field(
101
+ default_factory=BinarySettings,
102
+ description="Binary resolution and package manager fallback configuration",
103
+ json_schema_extra={"config_example_hidden": True},
116
104
  )
117
105
 
118
- # Scheduler settings
119
106
  scheduler: SchedulerSettings = Field(
120
107
  default_factory=SchedulerSettings,
121
108
  description="Task scheduler configuration settings",
109
+ json_schema_extra={"config_example_hidden": True},
122
110
  )
123
111
 
124
- # Pricing settings
125
- pricing: PricingSettings = Field(
126
- default_factory=PricingSettings,
127
- description="Pricing and cost calculation configuration settings",
112
+ plugin_discovery: PluginDiscoverySettings = Field(
113
+ default_factory=PluginDiscoverySettings,
114
+ description="Filesystem plugin discovery search paths",
115
+ json_schema_extra={"config_example_hidden": True},
128
116
  )
129
117
 
130
- @field_validator("server", mode="before")
131
- @classmethod
132
- def validate_server(cls, v: Any) -> Any:
133
- """Validate and convert server settings."""
134
- if v is None:
135
- return ServerSettings()
136
- if isinstance(v, ServerSettings):
137
- return v
138
- if isinstance(v, dict):
139
- return ServerSettings(**v)
140
- return v
141
-
142
- @field_validator("security", mode="before")
143
- @classmethod
144
- def validate_security(cls, v: Any) -> Any:
145
- """Validate and convert security settings."""
146
- if v is None:
147
- return SecuritySettings()
148
- if isinstance(v, SecuritySettings):
149
- return v
150
- if isinstance(v, dict):
151
- return SecuritySettings(**v)
152
- return v
153
-
154
- @field_validator("cors", mode="before")
155
- @classmethod
156
- def validate_cors(cls, v: Any) -> Any:
157
- """Validate and convert CORS settings."""
158
- if v is None:
159
- return CORSSettings()
160
- if isinstance(v, CORSSettings):
161
- return v
162
- if isinstance(v, dict):
163
- return CORSSettings(**v)
164
- return v
165
-
166
- @field_validator("claude", mode="before")
167
- @classmethod
168
- def validate_claude(cls, v: Any) -> Any:
169
- """Validate and convert Claude settings."""
170
- if v is None:
171
- return ClaudeSettings()
172
- if isinstance(v, ClaudeSettings):
173
- return v
174
- if isinstance(v, dict):
175
- return ClaudeSettings(**v)
176
- return v
177
-
178
- @field_validator("codex", mode="before")
179
- @classmethod
180
- def validate_codex(cls, v: Any) -> Any:
181
- """Validate and convert Codex settings."""
182
- if v is None:
183
- return CodexSettings()
184
- if isinstance(v, CodexSettings):
185
- return v
186
- if isinstance(v, dict):
187
- return CodexSettings(**v)
188
- return v
189
-
190
- @field_validator("reverse_proxy", mode="before")
191
- @classmethod
192
- def validate_reverse_proxy(cls, v: Any) -> Any:
193
- """Validate and convert reverse proxy settings."""
194
- if v is None:
195
- return ReverseProxySettings()
196
- if isinstance(v, ReverseProxySettings):
197
- return v
198
- if isinstance(v, dict):
199
- return ReverseProxySettings(**v)
200
- return v
201
-
202
- @field_validator("auth", mode="before")
203
- @classmethod
204
- def validate_auth(cls, v: Any) -> Any:
205
- """Validate and convert auth settings."""
206
- if v is None:
207
- return AuthSettings()
208
- if isinstance(v, AuthSettings):
209
- return v
210
- if isinstance(v, dict):
211
- return AuthSettings(**v)
212
- return v
213
-
214
- @field_validator("docker", mode="before")
215
- @classmethod
216
- def validate_docker_settings(cls, v: Any) -> Any:
217
- """Validate and convert Docker settings."""
218
- if v is None:
219
- return DockerSettings()
118
+ enable_plugins: bool = Field(
119
+ default=True,
120
+ description="Enable plugin system",
121
+ json_schema_extra={"config_example_hidden": True},
122
+ )
220
123
 
221
- # If it's already a DockerSettings instance, return as-is
222
- if isinstance(v, DockerSettings):
223
- return v
124
+ plugins_disable_local_discovery: bool = Field(
125
+ default=False,
126
+ description=(
127
+ "If true, skip filesystem plugin discovery from the local 'plugins/' directory "
128
+ "and load plugins only from installed entry points."
129
+ ),
130
+ json_schema_extra={"config_example_hidden": True},
131
+ )
224
132
 
225
- # If it's a dict, create DockerSettings from it
226
- if isinstance(v, dict):
227
- return DockerSettings(**v)
133
+ enabled_plugins: list[str] | None = Field(
134
+ default=None,
135
+ description="List of explicitly enabled plugins (None = all enabled). Takes precedence over disabled_plugins.",
136
+ json_schema_extra={"config_example_hidden": False},
137
+ )
228
138
 
229
- # Try to convert to dict if possible
230
- if hasattr(v, "model_dump"):
231
- return DockerSettings(**v.model_dump())
232
- elif hasattr(v, "__dict__"):
233
- return DockerSettings(**v.__dict__)
139
+ disabled_plugins: list[str] | None = Field(
140
+ default=None,
141
+ description="List of explicitly disabled plugins.",
142
+ json_schema_extra={"config_example_hidden": True},
143
+ )
234
144
 
235
- return v
145
+ # CLI context for plugin access (set dynamically)
146
+ cli_context: dict[str, Any] = Field(default_factory=dict, exclude=True)
236
147
 
237
- @field_validator("observability", mode="before")
238
- @classmethod
239
- def validate_observability(cls, v: Any) -> Any:
240
- """Validate and convert observability settings."""
241
- if v is None:
242
- return ObservabilitySettings()
243
- if isinstance(v, ObservabilitySettings):
244
- return v
245
- if isinstance(v, dict):
246
- return ObservabilitySettings(**v)
247
- return v
248
-
249
- @field_validator("scheduler", mode="before")
250
- @classmethod
251
- def validate_scheduler(cls, v: Any) -> Any:
252
- """Validate and convert scheduler settings."""
253
- if v is None:
254
- return SchedulerSettings()
255
- if isinstance(v, SchedulerSettings):
256
- return v
257
- if isinstance(v, dict):
258
- return SchedulerSettings(**v)
259
- return v
260
-
261
- @field_validator("pricing", mode="before")
262
- @classmethod
263
- def validate_pricing(cls, v: Any) -> Any:
264
- """Validate and convert pricing settings."""
265
- if v is None:
266
- return PricingSettings()
267
- if isinstance(v, PricingSettings):
268
- return v
269
- if isinstance(v, dict):
270
- return PricingSettings(**v)
271
- return v
272
-
273
- # validate_pool_settings method removed - connection pooling functionality has been removed
148
+ plugins: dict[str, dict[str, Any]] = Field(
149
+ default_factory=dict,
150
+ description="Plugin-specific configurations keyed by plugin name",
151
+ )
274
152
 
275
153
  @property
276
154
  def server_url(self) -> str:
277
155
  """Get the complete server URL."""
278
156
  return f"http://{self.server.host}:{self.server.port}"
279
157
 
280
- @property
281
- def is_development(self) -> bool:
282
- """Check if running in development mode."""
283
- return self.server.reload or self.server.log_level == "DEBUG"
284
-
285
- @model_validator(mode="after")
286
- def setup_claude_cli_path(self) -> "Settings":
287
- """Set up Claude CLI path in environment if provided or found."""
288
- # If not explicitly set, try to find it
289
- if not self.claude.cli_path:
290
- found_path, found_in_path = self.claude.find_claude_cli()
291
- if found_path:
292
- self.claude.cli_path = found_path
293
- # Only add to PATH if it wasn't found via which()
294
- if not found_in_path:
295
- cli_dir = str(Path(self.claude.cli_path).parent)
296
- current_path = os.environ.get("PATH", "")
297
- if cli_dir not in current_path:
298
- os.environ["PATH"] = f"{cli_dir}:{current_path}"
299
- elif self.claude.cli_path:
300
- # If explicitly set, always add to PATH
301
- cli_dir = str(Path(self.claude.cli_path).parent)
302
- current_path = os.environ.get("PATH", "")
303
- if cli_dir not in current_path:
304
- os.environ["PATH"] = f"{cli_dir}:{current_path}"
305
- return self
306
-
307
158
  def model_dump_safe(self) -> dict[str, Any]:
308
159
  """
309
160
  Dump model data with sensitive information masked.
@@ -311,21 +162,61 @@ class Settings(BaseSettings):
311
162
  Returns:
312
163
  dict: Configuration with sensitive data masked
313
164
  """
314
- return self.model_dump()
165
+ return self.model_dump(mode="json")
315
166
 
316
167
  @classmethod
317
- def load_toml_config(cls, toml_path: Path) -> dict[str, Any]:
318
- """Load configuration from a TOML file.
168
+ def _validate_deprecated_keys(cls, config_data: dict[str, Any]) -> None:
169
+ """Fail fast if deprecated legacy config keys are present."""
170
+ deprecated_hits: list[tuple[str, str]] = []
171
+
172
+ scheduler_cfg = config_data.get("scheduler") or {}
173
+ if isinstance(scheduler_cfg, dict):
174
+ key_map = {
175
+ "pushgateway_enabled": "plugins.metrics.pushgateway_enabled",
176
+ "pushgateway_url": "plugins.metrics.pushgateway_url",
177
+ "pushgateway_job": "plugins.metrics.pushgateway_job",
178
+ "pushgateway_interval_seconds": "plugins.metrics.pushgateway_push_interval",
179
+ }
180
+ for old_key, new_key in key_map.items():
181
+ if old_key in scheduler_cfg:
182
+ deprecated_hits.append((f"scheduler.{old_key}", new_key))
319
183
 
320
- Args:
321
- toml_path: Path to the TOML configuration file
184
+ if "observability" in config_data:
185
+ deprecated_hits.append(
186
+ ("observability.*", "plugins.* (metrics/analytics/dashboard)")
187
+ )
322
188
 
323
- Returns:
324
- dict: Configuration data from the TOML file
189
+ for env_key in os.environ:
190
+ upper = env_key.upper()
191
+ if upper.startswith("SCHEDULER__PUSHGATEWAY_"):
192
+ env_map = {
193
+ "SCHEDULER__PUSHGATEWAY_ENABLED": "plugins.metrics.pushgateway_enabled",
194
+ "SCHEDULER__PUSHGATEWAY_URL": "plugins.metrics.pushgateway_url",
195
+ "SCHEDULER__PUSHGATEWAY_JOB": "plugins.metrics.pushgateway_job",
196
+ "SCHEDULER__PUSHGATEWAY_INTERVAL_SECONDS": "plugins.metrics.pushgateway_push_interval",
197
+ }
198
+ target = env_map.get(upper, "plugins.metrics.*")
199
+ deprecated_hits.append((env_key, target))
200
+ if upper.startswith("OBSERVABILITY__"):
201
+ deprecated_hits.append(
202
+ (env_key, "plugins.* (metrics/analytics/dashboard)")
203
+ )
325
204
 
326
- Raises:
327
- ValueError: If the TOML file is invalid or cannot be read
328
- """
205
+ if deprecated_hits:
206
+ lines = [
207
+ "Removed configuration keys detected. The following are no longer supported:",
208
+ ]
209
+ for old, new in deprecated_hits:
210
+ lines.append(f"- {old} → {new}")
211
+ lines.append(
212
+ "Configure corresponding plugin settings under [plugins.*]. "
213
+ "See: ccproxy/plugins/metrics/README.md and the Plugin Config Quickstart."
214
+ )
215
+ raise ValueError("\n".join(lines))
216
+
217
+ @classmethod
218
+ def load_toml_config(cls, toml_path: Path) -> dict[str, Any]:
219
+ """Load configuration from a TOML file."""
329
220
  try:
330
221
  with toml_path.open("rb") as f:
331
222
  return tomllib.load(f)
@@ -336,17 +227,7 @@ class Settings(BaseSettings):
336
227
 
337
228
  @classmethod
338
229
  def load_config_file(cls, config_path: Path) -> dict[str, Any]:
339
- """Load configuration from a file based on its extension.
340
-
341
- Args:
342
- config_path: Path to the configuration file
343
-
344
- Returns:
345
- dict: Configuration data from the file
346
-
347
- Raises:
348
- ValueError: If the file format is unsupported or invalid
349
- """
230
+ """Load configuration from a file based on its extension."""
350
231
  suffix = config_path.suffix.lower()
351
232
 
352
233
  if suffix in [".toml"]:
@@ -359,219 +240,279 @@ class Settings(BaseSettings):
359
240
 
360
241
  @classmethod
361
242
  def from_toml(cls, toml_path: Path | None = None, **kwargs: Any) -> "Settings":
362
- """Create Settings instance from TOML configuration.
363
-
364
- Args:
365
- toml_path: Path to TOML configuration file. If None, auto-discovers file.
366
- **kwargs: Additional keyword arguments to override config values
367
-
368
- Returns:
369
- Settings: Configured Settings instance
370
- """
371
- # Use the more generic from_config method
243
+ """Create Settings instance from TOML configuration."""
372
244
  return cls.from_config(config_path=toml_path, **kwargs)
373
245
 
246
+ # ------------------------------
247
+ # Internal helpers (merging/overrides)
248
+ # ------------------------------
249
+ @staticmethod
250
+ def _env_has_prefix(prefix: str) -> bool:
251
+ p = prefix.upper()
252
+ return any(k.upper().startswith(p) for k in os.environ)
253
+
254
+ @staticmethod
255
+ def _merge_model(
256
+ model: BaseModel, overrides: dict[str, Any], env_prefix: str
257
+ ) -> BaseModel:
258
+ """
259
+ Deep-merge a dict of overrides into a BaseModel while preserving env-var precedence.
260
+ env_prefix should end with '__' when called for nested fields.
261
+ """
262
+ update_payload: dict[str, Any] = {}
263
+
264
+ for field_name, override_value in overrides.items():
265
+ field_env_key = f"{env_prefix}{field_name.upper()}"
266
+ # If an env var exists for this field, do NOT override from file.
267
+ if os.getenv(field_env_key) is not None:
268
+ continue
269
+
270
+ current_value = getattr(model, field_name, None)
271
+
272
+ if isinstance(current_value, BaseModel) and isinstance(
273
+ override_value, dict
274
+ ):
275
+ nested_prefix = f"{field_env_key}__"
276
+ merged_nested = Settings._merge_model(
277
+ current_value, override_value, nested_prefix
278
+ )
279
+ update_payload[field_name] = merged_nested
280
+ elif isinstance(current_value, dict) and isinstance(override_value, dict):
281
+ # Deep-merge dict but skip keys that have env overrides
282
+ merged_dict = current_value.copy()
283
+ for nk, nv in override_value.items():
284
+ nested_env_key = f"{field_env_key}__{nk.upper()}"
285
+ if os.getenv(nested_env_key) is None:
286
+ if isinstance(merged_dict.get(nk), dict) and isinstance(
287
+ nv, dict
288
+ ):
289
+ # deep merge nested dicts with respect to env
290
+ merged_dict[nk] = Settings._merge_dict(
291
+ merged_dict.get(nk, {}), nv, f"{nested_env_key}__"
292
+ )
293
+ else:
294
+ merged_dict[nk] = nv
295
+ update_payload[field_name] = merged_dict
296
+ else:
297
+ update_payload[field_name] = override_value
298
+
299
+ if not update_payload:
300
+ return model
301
+ return model.model_copy(update=update_payload)
302
+
303
+ @staticmethod
304
+ def _merge_dict(
305
+ base: dict[str, Any], overrides: dict[str, Any], env_prefix: str
306
+ ) -> dict[str, Any]:
307
+ """
308
+ Deep-merge dicts while respecting env-var precedence using the given env_prefix (no trailing __ required).
309
+ """
310
+ out = dict(base)
311
+ for k, v in overrides.items():
312
+ key_env = f"{env_prefix}{k.upper()}"
313
+ if os.getenv(key_env) is not None:
314
+ continue
315
+ if isinstance(out.get(k), dict) and isinstance(v, dict):
316
+ out[k] = Settings._merge_dict(out[k], v, f"{key_env}__")
317
+ else:
318
+ out[k] = v
319
+ return out
320
+
321
+ @staticmethod
322
+ def _merge_plugins(
323
+ current_plugins: dict[str, Any], overrides: dict[str, Any]
324
+ ) -> dict[str, Any]:
325
+ """
326
+ Merge plugin configuration trees with env precedence at both plugin and nested key levels.
327
+ """
328
+ merged = dict(current_plugins)
329
+ for plugin_name, plugin_cfg in overrides.items():
330
+ env_prefix = f"PLUGINS__{plugin_name.upper()}__"
331
+
332
+ # If any env for this plugin exists, we keep current_plugins[plugin_name] as-is,
333
+ # but we still allow env-free nested keys to merge if we already have a dict.
334
+ if isinstance(plugin_cfg, dict):
335
+ if Settings._env_has_prefix(env_prefix):
336
+ # Partial merge respecting env at nested levels if the plugin already exists as a dict.
337
+ if isinstance(merged.get(plugin_name), dict):
338
+ merged[plugin_name] = Settings._merge_dict(
339
+ merged[plugin_name],
340
+ plugin_cfg,
341
+ env_prefix,
342
+ )
343
+ else:
344
+ # Keep existing unless it's missing entirely.
345
+ merged.setdefault(plugin_name, merged.get(plugin_name, {}))
346
+ else:
347
+ existing = merged.get(plugin_name, {})
348
+ if isinstance(existing, dict):
349
+ merged[plugin_name] = Settings._merge_dict(
350
+ existing, plugin_cfg, env_prefix
351
+ )
352
+ else:
353
+ merged[plugin_name] = plugin_cfg
354
+ else:
355
+ # Non-dict plugin setting: only apply if no top-level env overrides present.
356
+ if not Settings._env_has_prefix(env_prefix):
357
+ merged[plugin_name] = plugin_cfg
358
+ return merged
359
+
360
+ @staticmethod
361
+ def _apply_overrides(target: Any, overrides: dict[str, Any]) -> None:
362
+ """
363
+ Apply CLI/kwargs overrides after file and env processing.
364
+ Dicts are shallow-merged; nested BaseModels recurse.
365
+ """
366
+ for k, v in overrides.items():
367
+ if (
368
+ isinstance(v, dict)
369
+ and hasattr(target, k)
370
+ and isinstance(getattr(target, k), (BaseModel | dict))
371
+ ):
372
+ sub = getattr(target, k)
373
+ if isinstance(sub, BaseModel):
374
+ # Apply directly field-by-field
375
+ Settings._apply_overrides(sub, v)
376
+ elif isinstance(sub, dict):
377
+ sub.update(v)
378
+ else:
379
+ setattr(target, k, v)
380
+
381
+ # ------------------------------
382
+ # Factory
383
+ # ------------------------------
374
384
  @classmethod
375
385
  def from_config(
376
- cls, config_path: Path | str | None = None, **kwargs: Any
386
+ cls,
387
+ config_path: Path | str | None = None,
388
+ cli_context: dict[str, Any] | None = None,
389
+ **kwargs: Any,
377
390
  ) -> "Settings":
378
- """Create Settings instance from configuration file.
391
+ """Create Settings instance from configuration file with env precedence and safe merging."""
392
+ logger = get_logger(__name__)
379
393
 
380
- Args:
381
- config_path: Path to configuration file. Can be:
382
- - None: Auto-discover config file or use CONFIG_FILE env var
383
- - Path or str: Use this specific config file
384
- **kwargs: Additional keyword arguments to override config values
394
+ global _CONFIG_MISSING_LOGGED
385
395
 
386
- Returns:
387
- Settings: Configured Settings instance
388
- """
389
- # Check for CONFIG_FILE environment variable first
390
396
  if config_path is None:
391
397
  config_path_env = os.environ.get("CONFIG_FILE")
392
398
  if config_path_env:
393
399
  config_path = Path(config_path_env)
394
400
 
395
- # Convert string to Path if needed
396
401
  if isinstance(config_path, str):
397
402
  config_path = Path(config_path)
398
403
 
399
- # Auto-discover config file if not provided
400
404
  if config_path is None:
401
405
  config_path = find_toml_config_file()
402
406
 
403
- # Load config if found
404
- config_data = {}
407
+ config_data: dict[str, Any] = {}
405
408
  if config_path and config_path.exists():
406
409
  config_data = cls.load_config_file(config_path)
407
-
408
- # Merge config with kwargs (kwargs take precedence)
409
- merged_config = {**config_data, **kwargs}
410
-
411
- # Create Settings instance with merged config
412
- return cls(**merged_config)
413
-
414
-
415
- class ConfigurationManager:
416
- """Centralized configuration management for CLI and server."""
417
-
418
- def __init__(self) -> None:
419
- self._settings: Settings | None = None
420
- self._config_path: Path | None = None
421
- self._logging_configured = False
422
-
423
- def load_settings(
424
- self,
425
- config_path: Path | None = None,
426
- cli_overrides: dict[str, Any] | None = None,
427
- ) -> Settings:
428
- """Load settings with CLI overrides and caching."""
429
- if self._settings is None or config_path != self._config_path:
430
- try:
431
- self._settings = Settings.from_config(
432
- config_path=config_path, **(cli_overrides or {})
433
- )
434
- self._config_path = config_path
435
- except Exception as e:
436
- raise ConfigurationError(f"Failed to load configuration: {e}") from e
437
-
438
- return self._settings
439
-
440
- def setup_logging(self, log_level: str | None = None) -> None:
441
- """Configure logging once based on settings."""
442
- if self._logging_configured:
443
- return
444
-
445
- # Import here to avoid circular import
446
-
447
- effective_level = log_level or (
448
- self._settings.server.log_level if self._settings else "INFO"
449
- )
450
-
451
- # Determine format based on log level - Rich for DEBUG, JSON for production
452
- format_type = "rich" if effective_level.upper() == "DEBUG" else "json"
453
-
454
- # setup_dual_logging(
455
- # level=effective_level,
456
- # format_type=format_type,
457
- # configure_uvicorn=True,
458
- # verbose_tracebacks=effective_level.upper() == "DEBUG",
459
- # )
460
- self._logging_configured = True
461
-
462
- def get_cli_overrides_from_args(self, **cli_args: Any) -> dict[str, Any]:
463
- """Extract non-None CLI arguments as configuration overrides."""
464
- overrides = {}
465
-
466
- # Server settings
467
- server_settings = {}
468
- for key in ["host", "port", "reload", "log_level", "log_file"]:
469
- if cli_args.get(key) is not None:
470
- server_settings[key] = cli_args[key]
471
- if server_settings:
472
- overrides["server"] = server_settings
473
-
474
- # Security settings
475
- if cli_args.get("auth_token") is not None:
476
- overrides["security"] = {"auth_token": cli_args["auth_token"]}
477
-
478
- # Claude settings
479
- claude_settings = {}
480
- if cli_args.get("claude_cli_path") is not None:
481
- claude_settings["cli_path"] = cli_args["claude_cli_path"]
482
-
483
- # Direct Claude settings (not nested in code_options)
484
- for key in [
485
- "sdk_message_mode",
486
- "system_prompt_injection_mode",
487
- "builtin_permissions",
488
- ]:
489
- if cli_args.get(key) is not None:
490
- claude_settings[key] = cli_args[key]
491
-
492
- # Handle pool configuration
493
- if cli_args.get("sdk_pool") is not None:
494
- claude_settings["sdk_pool"] = {"enabled": cli_args["sdk_pool"]}
495
-
496
- if cli_args.get("sdk_pool_size") is not None:
497
- if "sdk_pool" not in claude_settings:
498
- claude_settings["sdk_pool"] = {}
499
- claude_settings["sdk_pool"]["pool_size"] = cli_args["sdk_pool_size"]
500
-
501
- if cli_args.get("sdk_session_pool") is not None:
502
- claude_settings["sdk_session_pool"] = {
503
- "enabled": cli_args["sdk_session_pool"]
504
- }
505
-
506
- # Claude Code options
507
- claude_opts = {}
508
- for key in [
509
- "max_thinking_tokens",
510
- "permission_mode",
511
- "cwd",
512
- "max_turns",
513
- "append_system_prompt",
514
- "permission_prompt_tool_name",
515
- "continue_conversation",
516
- ]:
517
- if cli_args.get(key) is not None:
518
- claude_opts[key] = cli_args[key]
519
-
520
- # Handle comma-separated lists
521
- for key in ["allowed_tools", "disallowed_tools"]:
522
- if cli_args.get(key):
523
- claude_opts[key] = [tool.strip() for tool in cli_args[key].split(",")]
524
-
525
- if claude_opts:
526
- claude_settings["code_options"] = claude_opts
527
-
528
- if claude_settings:
529
- overrides["claude"] = claude_settings
530
-
531
- # CORS settings
532
- if cli_args.get("cors_origins"):
533
- overrides["cors"] = {
534
- "origins": [
535
- origin.strip() for origin in cli_args["cors_origins"].split(",")
536
- ]
410
+ logger.debug(
411
+ "config_file_loaded",
412
+ path=str(config_path),
413
+ category="config",
414
+ )
415
+ elif not _CONFIG_MISSING_LOGGED:
416
+ suggestion = f"ccproxy config init --output-dir {get_ccproxy_config_dir()}"
417
+ log_kwargs: dict[str, Any] = {
418
+ "category": "config",
419
+ "suggested_command": suggestion,
537
420
  }
421
+ if config_path is not None:
422
+ log_kwargs["path"] = str(config_path)
423
+ logger.warning("config_file_missing", **log_kwargs)
424
+ _CONFIG_MISSING_LOGGED = True
425
+
426
+ cls._validate_deprecated_keys(config_data)
427
+
428
+ # Start from env + .env via BaseSettings
429
+ settings = cls()
430
+
431
+ # Merge file-based configuration with env-var precedence
432
+ for key, value in config_data.items():
433
+ if not hasattr(settings, key):
434
+ continue
435
+
436
+ if key == "plugins" and isinstance(value, dict):
437
+ current_plugins = getattr(settings, key, {})
438
+ merged_plugins = cls._merge_plugins(current_plugins, value)
439
+ setattr(settings, key, merged_plugins)
440
+ continue
441
+
442
+ current_attr = getattr(settings, key)
443
+
444
+ if isinstance(value, dict) and isinstance(current_attr, BaseModel):
445
+ merged_model = cls._merge_model(current_attr, value, f"{key.upper()}__")
446
+ setattr(settings, key, merged_model)
447
+ else:
448
+ # Only set top-level simple types if there is no top-level env override
449
+ env_key = key.upper()
450
+ if os.getenv(env_key) is None:
451
+ setattr(settings, key, value)
452
+
453
+ # Smart default: if no config file exists and enabled_plugins is still None,
454
+ # set a curated default list of core plugins
455
+ if not config_path or not config_path.exists():
456
+ if settings.enabled_plugins is None:
457
+ settings.enabled_plugins = DEFAULT_ENABLED_PLUGINS
458
+
459
+ # Apply direct kwargs overrides (highest precedence within process)
460
+ if kwargs:
461
+ cls._apply_overrides(settings, kwargs)
462
+
463
+ # Apply CLI context (explicit flags)
464
+ if cli_context:
465
+ # Store raw CLI context for plugin access
466
+ settings.cli_context = cli_context
467
+
468
+ # Apply common serve CLI overrides directly to settings
469
+ server_overrides: dict[str, Any] = {}
470
+ if cli_context.get("host") is not None:
471
+ server_overrides["host"] = cli_context["host"]
472
+ if cli_context.get("port") is not None:
473
+ server_overrides["port"] = cli_context["port"]
474
+ if cli_context.get("reload") is not None:
475
+ server_overrides["reload"] = cli_context["reload"]
476
+
477
+ logging_overrides: dict[str, Any] = {}
478
+ if cli_context.get("log_level") is not None:
479
+ logging_overrides["level"] = cli_context["log_level"]
480
+ if cli_context.get("log_file") is not None:
481
+ logging_overrides["file"] = cli_context["log_file"]
482
+
483
+ security_overrides: dict[str, Any] = {}
484
+ if cli_context.get("auth_token") is not None:
485
+ security_overrides["auth_token"] = cli_context["auth_token"]
486
+
487
+ if server_overrides:
488
+ cls._apply_overrides(settings, {"server": server_overrides})
489
+ if logging_overrides:
490
+ cls._apply_overrides(settings, {"logging": logging_overrides})
491
+ if security_overrides:
492
+ cls._apply_overrides(settings, {"security": security_overrides})
493
+
494
+ # Apply plugin enable/disable lists if provided
495
+ enabled_plugins = cli_context.get("enabled_plugins")
496
+ disabled_plugins = cli_context.get("disabled_plugins")
497
+ if enabled_plugins is not None:
498
+ settings.enabled_plugins = list(enabled_plugins)
499
+ if disabled_plugins is not None:
500
+ settings.disabled_plugins = list(disabled_plugins)
538
501
 
539
- return overrides
540
-
541
- def reset(self) -> None:
542
- """Reset configuration state (useful for testing)."""
543
- self._settings = None
544
- self._config_path = None
545
- self._logging_configured = False
546
-
547
-
548
- # Global configuration manager instance
549
- config_manager = ConfigurationManager()
550
-
551
- logger = structlog.get_logger(__name__)
502
+ return settings
552
503
 
504
+ def get_cli_context(self) -> dict[str, Any]:
505
+ """Get CLI context for plugin access."""
506
+ return self.cli_context
553
507
 
554
- def get_settings(config_path: Path | str | None = None) -> Settings:
555
- """Get the global settings instance with configuration file support.
508
+ class LLMSettings(BaseModel):
509
+ """LLM-specific feature toggles and defaults."""
556
510
 
557
- Args:
558
- config_path: Optional path to configuration file. If None, uses CONFIG_FILE env var
559
- or auto-discovers config file.
511
+ openai_thinking_xml: bool = Field(
512
+ default=True, description="Serialize thinking as XML in OpenAI streams"
513
+ )
560
514
 
561
- Returns:
562
- Settings: Configured Settings instance
563
- """
564
- try:
565
- # Check for CLI overrides from environment variable
566
- cli_overrides = {}
567
- cli_overrides_json = os.environ.get("CCPROXY_CONFIG_OVERRIDES")
568
- if cli_overrides_json:
569
- with contextlib.suppress(json.JSONDecodeError):
570
- cli_overrides = json.loads(cli_overrides_json)
571
-
572
- settings = Settings.from_config(config_path=config_path, **cli_overrides)
573
- return settings
574
- except Exception as e:
575
- # If settings can't be loaded (e.g., missing API key),
576
- # this will be handled by the caller
577
- raise ValueError(f"Configuration error: {e}") from e
515
+ llm: LLMSettings = Field(
516
+ default_factory=LLMSettings,
517
+ description="Large Language Model (LLM) settings",
518
+ )