ccproxy-api 0.1.7__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 +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.0.dist-info/METADATA +212 -0
  389. ccproxy_api-0.2.0.dist-info/RECORD +417 -0
  390. {ccproxy_api-0.1.7.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 -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.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,829 @@
1
+ import json
2
+ import time
3
+ import uuid
4
+ from typing import Any, cast
5
+
6
+ import httpx
7
+ from starlette.responses import Response, StreamingResponse
8
+
9
+ from ccproxy.auth.exceptions import CredentialsInvalidError, OAuthTokenRefreshError
10
+ from ccproxy.core.logging import get_plugin_logger
11
+ from ccproxy.core.plugins.interfaces import (
12
+ DetectionServiceProtocol,
13
+ TokenManagerProtocol,
14
+ )
15
+ from ccproxy.services.adapters.http_adapter import BaseHTTPAdapter
16
+ from ccproxy.utils.headers import (
17
+ extract_response_headers,
18
+ filter_request_headers,
19
+ )
20
+
21
+ from .config import ClaudeAPISettings
22
+
23
+
24
+ logger = get_plugin_logger()
25
+
26
+
27
+ class ClaudeAPIAdapter(BaseHTTPAdapter):
28
+ """Simplified Claude API adapter."""
29
+
30
+ def __init__(
31
+ self,
32
+ detection_service: DetectionServiceProtocol,
33
+ config: ClaudeAPISettings | None = None,
34
+ **kwargs: Any,
35
+ ) -> None:
36
+ super().__init__(config=config or ClaudeAPISettings(), **kwargs)
37
+ self.detection_service: DetectionServiceProtocol = detection_service
38
+ self.token_manager: TokenManagerProtocol = cast(
39
+ TokenManagerProtocol, self.auth_manager
40
+ )
41
+
42
+ self.base_url = self.config.base_url.rstrip("/")
43
+
44
+ async def get_target_url(self, endpoint: str) -> str:
45
+ return f"{self.base_url}/v1/messages"
46
+
47
+ async def prepare_provider_request(
48
+ self, body: bytes, headers: dict[str, str], endpoint: str
49
+ ) -> tuple[bytes, dict[str, str]]:
50
+ # Get a valid access token (auto-refreshes if expired)
51
+ token_value = await self._resolve_access_token()
52
+
53
+ # Parse body
54
+ body_data = json.loads(body.decode()) if body else {}
55
+
56
+ # Anthropic API rejects null temperature fields, so strip them early
57
+ if body_data.get("temperature") is None:
58
+ body_data.pop("temperature", None)
59
+
60
+ # Anthropic API constraint: cannot accept both temperature and top_p
61
+ # Prioritize temperature over top_p when both are present
62
+ if "temperature" in body_data and "top_p" in body_data:
63
+ body_data.pop("top_p", None)
64
+
65
+ if self._needs_anthropic_conversion(endpoint):
66
+ body_data = self._convert_openai_to_anthropic(body_data)
67
+
68
+ # Inject system prompt based on config mode using detection service helper
69
+ system_mode = self.config.system_prompt_injection_mode
70
+ if self.detection_service and system_mode != "none":
71
+ system_value = self._resolve_system_prompt_value(system_mode)
72
+ if system_value is not None:
73
+ body_data = self._inject_system_prompt(
74
+ body_data, system_value, mode=system_mode
75
+ )
76
+
77
+ # Limit cache_control blocks to comply with Anthropic's limit
78
+ body_data = self._limit_cache_control_blocks(body_data)
79
+
80
+ # Remove internal metadata fields like _ccproxy_injected before sending to the API
81
+ body_data = self._remove_metadata_fields(body_data)
82
+
83
+ # Filter headers and enforce OAuth Authorization
84
+ filtered_headers = filter_request_headers(headers, preserve_auth=False)
85
+ # Always set Authorization from OAuth-managed access token
86
+ filtered_headers["authorization"] = f"Bearer {token_value}"
87
+
88
+ # Add CLI headers if available, but never allow overriding auth
89
+ cli_headers = self._collect_cli_headers()
90
+ if cli_headers:
91
+ blocked_overrides = {"authorization", "x-api-key"}
92
+ for key, value in cli_headers.items():
93
+ lk = key.lower()
94
+ if lk in blocked_overrides:
95
+ logger.debug(
96
+ "cli_header_override_blocked",
97
+ header=lk,
98
+ reason="preserve_oauth_auth_header",
99
+ )
100
+ continue
101
+ filtered_headers[lk] = value
102
+
103
+ return json.dumps(body_data).encode(), filtered_headers
104
+
105
+ async def process_provider_response(
106
+ self, response: httpx.Response, endpoint: str
107
+ ) -> Response | StreamingResponse:
108
+ """Return a plain Response; streaming handled upstream by BaseHTTPAdapter.
109
+
110
+ The BaseHTTPAdapter is responsible for detecting streaming and delegating
111
+ to the shared StreamingHandler. For non-streaming responses, adapters
112
+ should return a simple Starlette Response.
113
+ """
114
+ response_headers = extract_response_headers(response)
115
+
116
+ body_bytes = response.content
117
+ media_type = response.headers.get("content-type")
118
+
119
+ if self._needs_openai_conversion(endpoint):
120
+ converted = self._convert_anthropic_to_openai_response(response)
121
+ if converted is not None:
122
+ body_bytes = json.dumps(converted).encode()
123
+ media_type = "application/json"
124
+
125
+ return Response(
126
+ content=body_bytes,
127
+ status_code=response.status_code,
128
+ headers=response_headers,
129
+ media_type=media_type,
130
+ )
131
+
132
+ def _needs_openai_conversion(self, endpoint: str) -> bool:
133
+ if not getattr(self.config, "support_openai_format", True):
134
+ return False
135
+ normalized = (endpoint or "").strip().lower()
136
+ return normalized.startswith("/v1/chat/completions")
137
+
138
+ def _needs_anthropic_conversion(self, endpoint: str) -> bool:
139
+ if not getattr(self.config, "support_openai_format", True):
140
+ return False
141
+ normalized = (endpoint or "").strip().lower()
142
+ return normalized.startswith("/v1/chat/completions")
143
+
144
+ async def _resolve_access_token(self) -> str:
145
+ """Resolve a usable Claude API OAuth token from the token manager.
146
+
147
+ If the auth manager is not configured, raise a unified AuthenticationError
148
+ so middleware returns a clean 401 without stack traces.
149
+ """
150
+
151
+ if not getattr(self, "token_manager", None):
152
+ from ccproxy.core.errors import AuthenticationError
153
+
154
+ logger.warning(
155
+ "auth_manager_override_not_resolved",
156
+ plugin="claude_api",
157
+ auth_manager_name="oauth_claude",
158
+ category="auth",
159
+ )
160
+ raise AuthenticationError(
161
+ "Authentication manager not configured for Claude API provider"
162
+ )
163
+
164
+ token_manager = self.token_manager
165
+
166
+ async def _snapshot_token() -> str | None:
167
+ snapshot = await token_manager.get_token_snapshot()
168
+ if snapshot and snapshot.access_token:
169
+ return str(snapshot.access_token)
170
+ return None
171
+
172
+ credentials = await token_manager.load_credentials()
173
+ if credentials and token_manager.should_refresh(credentials):
174
+ try:
175
+ refreshed = await token_manager.get_access_token_with_refresh()
176
+ if refreshed:
177
+ return refreshed
178
+ except OAuthTokenRefreshError as exc:
179
+ logger.warning(
180
+ "claude_token_refresh_failed",
181
+ error=str(exc),
182
+ category="auth",
183
+ )
184
+ fallback = await _snapshot_token()
185
+ if fallback:
186
+ return fallback
187
+
188
+ # Primary path: rely on manager contract
189
+ try:
190
+ token = await token_manager.get_access_token()
191
+ if token:
192
+ return token
193
+ except CredentialsInvalidError:
194
+ logger.debug("claude_token_invalid", category="auth")
195
+ except OAuthTokenRefreshError as exc:
196
+ logger.warning(
197
+ "claude_token_refresh_failed",
198
+ error=str(exc),
199
+ category="auth",
200
+ )
201
+ fallback = await _snapshot_token()
202
+ if fallback:
203
+ return fallback
204
+ except Exception as exc: # pragma: no cover - defensive logging
205
+ logger.debug(
206
+ "claude_token_fetch_failed",
207
+ error=str(exc),
208
+ category="auth",
209
+ )
210
+
211
+ # Fallback to explicit refresh helper
212
+ try:
213
+ refreshed = await token_manager.get_access_token_with_refresh()
214
+ if refreshed:
215
+ return refreshed
216
+ except OAuthTokenRefreshError as exc:
217
+ logger.warning(
218
+ "claude_token_refresh_failed",
219
+ error=str(exc),
220
+ category="auth",
221
+ )
222
+ fallback = await _snapshot_token()
223
+ if fallback:
224
+ return fallback
225
+ except Exception as exc: # pragma: no cover - defensive logging
226
+ logger.debug(
227
+ "claude_token_refresh_failed",
228
+ error=str(exc),
229
+ category="auth",
230
+ )
231
+
232
+ fallback = await _snapshot_token()
233
+ if fallback:
234
+ return fallback
235
+
236
+ raise ValueError("No valid OAuth access token available for Claude API")
237
+
238
+ def _resolve_system_prompt_value(self, mode: str) -> Any:
239
+ """Retrieve system prompt content for injection from detection cache."""
240
+
241
+ if not self.detection_service:
242
+ return None
243
+
244
+ # Primary path: detection service helper
245
+ try:
246
+ prompt = self.detection_service.get_system_prompt(mode=mode)
247
+ except Exception:
248
+ prompt = {}
249
+ if isinstance(prompt, dict):
250
+ system_value = prompt.get("system")
251
+ if system_value:
252
+ return system_value
253
+
254
+ prompts = self.detection_service.get_detected_prompts()
255
+ if prompts.has_system():
256
+ system_payload = prompts.system_payload(mode=mode)
257
+ system_value = system_payload.get("system") if system_payload else None
258
+ if system_value:
259
+ return system_value
260
+ return prompts.system
261
+
262
+ cached = self.detection_service.get_cached_data()
263
+ # Backward compatibility: legacy cached.system_prompt object
264
+ system_prompt_obj = getattr(cached, "system_prompt", None) if cached else None
265
+ if system_prompt_obj is not None:
266
+ return getattr(system_prompt_obj, "system_field", system_prompt_obj)
267
+
268
+ return None
269
+
270
+ def _collect_cli_headers(self) -> dict[str, str]:
271
+ """Collect safe CLI headers from detection cache for request forwarding."""
272
+
273
+ if not self.detection_service:
274
+ return {}
275
+
276
+ headers_data = self.detection_service.get_detected_headers()
277
+ if not headers_data:
278
+ return {}
279
+
280
+ ignores = {h.lower() for h in self.detection_service.get_ignored_headers()}
281
+ redacted = {h.lower() for h in self.detection_service.get_redacted_headers()}
282
+
283
+ return headers_data.filtered(ignores=ignores, redacted=redacted)
284
+
285
+ def _convert_openai_to_anthropic(self, payload: dict[str, Any]) -> dict[str, Any]:
286
+ """Convert an OpenAI chat.completions style payload to Anthropic format."""
287
+
288
+ if not isinstance(payload, dict):
289
+ return payload
290
+
291
+ messages = payload.get("messages")
292
+ if not isinstance(messages, list):
293
+ return payload
294
+
295
+ system_blocks: list[Any] = []
296
+ anthropic_messages: list[dict[str, Any]] = []
297
+
298
+ for msg in messages:
299
+ if not isinstance(msg, dict):
300
+ continue
301
+ role = msg.get("role")
302
+ content = msg.get("content", "")
303
+
304
+ if role == "system":
305
+ block = self._normalize_text_block(content)
306
+ if block is not None:
307
+ if isinstance(block, list):
308
+ system_blocks.extend(block)
309
+ else:
310
+ system_blocks.append(block)
311
+ continue
312
+
313
+ block = self._normalize_text_block(content)
314
+ if block is None:
315
+ block = []
316
+
317
+ anthropic_messages.append(
318
+ {
319
+ "role": role or "user",
320
+ "content": block if isinstance(block, list) else [block],
321
+ }
322
+ )
323
+
324
+ converted = {k: v for k, v in payload.items() if k != "messages"}
325
+ if system_blocks:
326
+ converted["system"] = system_blocks
327
+ converted["messages"] = anthropic_messages
328
+ return converted
329
+
330
+ def _normalize_text_block(self, content: Any) -> Any:
331
+ if isinstance(content, list):
332
+ blocks = []
333
+ for part in content:
334
+ if isinstance(part, dict):
335
+ blocks.append(part)
336
+ elif isinstance(part, str):
337
+ blocks.append({"type": "text", "text": part})
338
+ return blocks
339
+ if isinstance(content, dict):
340
+ return content
341
+ if isinstance(content, str):
342
+ return {"type": "text", "text": content}
343
+ return None
344
+
345
+ def _convert_anthropic_to_openai_response(
346
+ self, response: httpx.Response
347
+ ) -> dict[str, Any] | None:
348
+ try:
349
+ payload = json.loads(response.content.decode()) if response.content else {}
350
+ except json.JSONDecodeError:
351
+ return None
352
+
353
+ if not isinstance(payload, dict):
354
+ return None
355
+
356
+ message = {
357
+ "role": "assistant",
358
+ "content": "",
359
+ }
360
+
361
+ content_blocks = payload.get("content")
362
+ if isinstance(content_blocks, list):
363
+ texts = [
364
+ block.get("text", "")
365
+ for block in content_blocks
366
+ if isinstance(block, dict) and block.get("type") == "text"
367
+ ]
368
+ message["content"] = "".join(texts)
369
+ elif isinstance(content_blocks, str):
370
+ message["content"] = content_blocks
371
+
372
+ finish_reason = payload.get("stop_reason") or payload.get("stop_sequence")
373
+ finish_reason_map = {
374
+ "end_turn": "stop",
375
+ "stop_sequence": "stop",
376
+ "max_tokens": "length",
377
+ }
378
+ if isinstance(finish_reason, str):
379
+ mapped_finish_reason = finish_reason_map.get(finish_reason, "stop")
380
+ else:
381
+ mapped_finish_reason = "stop"
382
+
383
+ usage = payload.get("usage")
384
+ usage_converted = None
385
+ if isinstance(usage, dict):
386
+ prompt_tokens = int(usage.get("input_tokens") or 0)
387
+ completion_tokens = int(usage.get("output_tokens") or 0)
388
+ total_tokens = int(
389
+ usage.get("total_tokens") or prompt_tokens + completion_tokens
390
+ )
391
+ usage_converted = {
392
+ "prompt_tokens": prompt_tokens,
393
+ "completion_tokens": completion_tokens,
394
+ "total_tokens": total_tokens,
395
+ }
396
+
397
+ model_value = payload.get("model")
398
+ if not isinstance(model_value, str) or not model_value:
399
+ mappings = getattr(self.config, "model_mappings", None) or []
400
+ if mappings:
401
+ model_value = mappings[0].target
402
+ else:
403
+ model_value = ""
404
+
405
+ converted = {
406
+ "id": payload.get("id") or f"chatcmpl-{uuid.uuid4().hex}",
407
+ "object": "chat.completion",
408
+ "created": int(time.time()),
409
+ "model": model_value,
410
+ "choices": [
411
+ {
412
+ "index": 0,
413
+ "message": message,
414
+ "finish_reason": mapped_finish_reason,
415
+ "logprobs": None,
416
+ }
417
+ ],
418
+ }
419
+
420
+ if usage_converted is not None:
421
+ converted["usage"] = usage_converted
422
+
423
+ return converted
424
+
425
+ # Helper methods (move from transformers)
426
+ def _inject_system_prompt(
427
+ self, body_data: dict[str, Any], system_prompt: Any, mode: str = "full"
428
+ ) -> dict[str, Any]:
429
+ """Inject system prompt from Claude CLI detection.
430
+
431
+ Args:
432
+ body_data: The request body data dict
433
+ system_prompt: System prompt data from detection service
434
+ mode: Injection mode - "full" (all prompts), "minimal" (first prompt only), or "none"
435
+
436
+ Returns:
437
+ Modified body data with system prompt injected
438
+ """
439
+ if not system_prompt:
440
+ return body_data
441
+
442
+ # Get the system field from the system prompt data
443
+ system_field = (
444
+ system_prompt.system_field
445
+ if hasattr(system_prompt, "system_field")
446
+ else system_prompt
447
+ )
448
+
449
+ if not system_field:
450
+ return body_data
451
+
452
+ # Apply injection mode filtering
453
+ if mode == "minimal":
454
+ # Only inject the first system prompt block
455
+ if isinstance(system_field, list) and len(system_field) > 0:
456
+ system_field = [system_field[0]]
457
+ # If it's a string, keep as-is (already minimal)
458
+ elif mode == "none":
459
+ # Should not reach here due to earlier check, but handle gracefully
460
+ return body_data
461
+ # For "full" mode, use system_field as-is
462
+
463
+ # Mark the detected system prompt as injected for preservation
464
+ marked_system = self._mark_injected_system_prompts(system_field)
465
+
466
+ existing_system = body_data.get("system")
467
+
468
+ if existing_system is None:
469
+ # No existing system prompt, inject the marked detected one
470
+ body_data["system"] = marked_system
471
+ else:
472
+ # Request has existing system prompt, prepend the marked detected one
473
+ if isinstance(marked_system, list):
474
+ if isinstance(existing_system, str):
475
+ # Detected is marked list, existing is string
476
+ body_data["system"] = marked_system + [
477
+ {"type": "text", "text": existing_system}
478
+ ]
479
+ elif isinstance(existing_system, list):
480
+ # Both are lists, concatenate (detected first)
481
+ body_data["system"] = marked_system + existing_system
482
+ else:
483
+ # Convert both to list format for consistency
484
+ if isinstance(existing_system, str):
485
+ body_data["system"] = [
486
+ {
487
+ "type": "text",
488
+ "text": str(marked_system),
489
+ "_ccproxy_injected": True,
490
+ },
491
+ {"type": "text", "text": existing_system},
492
+ ]
493
+ elif isinstance(existing_system, list):
494
+ body_data["system"] = [
495
+ {
496
+ "type": "text",
497
+ "text": str(marked_system),
498
+ "_ccproxy_injected": True,
499
+ }
500
+ ] + existing_system
501
+
502
+ return body_data
503
+
504
+ def _mark_injected_system_prompts(self, system_data: Any) -> Any:
505
+ """Mark system prompts as injected by ccproxy for preservation.
506
+
507
+ Args:
508
+ system_data: System prompt data to mark
509
+
510
+ Returns:
511
+ System data with injected blocks marked with _ccproxy_injected metadata
512
+ """
513
+ if isinstance(system_data, str):
514
+ # String format - convert to list with marking
515
+ return [{"type": "text", "text": system_data, "_ccproxy_injected": True}]
516
+ elif isinstance(system_data, list):
517
+ # List format - mark each block as injected
518
+ marked_data = []
519
+ for block in system_data:
520
+ if isinstance(block, dict):
521
+ # Copy block and add marking
522
+ marked_block = block.copy()
523
+ marked_block["_ccproxy_injected"] = True
524
+ marked_data.append(marked_block)
525
+ else:
526
+ # Preserve non-dict blocks as-is
527
+ marked_data.append(block)
528
+ return marked_data
529
+
530
+ return system_data
531
+
532
+ def _remove_metadata_fields(self, data: dict[str, Any]) -> dict[str, Any]:
533
+ """Remove internal ccproxy metadata from request data before sending to API.
534
+
535
+ This method removes:
536
+ - Fields starting with '_' (internal metadata like _ccproxy_injected)
537
+ - Any other internal ccproxy metadata that shouldn't be sent to the API
538
+
539
+ Args:
540
+ data: Request data dictionary
541
+
542
+ Returns:
543
+ Cleaned data dictionary without internal metadata
544
+ """
545
+ import copy
546
+
547
+ # Deep copy to avoid modifying original
548
+ clean_data = copy.deepcopy(data)
549
+
550
+ # Clean system field
551
+ system = clean_data.get("system")
552
+ if isinstance(system, list):
553
+ for block in system:
554
+ if isinstance(block, dict) and "_ccproxy_injected" in block:
555
+ del block["_ccproxy_injected"]
556
+
557
+ # Clean messages
558
+ messages = clean_data.get("messages", [])
559
+ for message in messages:
560
+ content = message.get("content")
561
+ if isinstance(content, list):
562
+ for block in content:
563
+ if isinstance(block, dict) and "_ccproxy_injected" in block:
564
+ del block["_ccproxy_injected"]
565
+
566
+ # Clean tools (though they shouldn't have _ccproxy_injected, but be safe)
567
+ tools = clean_data.get("tools", [])
568
+ for tool in tools:
569
+ if isinstance(tool, dict) and "_ccproxy_injected" in tool:
570
+ del tool["_ccproxy_injected"]
571
+
572
+ return clean_data
573
+
574
+ def _find_cache_control_blocks(
575
+ self, data: dict[str, Any]
576
+ ) -> list[tuple[str, int, int]]:
577
+ """Find all cache_control blocks in the request with their locations.
578
+
579
+ Returns:
580
+ List of tuples (location_type, location_index, block_index) for each cache_control block
581
+ where location_type is 'system', 'message', 'tool', 'tool_use', or 'tool_result'
582
+ """
583
+ blocks = []
584
+
585
+ # Find in system field
586
+ system = data.get("system")
587
+ if isinstance(system, list):
588
+ for i, block in enumerate(system):
589
+ if isinstance(block, dict) and "cache_control" in block:
590
+ blocks.append(("system", 0, i))
591
+
592
+ # Find in messages
593
+ messages = data.get("messages", [])
594
+ for msg_idx, msg in enumerate(messages):
595
+ content = msg.get("content")
596
+ if isinstance(content, list):
597
+ for block_idx, block in enumerate(content):
598
+ if isinstance(block, dict) and "cache_control" in block:
599
+ block_type = block.get("type")
600
+ if block_type == "tool_use":
601
+ blocks.append(("tool_use", msg_idx, block_idx))
602
+ elif block_type == "tool_result":
603
+ blocks.append(("tool_result", msg_idx, block_idx))
604
+ else:
605
+ blocks.append(("message", msg_idx, block_idx))
606
+
607
+ # Find in tools
608
+ tools = data.get("tools", [])
609
+ for tool_idx, tool in enumerate(tools):
610
+ if isinstance(tool, dict) and "cache_control" in tool:
611
+ blocks.append(("tool", tool_idx, 0))
612
+
613
+ return blocks
614
+
615
+ def _calculate_content_size(self, data: dict[str, Any]) -> int:
616
+ """Calculate the approximate content size of a block for cache prioritization.
617
+
618
+ Args:
619
+ data: Block data dictionary
620
+
621
+ Returns:
622
+ Approximate size in characters
623
+ """
624
+ size = 0
625
+
626
+ # Count text content
627
+ if "text" in data:
628
+ size += len(str(data["text"]))
629
+
630
+ # Count tool use content
631
+ if "name" in data: # Tool use block
632
+ size += len(str(data["name"]))
633
+ if "input" in data:
634
+ size += len(str(data["input"]))
635
+
636
+ # Count tool result content
637
+ if "content" in data and isinstance(data["content"], str | list):
638
+ if isinstance(data["content"], str):
639
+ size += len(data["content"])
640
+ else:
641
+ # Nested content - recursively calculate
642
+ for sub_item in data["content"]:
643
+ if isinstance(sub_item, dict):
644
+ size += self._calculate_content_size(sub_item)
645
+ else:
646
+ size += len(str(sub_item))
647
+
648
+ # Count other string fields
649
+ for key, value in data.items():
650
+ if key not in (
651
+ "text",
652
+ "name",
653
+ "input",
654
+ "content",
655
+ "cache_control",
656
+ "_ccproxy_injected",
657
+ "type",
658
+ ):
659
+ size += len(str(value))
660
+
661
+ return size
662
+
663
+ def _get_block_at_location(
664
+ self,
665
+ data: dict[str, Any],
666
+ location_type: str,
667
+ location_index: int,
668
+ block_index: int,
669
+ ) -> dict[str, Any] | None:
670
+ """Get the block at a specific location in the data structure.
671
+
672
+ Returns:
673
+ Block dictionary or None if not found
674
+ """
675
+ if location_type == "system":
676
+ system = data.get("system")
677
+ if isinstance(system, list) and block_index < len(system):
678
+ block = system[block_index]
679
+ return block if isinstance(block, dict) else None
680
+ elif location_type in ("message", "tool_use", "tool_result"):
681
+ messages = data.get("messages", [])
682
+ if location_index < len(messages):
683
+ content = messages[location_index].get("content")
684
+ if isinstance(content, list) and block_index < len(content):
685
+ block = content[block_index]
686
+ return block if isinstance(block, dict) else None
687
+ elif location_type == "tool":
688
+ tools = data.get("tools", [])
689
+ if location_index < len(tools):
690
+ tool = tools[location_index]
691
+ return tool if isinstance(tool, dict) else None
692
+
693
+ return None
694
+
695
+ def _remove_cache_control_at_location(
696
+ self,
697
+ data: dict[str, Any],
698
+ location_type: str,
699
+ location_index: int,
700
+ block_index: int,
701
+ ) -> bool:
702
+ """Remove cache_control from a block at a specific location.
703
+
704
+ Returns:
705
+ True if cache_control was successfully removed, False otherwise
706
+ """
707
+ block = self._get_block_at_location(
708
+ data, location_type, location_index, block_index
709
+ )
710
+ if block and isinstance(block, dict) and "cache_control" in block:
711
+ del block["cache_control"]
712
+ return True
713
+ return False
714
+
715
+ def _limit_cache_control_blocks(
716
+ self, data: dict[str, Any], max_blocks: int = 4
717
+ ) -> dict[str, Any]:
718
+ """Limit the number of cache_control blocks using smart algorithm.
719
+
720
+ Smart algorithm:
721
+ 1. Preserve all injected system prompts (marked with _ccproxy_injected)
722
+ 2. Keep the 2 largest remaining blocks by content size
723
+ 3. Remove cache_control from smaller blocks when exceeding the limit
724
+
725
+ Args:
726
+ data: Request data dictionary
727
+ max_blocks: Maximum number of cache_control blocks allowed (default: 4)
728
+
729
+ Returns:
730
+ Modified data dictionary with cache_control blocks limited
731
+ """
732
+ import copy
733
+
734
+ # Deep copy to avoid modifying original
735
+ data = copy.deepcopy(data)
736
+
737
+ # Find all cache_control blocks
738
+ cache_blocks = self._find_cache_control_blocks(data)
739
+ total_blocks = len(cache_blocks)
740
+
741
+ if total_blocks <= max_blocks:
742
+ # No need to remove anything
743
+ return data
744
+
745
+ logger.warning(
746
+ "cache_control_limit_exceeded",
747
+ total_blocks=total_blocks,
748
+ max_blocks=max_blocks,
749
+ category="transform",
750
+ )
751
+
752
+ # Classify blocks as injected vs non-injected and calculate sizes
753
+ injected_blocks = []
754
+ non_injected_blocks = []
755
+
756
+ for location in cache_blocks:
757
+ location_type, location_index, block_index = location
758
+ block = self._get_block_at_location(
759
+ data, location_type, location_index, block_index
760
+ )
761
+
762
+ if block and isinstance(block, dict):
763
+ if block.get("_ccproxy_injected", False):
764
+ injected_blocks.append(location)
765
+ logger.debug(
766
+ "found_injected_block",
767
+ location_type=location_type,
768
+ location_index=location_index,
769
+ block_index=block_index,
770
+ category="transform",
771
+ )
772
+ else:
773
+ # Calculate content size for prioritization
774
+ content_size = self._calculate_content_size(block)
775
+ non_injected_blocks.append((location, content_size))
776
+
777
+ # Sort non-injected blocks by size (largest first)
778
+ non_injected_blocks.sort(key=lambda x: x[1], reverse=True)
779
+
780
+ # Determine how many non-injected blocks we can keep
781
+ injected_count = len(injected_blocks)
782
+ remaining_slots = max_blocks - injected_count
783
+
784
+ logger.info(
785
+ "cache_control_smart_limiting",
786
+ total_blocks=total_blocks,
787
+ injected_blocks=injected_count,
788
+ non_injected_blocks=len(non_injected_blocks),
789
+ remaining_slots=remaining_slots,
790
+ max_blocks=max_blocks,
791
+ category="transform",
792
+ )
793
+
794
+ # Keep the largest non-injected blocks up to remaining slots
795
+ blocks_to_keep = set(injected_blocks) # Always keep injected blocks
796
+ if remaining_slots > 0:
797
+ largest_blocks = non_injected_blocks[:remaining_slots]
798
+ blocks_to_keep.update(location for location, size in largest_blocks)
799
+
800
+ logger.debug(
801
+ "keeping_largest_blocks",
802
+ kept_blocks=[(loc, size) for loc, size in largest_blocks],
803
+ category="transform",
804
+ )
805
+
806
+ # Remove cache_control from blocks not in the keep set
807
+ blocks_to_remove = [loc for loc in cache_blocks if loc not in blocks_to_keep]
808
+
809
+ for location_type, location_index, block_index in blocks_to_remove:
810
+ if self._remove_cache_control_at_location(
811
+ data, location_type, location_index, block_index
812
+ ):
813
+ logger.debug(
814
+ "removed_cache_control_smart",
815
+ location=location_type,
816
+ location_index=location_index,
817
+ block_index=block_index,
818
+ category="transform",
819
+ )
820
+
821
+ logger.info(
822
+ "cache_control_limiting_complete",
823
+ blocks_removed=len(blocks_to_remove),
824
+ blocks_kept=len(blocks_to_keep),
825
+ injected_preserved=injected_count,
826
+ category="transform",
827
+ )
828
+
829
+ return data