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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (481) hide show
  1. ccproxy/api/__init__.py +1 -15
  2. ccproxy/api/app.py +434 -219
  3. ccproxy/api/bootstrap.py +30 -0
  4. ccproxy/api/decorators.py +85 -0
  5. ccproxy/api/dependencies.py +144 -168
  6. ccproxy/api/format_validation.py +54 -0
  7. ccproxy/api/middleware/cors.py +6 -3
  8. ccproxy/api/middleware/errors.py +388 -524
  9. ccproxy/api/middleware/hooks.py +563 -0
  10. ccproxy/api/middleware/normalize_headers.py +59 -0
  11. ccproxy/api/middleware/request_id.py +35 -16
  12. ccproxy/api/middleware/streaming_hooks.py +292 -0
  13. ccproxy/api/routes/__init__.py +5 -14
  14. ccproxy/api/routes/health.py +39 -672
  15. ccproxy/api/routes/plugins.py +277 -0
  16. ccproxy/auth/__init__.py +2 -19
  17. ccproxy/auth/bearer.py +25 -15
  18. ccproxy/auth/dependencies.py +123 -157
  19. ccproxy/auth/exceptions.py +0 -12
  20. ccproxy/auth/manager.py +35 -49
  21. ccproxy/auth/managers/__init__.py +10 -0
  22. ccproxy/auth/managers/base.py +523 -0
  23. ccproxy/auth/managers/base_enhanced.py +63 -0
  24. ccproxy/auth/managers/token_snapshot.py +77 -0
  25. ccproxy/auth/models/base.py +65 -0
  26. ccproxy/auth/models/credentials.py +40 -0
  27. ccproxy/auth/oauth/__init__.py +4 -18
  28. ccproxy/auth/oauth/base.py +533 -0
  29. ccproxy/auth/oauth/cli_errors.py +37 -0
  30. ccproxy/auth/oauth/flows.py +430 -0
  31. ccproxy/auth/oauth/protocol.py +366 -0
  32. ccproxy/auth/oauth/registry.py +408 -0
  33. ccproxy/auth/oauth/router.py +396 -0
  34. ccproxy/auth/oauth/routes.py +186 -113
  35. ccproxy/auth/oauth/session.py +151 -0
  36. ccproxy/auth/oauth/templates.py +342 -0
  37. ccproxy/auth/storage/__init__.py +2 -5
  38. ccproxy/auth/storage/base.py +279 -5
  39. ccproxy/auth/storage/generic.py +134 -0
  40. ccproxy/cli/__init__.py +1 -2
  41. ccproxy/cli/_settings_help.py +351 -0
  42. ccproxy/cli/commands/auth.py +1519 -793
  43. ccproxy/cli/commands/config/commands.py +209 -276
  44. ccproxy/cli/commands/plugins.py +669 -0
  45. ccproxy/cli/commands/serve.py +75 -810
  46. ccproxy/cli/commands/status.py +254 -0
  47. ccproxy/cli/decorators.py +83 -0
  48. ccproxy/cli/helpers.py +22 -60
  49. ccproxy/cli/main.py +359 -10
  50. ccproxy/cli/options/claude_options.py +0 -25
  51. ccproxy/config/__init__.py +7 -11
  52. ccproxy/config/core.py +227 -0
  53. ccproxy/config/env_generator.py +232 -0
  54. ccproxy/config/runtime.py +67 -0
  55. ccproxy/config/security.py +36 -3
  56. ccproxy/config/settings.py +382 -441
  57. ccproxy/config/toml_generator.py +299 -0
  58. ccproxy/config/utils.py +452 -0
  59. ccproxy/core/__init__.py +7 -271
  60. ccproxy/{_version.py → core/_version.py} +16 -3
  61. ccproxy/core/async_task_manager.py +516 -0
  62. ccproxy/core/async_utils.py +47 -14
  63. ccproxy/core/auth/__init__.py +6 -0
  64. ccproxy/core/constants.py +16 -50
  65. ccproxy/core/errors.py +53 -0
  66. ccproxy/core/id_utils.py +20 -0
  67. ccproxy/core/interfaces.py +16 -123
  68. ccproxy/core/logging.py +473 -18
  69. ccproxy/core/plugins/__init__.py +77 -0
  70. ccproxy/core/plugins/cli_discovery.py +211 -0
  71. ccproxy/core/plugins/declaration.py +455 -0
  72. ccproxy/core/plugins/discovery.py +604 -0
  73. ccproxy/core/plugins/factories.py +967 -0
  74. ccproxy/core/plugins/hooks/__init__.py +30 -0
  75. ccproxy/core/plugins/hooks/base.py +58 -0
  76. ccproxy/core/plugins/hooks/events.py +46 -0
  77. ccproxy/core/plugins/hooks/implementations/__init__.py +16 -0
  78. ccproxy/core/plugins/hooks/implementations/formatters/__init__.py +11 -0
  79. ccproxy/core/plugins/hooks/implementations/formatters/json.py +552 -0
  80. ccproxy/core/plugins/hooks/implementations/formatters/raw.py +370 -0
  81. ccproxy/core/plugins/hooks/implementations/http_tracer.py +431 -0
  82. ccproxy/core/plugins/hooks/layers.py +44 -0
  83. ccproxy/core/plugins/hooks/manager.py +186 -0
  84. ccproxy/core/plugins/hooks/registry.py +139 -0
  85. ccproxy/core/plugins/hooks/thread_manager.py +203 -0
  86. ccproxy/core/plugins/hooks/types.py +22 -0
  87. ccproxy/core/plugins/interfaces.py +416 -0
  88. ccproxy/core/plugins/loader.py +166 -0
  89. ccproxy/core/plugins/middleware.py +233 -0
  90. ccproxy/core/plugins/models.py +59 -0
  91. ccproxy/core/plugins/protocol.py +180 -0
  92. ccproxy/core/plugins/runtime.py +519 -0
  93. ccproxy/{observability/context.py → core/request_context.py} +137 -94
  94. ccproxy/core/status_report.py +211 -0
  95. ccproxy/core/transformers.py +13 -8
  96. ccproxy/data/claude_headers_fallback.json +540 -19
  97. ccproxy/data/codex_headers_fallback.json +114 -7
  98. ccproxy/http/__init__.py +30 -0
  99. ccproxy/http/base.py +95 -0
  100. ccproxy/http/client.py +323 -0
  101. ccproxy/http/hooks.py +642 -0
  102. ccproxy/http/pool.py +279 -0
  103. ccproxy/llms/formatters/__init__.py +7 -0
  104. ccproxy/llms/formatters/anthropic_to_openai/__init__.py +55 -0
  105. ccproxy/llms/formatters/anthropic_to_openai/errors.py +65 -0
  106. ccproxy/llms/formatters/anthropic_to_openai/requests.py +356 -0
  107. ccproxy/llms/formatters/anthropic_to_openai/responses.py +153 -0
  108. ccproxy/llms/formatters/anthropic_to_openai/streams.py +1546 -0
  109. ccproxy/llms/formatters/base.py +140 -0
  110. ccproxy/llms/formatters/base_model.py +33 -0
  111. ccproxy/llms/formatters/common/__init__.py +51 -0
  112. ccproxy/llms/formatters/common/identifiers.py +48 -0
  113. ccproxy/llms/formatters/common/streams.py +254 -0
  114. ccproxy/llms/formatters/common/thinking.py +74 -0
  115. ccproxy/llms/formatters/common/usage.py +135 -0
  116. ccproxy/llms/formatters/constants.py +55 -0
  117. ccproxy/llms/formatters/context.py +116 -0
  118. ccproxy/llms/formatters/mapping.py +33 -0
  119. ccproxy/llms/formatters/openai_to_anthropic/__init__.py +55 -0
  120. ccproxy/llms/formatters/openai_to_anthropic/_helpers.py +141 -0
  121. ccproxy/llms/formatters/openai_to_anthropic/errors.py +53 -0
  122. ccproxy/llms/formatters/openai_to_anthropic/requests.py +674 -0
  123. ccproxy/llms/formatters/openai_to_anthropic/responses.py +285 -0
  124. ccproxy/llms/formatters/openai_to_anthropic/streams.py +530 -0
  125. ccproxy/llms/formatters/openai_to_openai/__init__.py +53 -0
  126. ccproxy/llms/formatters/openai_to_openai/_helpers.py +325 -0
  127. ccproxy/llms/formatters/openai_to_openai/errors.py +6 -0
  128. ccproxy/llms/formatters/openai_to_openai/requests.py +388 -0
  129. ccproxy/llms/formatters/openai_to_openai/responses.py +594 -0
  130. ccproxy/llms/formatters/openai_to_openai/streams.py +1832 -0
  131. ccproxy/llms/formatters/utils.py +306 -0
  132. ccproxy/llms/models/__init__.py +9 -0
  133. ccproxy/llms/models/anthropic.py +619 -0
  134. ccproxy/llms/models/openai.py +844 -0
  135. ccproxy/llms/streaming/__init__.py +26 -0
  136. ccproxy/llms/streaming/accumulators.py +1074 -0
  137. ccproxy/llms/streaming/formatters.py +251 -0
  138. ccproxy/{adapters/openai/streaming.py → llms/streaming/processors.py} +193 -240
  139. ccproxy/models/__init__.py +8 -159
  140. ccproxy/models/detection.py +92 -193
  141. ccproxy/models/provider.py +75 -0
  142. ccproxy/plugins/access_log/README.md +32 -0
  143. ccproxy/plugins/access_log/__init__.py +20 -0
  144. ccproxy/plugins/access_log/config.py +33 -0
  145. ccproxy/plugins/access_log/formatter.py +126 -0
  146. ccproxy/plugins/access_log/hook.py +763 -0
  147. ccproxy/plugins/access_log/logger.py +254 -0
  148. ccproxy/plugins/access_log/plugin.py +137 -0
  149. ccproxy/plugins/access_log/writer.py +109 -0
  150. ccproxy/plugins/analytics/README.md +24 -0
  151. ccproxy/plugins/analytics/__init__.py +1 -0
  152. ccproxy/plugins/analytics/config.py +5 -0
  153. ccproxy/plugins/analytics/ingest.py +85 -0
  154. ccproxy/plugins/analytics/models.py +97 -0
  155. ccproxy/plugins/analytics/plugin.py +121 -0
  156. ccproxy/plugins/analytics/routes.py +163 -0
  157. ccproxy/plugins/analytics/service.py +284 -0
  158. ccproxy/plugins/claude_api/README.md +29 -0
  159. ccproxy/plugins/claude_api/__init__.py +10 -0
  160. ccproxy/plugins/claude_api/adapter.py +829 -0
  161. ccproxy/plugins/claude_api/config.py +52 -0
  162. ccproxy/plugins/claude_api/detection_service.py +461 -0
  163. ccproxy/plugins/claude_api/health.py +175 -0
  164. ccproxy/plugins/claude_api/hooks.py +284 -0
  165. ccproxy/plugins/claude_api/models.py +256 -0
  166. ccproxy/plugins/claude_api/plugin.py +298 -0
  167. ccproxy/plugins/claude_api/routes.py +118 -0
  168. ccproxy/plugins/claude_api/streaming_metrics.py +68 -0
  169. ccproxy/plugins/claude_api/tasks.py +84 -0
  170. ccproxy/plugins/claude_sdk/README.md +35 -0
  171. ccproxy/plugins/claude_sdk/__init__.py +80 -0
  172. ccproxy/plugins/claude_sdk/adapter.py +749 -0
  173. ccproxy/plugins/claude_sdk/auth.py +57 -0
  174. ccproxy/{claude_sdk → plugins/claude_sdk}/client.py +63 -39
  175. ccproxy/plugins/claude_sdk/config.py +210 -0
  176. ccproxy/{claude_sdk → plugins/claude_sdk}/converter.py +6 -6
  177. ccproxy/plugins/claude_sdk/detection_service.py +163 -0
  178. ccproxy/{services/claude_sdk_service.py → plugins/claude_sdk/handler.py} +123 -304
  179. ccproxy/plugins/claude_sdk/health.py +113 -0
  180. ccproxy/plugins/claude_sdk/hooks.py +115 -0
  181. ccproxy/{claude_sdk → plugins/claude_sdk}/manager.py +42 -32
  182. ccproxy/{claude_sdk → plugins/claude_sdk}/message_queue.py +8 -8
  183. ccproxy/{models/claude_sdk.py → plugins/claude_sdk/models.py} +64 -16
  184. ccproxy/plugins/claude_sdk/options.py +154 -0
  185. ccproxy/{claude_sdk → plugins/claude_sdk}/parser.py +23 -5
  186. ccproxy/plugins/claude_sdk/plugin.py +269 -0
  187. ccproxy/plugins/claude_sdk/routes.py +104 -0
  188. ccproxy/{claude_sdk → plugins/claude_sdk}/session_client.py +124 -12
  189. ccproxy/plugins/claude_sdk/session_pool.py +700 -0
  190. ccproxy/{claude_sdk → plugins/claude_sdk}/stream_handle.py +48 -43
  191. ccproxy/{claude_sdk → plugins/claude_sdk}/stream_worker.py +22 -18
  192. ccproxy/{claude_sdk → plugins/claude_sdk}/streaming.py +50 -16
  193. ccproxy/plugins/claude_sdk/tasks.py +97 -0
  194. ccproxy/plugins/claude_shared/README.md +18 -0
  195. ccproxy/plugins/claude_shared/__init__.py +12 -0
  196. ccproxy/plugins/claude_shared/model_defaults.py +171 -0
  197. ccproxy/plugins/codex/README.md +35 -0
  198. ccproxy/plugins/codex/__init__.py +6 -0
  199. ccproxy/plugins/codex/adapter.py +635 -0
  200. ccproxy/{config/codex.py → plugins/codex/config.py} +78 -12
  201. ccproxy/plugins/codex/detection_service.py +544 -0
  202. ccproxy/plugins/codex/health.py +162 -0
  203. ccproxy/plugins/codex/hooks.py +263 -0
  204. ccproxy/plugins/codex/model_defaults.py +39 -0
  205. ccproxy/plugins/codex/models.py +263 -0
  206. ccproxy/plugins/codex/plugin.py +275 -0
  207. ccproxy/plugins/codex/routes.py +129 -0
  208. ccproxy/plugins/codex/streaming_metrics.py +324 -0
  209. ccproxy/plugins/codex/tasks.py +106 -0
  210. ccproxy/plugins/codex/utils/__init__.py +1 -0
  211. ccproxy/plugins/codex/utils/sse_parser.py +106 -0
  212. ccproxy/plugins/command_replay/README.md +34 -0
  213. ccproxy/plugins/command_replay/__init__.py +17 -0
  214. ccproxy/plugins/command_replay/config.py +133 -0
  215. ccproxy/plugins/command_replay/formatter.py +432 -0
  216. ccproxy/plugins/command_replay/hook.py +294 -0
  217. ccproxy/plugins/command_replay/plugin.py +161 -0
  218. ccproxy/plugins/copilot/README.md +39 -0
  219. ccproxy/plugins/copilot/__init__.py +11 -0
  220. ccproxy/plugins/copilot/adapter.py +465 -0
  221. ccproxy/plugins/copilot/config.py +155 -0
  222. ccproxy/plugins/copilot/data/copilot_fallback.json +41 -0
  223. ccproxy/plugins/copilot/detection_service.py +255 -0
  224. ccproxy/plugins/copilot/manager.py +275 -0
  225. ccproxy/plugins/copilot/model_defaults.py +284 -0
  226. ccproxy/plugins/copilot/models.py +148 -0
  227. ccproxy/plugins/copilot/oauth/__init__.py +16 -0
  228. ccproxy/plugins/copilot/oauth/client.py +494 -0
  229. ccproxy/plugins/copilot/oauth/models.py +385 -0
  230. ccproxy/plugins/copilot/oauth/provider.py +602 -0
  231. ccproxy/plugins/copilot/oauth/storage.py +170 -0
  232. ccproxy/plugins/copilot/plugin.py +360 -0
  233. ccproxy/plugins/copilot/routes.py +294 -0
  234. ccproxy/plugins/credential_balancer/README.md +124 -0
  235. ccproxy/plugins/credential_balancer/__init__.py +6 -0
  236. ccproxy/plugins/credential_balancer/config.py +270 -0
  237. ccproxy/plugins/credential_balancer/factory.py +415 -0
  238. ccproxy/plugins/credential_balancer/hook.py +51 -0
  239. ccproxy/plugins/credential_balancer/manager.py +587 -0
  240. ccproxy/plugins/credential_balancer/plugin.py +146 -0
  241. ccproxy/plugins/dashboard/README.md +25 -0
  242. ccproxy/plugins/dashboard/__init__.py +1 -0
  243. ccproxy/plugins/dashboard/config.py +8 -0
  244. ccproxy/plugins/dashboard/plugin.py +71 -0
  245. ccproxy/plugins/dashboard/routes.py +67 -0
  246. ccproxy/plugins/docker/README.md +32 -0
  247. ccproxy/{docker → plugins/docker}/__init__.py +3 -0
  248. ccproxy/{docker → plugins/docker}/adapter.py +108 -10
  249. ccproxy/plugins/docker/config.py +82 -0
  250. ccproxy/{docker → plugins/docker}/docker_path.py +4 -3
  251. ccproxy/{docker → plugins/docker}/middleware.py +2 -2
  252. ccproxy/plugins/docker/plugin.py +198 -0
  253. ccproxy/{docker → plugins/docker}/stream_process.py +3 -3
  254. ccproxy/plugins/duckdb_storage/README.md +26 -0
  255. ccproxy/plugins/duckdb_storage/__init__.py +1 -0
  256. ccproxy/plugins/duckdb_storage/config.py +22 -0
  257. ccproxy/plugins/duckdb_storage/plugin.py +128 -0
  258. ccproxy/plugins/duckdb_storage/routes.py +51 -0
  259. ccproxy/plugins/duckdb_storage/storage.py +633 -0
  260. ccproxy/plugins/max_tokens/README.md +38 -0
  261. ccproxy/plugins/max_tokens/__init__.py +12 -0
  262. ccproxy/plugins/max_tokens/adapter.py +235 -0
  263. ccproxy/plugins/max_tokens/config.py +86 -0
  264. ccproxy/plugins/max_tokens/models.py +53 -0
  265. ccproxy/plugins/max_tokens/plugin.py +200 -0
  266. ccproxy/plugins/max_tokens/service.py +271 -0
  267. ccproxy/plugins/max_tokens/token_limits.json +54 -0
  268. ccproxy/plugins/metrics/README.md +35 -0
  269. ccproxy/plugins/metrics/__init__.py +10 -0
  270. ccproxy/{observability/metrics.py → plugins/metrics/collector.py} +20 -153
  271. ccproxy/plugins/metrics/config.py +85 -0
  272. ccproxy/plugins/metrics/grafana/dashboards/ccproxy-dashboard.json +1720 -0
  273. ccproxy/plugins/metrics/hook.py +403 -0
  274. ccproxy/plugins/metrics/plugin.py +268 -0
  275. ccproxy/{observability → plugins/metrics}/pushgateway.py +57 -59
  276. ccproxy/plugins/metrics/routes.py +107 -0
  277. ccproxy/plugins/metrics/tasks.py +117 -0
  278. ccproxy/plugins/oauth_claude/README.md +35 -0
  279. ccproxy/plugins/oauth_claude/__init__.py +14 -0
  280. ccproxy/plugins/oauth_claude/client.py +270 -0
  281. ccproxy/plugins/oauth_claude/config.py +84 -0
  282. ccproxy/plugins/oauth_claude/manager.py +482 -0
  283. ccproxy/plugins/oauth_claude/models.py +266 -0
  284. ccproxy/plugins/oauth_claude/plugin.py +149 -0
  285. ccproxy/plugins/oauth_claude/provider.py +571 -0
  286. ccproxy/plugins/oauth_claude/storage.py +212 -0
  287. ccproxy/plugins/oauth_codex/README.md +38 -0
  288. ccproxy/plugins/oauth_codex/__init__.py +14 -0
  289. ccproxy/plugins/oauth_codex/client.py +224 -0
  290. ccproxy/plugins/oauth_codex/config.py +95 -0
  291. ccproxy/plugins/oauth_codex/manager.py +256 -0
  292. ccproxy/plugins/oauth_codex/models.py +239 -0
  293. ccproxy/plugins/oauth_codex/plugin.py +146 -0
  294. ccproxy/plugins/oauth_codex/provider.py +574 -0
  295. ccproxy/plugins/oauth_codex/storage.py +92 -0
  296. ccproxy/plugins/permissions/README.md +28 -0
  297. ccproxy/plugins/permissions/__init__.py +22 -0
  298. ccproxy/plugins/permissions/config.py +28 -0
  299. ccproxy/{cli/commands/permission_handler.py → plugins/permissions/handlers/cli.py} +49 -25
  300. ccproxy/plugins/permissions/handlers/protocol.py +33 -0
  301. ccproxy/plugins/permissions/handlers/terminal.py +675 -0
  302. ccproxy/{api/routes → plugins/permissions}/mcp.py +34 -7
  303. ccproxy/{models/permissions.py → plugins/permissions/models.py} +65 -1
  304. ccproxy/plugins/permissions/plugin.py +153 -0
  305. ccproxy/{api/routes/permissions.py → plugins/permissions/routes.py} +20 -16
  306. ccproxy/{api/services/permission_service.py → plugins/permissions/service.py} +65 -11
  307. ccproxy/{api → plugins/permissions}/ui/permission_handler_protocol.py +1 -1
  308. ccproxy/{api → plugins/permissions}/ui/terminal_permission_handler.py +66 -10
  309. ccproxy/plugins/pricing/README.md +34 -0
  310. ccproxy/plugins/pricing/__init__.py +6 -0
  311. ccproxy/{pricing → plugins/pricing}/cache.py +7 -6
  312. ccproxy/{config/pricing.py → plugins/pricing/config.py} +32 -6
  313. ccproxy/plugins/pricing/exceptions.py +35 -0
  314. ccproxy/plugins/pricing/loader.py +440 -0
  315. ccproxy/{pricing → plugins/pricing}/models.py +13 -23
  316. ccproxy/plugins/pricing/plugin.py +169 -0
  317. ccproxy/plugins/pricing/service.py +191 -0
  318. ccproxy/plugins/pricing/tasks.py +300 -0
  319. ccproxy/{pricing → plugins/pricing}/updater.py +86 -72
  320. ccproxy/plugins/pricing/utils.py +99 -0
  321. ccproxy/plugins/request_tracer/README.md +40 -0
  322. ccproxy/plugins/request_tracer/__init__.py +7 -0
  323. ccproxy/plugins/request_tracer/config.py +120 -0
  324. ccproxy/plugins/request_tracer/hook.py +415 -0
  325. ccproxy/plugins/request_tracer/plugin.py +255 -0
  326. ccproxy/scheduler/__init__.py +2 -14
  327. ccproxy/scheduler/core.py +26 -41
  328. ccproxy/scheduler/manager.py +61 -105
  329. ccproxy/scheduler/registry.py +6 -32
  330. ccproxy/scheduler/tasks.py +268 -276
  331. ccproxy/services/__init__.py +0 -1
  332. ccproxy/services/adapters/__init__.py +11 -0
  333. ccproxy/services/adapters/base.py +123 -0
  334. ccproxy/services/adapters/chain_composer.py +88 -0
  335. ccproxy/services/adapters/chain_validation.py +44 -0
  336. ccproxy/services/adapters/chat_accumulator.py +200 -0
  337. ccproxy/services/adapters/delta_utils.py +142 -0
  338. ccproxy/services/adapters/format_adapter.py +136 -0
  339. ccproxy/services/adapters/format_context.py +11 -0
  340. ccproxy/services/adapters/format_registry.py +158 -0
  341. ccproxy/services/adapters/http_adapter.py +1045 -0
  342. ccproxy/services/adapters/mock_adapter.py +118 -0
  343. ccproxy/services/adapters/protocols.py +35 -0
  344. ccproxy/services/adapters/simple_converters.py +571 -0
  345. ccproxy/services/auth_registry.py +180 -0
  346. ccproxy/services/cache/__init__.py +6 -0
  347. ccproxy/services/cache/response_cache.py +261 -0
  348. ccproxy/services/cli_detection.py +437 -0
  349. ccproxy/services/config/__init__.py +6 -0
  350. ccproxy/services/config/proxy_configuration.py +111 -0
  351. ccproxy/services/container.py +256 -0
  352. ccproxy/services/factories.py +380 -0
  353. ccproxy/services/handler_config.py +76 -0
  354. ccproxy/services/interfaces.py +298 -0
  355. ccproxy/services/mocking/__init__.py +6 -0
  356. ccproxy/services/mocking/mock_handler.py +291 -0
  357. ccproxy/services/tracing/__init__.py +7 -0
  358. ccproxy/services/tracing/interfaces.py +61 -0
  359. ccproxy/services/tracing/null_tracer.py +57 -0
  360. ccproxy/streaming/__init__.py +23 -0
  361. ccproxy/streaming/buffer.py +1056 -0
  362. ccproxy/streaming/deferred.py +897 -0
  363. ccproxy/streaming/handler.py +117 -0
  364. ccproxy/streaming/interfaces.py +77 -0
  365. ccproxy/streaming/simple_adapter.py +39 -0
  366. ccproxy/streaming/sse.py +109 -0
  367. ccproxy/streaming/sse_parser.py +127 -0
  368. ccproxy/templates/__init__.py +6 -0
  369. ccproxy/templates/plugin_scaffold.py +695 -0
  370. ccproxy/testing/endpoints/__init__.py +33 -0
  371. ccproxy/testing/endpoints/cli.py +215 -0
  372. ccproxy/testing/endpoints/config.py +874 -0
  373. ccproxy/testing/endpoints/console.py +57 -0
  374. ccproxy/testing/endpoints/models.py +100 -0
  375. ccproxy/testing/endpoints/runner.py +1903 -0
  376. ccproxy/testing/endpoints/tools.py +308 -0
  377. ccproxy/testing/mock_responses.py +70 -1
  378. ccproxy/testing/response_handlers.py +20 -0
  379. ccproxy/utils/__init__.py +0 -6
  380. ccproxy/utils/binary_resolver.py +476 -0
  381. ccproxy/utils/caching.py +327 -0
  382. ccproxy/utils/cli_logging.py +101 -0
  383. ccproxy/utils/command_line.py +251 -0
  384. ccproxy/utils/headers.py +228 -0
  385. ccproxy/utils/model_mapper.py +120 -0
  386. ccproxy/utils/startup_helpers.py +68 -446
  387. ccproxy/utils/version_checker.py +273 -6
  388. ccproxy_api-0.2.0a4.dist-info/METADATA +212 -0
  389. ccproxy_api-0.2.0a4.dist-info/RECORD +417 -0
  390. {ccproxy_api-0.1.7.dist-info → ccproxy_api-0.2.0a4.dist-info}/WHEEL +1 -1
  391. ccproxy_api-0.2.0a4.dist-info/entry_points.txt +24 -0
  392. ccproxy/__init__.py +0 -4
  393. ccproxy/adapters/__init__.py +0 -11
  394. ccproxy/adapters/base.py +0 -80
  395. ccproxy/adapters/codex/__init__.py +0 -11
  396. ccproxy/adapters/openai/__init__.py +0 -42
  397. ccproxy/adapters/openai/adapter.py +0 -953
  398. ccproxy/adapters/openai/models.py +0 -412
  399. ccproxy/adapters/openai/response_adapter.py +0 -355
  400. ccproxy/adapters/openai/response_models.py +0 -178
  401. ccproxy/api/middleware/headers.py +0 -49
  402. ccproxy/api/middleware/logging.py +0 -180
  403. ccproxy/api/middleware/request_content_logging.py +0 -297
  404. ccproxy/api/middleware/server_header.py +0 -58
  405. ccproxy/api/responses.py +0 -89
  406. ccproxy/api/routes/claude.py +0 -371
  407. ccproxy/api/routes/codex.py +0 -1251
  408. ccproxy/api/routes/metrics.py +0 -1029
  409. ccproxy/api/routes/proxy.py +0 -211
  410. ccproxy/api/services/__init__.py +0 -6
  411. ccproxy/auth/conditional.py +0 -84
  412. ccproxy/auth/credentials_adapter.py +0 -93
  413. ccproxy/auth/models.py +0 -118
  414. ccproxy/auth/oauth/models.py +0 -48
  415. ccproxy/auth/openai/__init__.py +0 -13
  416. ccproxy/auth/openai/credentials.py +0 -166
  417. ccproxy/auth/openai/oauth_client.py +0 -334
  418. ccproxy/auth/openai/storage.py +0 -184
  419. ccproxy/auth/storage/json_file.py +0 -158
  420. ccproxy/auth/storage/keyring.py +0 -189
  421. ccproxy/claude_sdk/__init__.py +0 -18
  422. ccproxy/claude_sdk/options.py +0 -194
  423. ccproxy/claude_sdk/session_pool.py +0 -550
  424. ccproxy/cli/docker/__init__.py +0 -34
  425. ccproxy/cli/docker/adapter_factory.py +0 -157
  426. ccproxy/cli/docker/params.py +0 -274
  427. ccproxy/config/auth.py +0 -153
  428. ccproxy/config/claude.py +0 -348
  429. ccproxy/config/cors.py +0 -79
  430. ccproxy/config/discovery.py +0 -95
  431. ccproxy/config/docker_settings.py +0 -264
  432. ccproxy/config/observability.py +0 -158
  433. ccproxy/config/reverse_proxy.py +0 -31
  434. ccproxy/config/scheduler.py +0 -108
  435. ccproxy/config/server.py +0 -86
  436. ccproxy/config/validators.py +0 -231
  437. ccproxy/core/codex_transformers.py +0 -389
  438. ccproxy/core/http.py +0 -328
  439. ccproxy/core/http_transformers.py +0 -812
  440. ccproxy/core/proxy.py +0 -143
  441. ccproxy/core/validators.py +0 -288
  442. ccproxy/models/errors.py +0 -42
  443. ccproxy/models/messages.py +0 -269
  444. ccproxy/models/requests.py +0 -107
  445. ccproxy/models/responses.py +0 -270
  446. ccproxy/models/types.py +0 -102
  447. ccproxy/observability/__init__.py +0 -51
  448. ccproxy/observability/access_logger.py +0 -457
  449. ccproxy/observability/sse_events.py +0 -303
  450. ccproxy/observability/stats_printer.py +0 -753
  451. ccproxy/observability/storage/__init__.py +0 -1
  452. ccproxy/observability/storage/duckdb_simple.py +0 -677
  453. ccproxy/observability/storage/models.py +0 -70
  454. ccproxy/observability/streaming_response.py +0 -107
  455. ccproxy/pricing/__init__.py +0 -19
  456. ccproxy/pricing/loader.py +0 -251
  457. ccproxy/services/claude_detection_service.py +0 -243
  458. ccproxy/services/codex_detection_service.py +0 -252
  459. ccproxy/services/credentials/__init__.py +0 -55
  460. ccproxy/services/credentials/config.py +0 -105
  461. ccproxy/services/credentials/manager.py +0 -561
  462. ccproxy/services/credentials/oauth_client.py +0 -481
  463. ccproxy/services/proxy_service.py +0 -1827
  464. ccproxy/static/.keep +0 -0
  465. ccproxy/utils/cost_calculator.py +0 -210
  466. ccproxy/utils/disconnection_monitor.py +0 -83
  467. ccproxy/utils/model_mapping.py +0 -199
  468. ccproxy/utils/models_provider.py +0 -150
  469. ccproxy/utils/simple_request_logger.py +0 -284
  470. ccproxy/utils/streaming_metrics.py +0 -199
  471. ccproxy_api-0.1.7.dist-info/METADATA +0 -615
  472. ccproxy_api-0.1.7.dist-info/RECORD +0 -191
  473. ccproxy_api-0.1.7.dist-info/entry_points.txt +0 -4
  474. /ccproxy/{api/middleware/auth.py → auth/models/__init__.py} +0 -0
  475. /ccproxy/{claude_sdk → plugins/claude_sdk}/exceptions.py +0 -0
  476. /ccproxy/{docker → plugins/docker}/models.py +0 -0
  477. /ccproxy/{docker → plugins/docker}/protocol.py +0 -0
  478. /ccproxy/{docker → plugins/docker}/validators.py +0 -0
  479. /ccproxy/{auth/oauth/storage.py → plugins/permissions/handlers/__init__.py} +0 -0
  480. /ccproxy/{api → plugins/permissions}/ui/__init__.py +0 -0
  481. {ccproxy_api-0.1.7.dist-info → ccproxy_api-0.2.0a4.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,563 @@
1
+ """Hooks middleware for request lifecycle management."""
2
+
3
+ import time
4
+ from datetime import datetime
5
+ from typing import Any, cast
6
+
7
+ from fastapi import Request, Response
8
+ from starlette.middleware.base import BaseHTTPMiddleware
9
+ from starlette.responses import StreamingResponse
10
+
11
+ from ccproxy.api.middleware.streaming_hooks import StreamingResponseWithHooks
12
+ from ccproxy.core.logging import TraceBoundLogger, get_logger
13
+ from ccproxy.core.plugins.hooks import HookEvent, HookManager
14
+ from ccproxy.core.plugins.hooks.base import HookContext
15
+ from ccproxy.utils.headers import (
16
+ extract_request_headers,
17
+ extract_response_headers,
18
+ )
19
+
20
+
21
+ logger: TraceBoundLogger = get_logger()
22
+
23
+ MAX_BODY_LOG_CHARS = 2048
24
+
25
+
26
+ def _stringify_raw_body(body: bytes | None) -> tuple[str | None, int, bool]:
27
+ """Convert raw body bytes into a logging-friendly preview."""
28
+
29
+ if not body:
30
+ return None, 0, False
31
+
32
+ text = body.decode("utf-8", errors="replace")
33
+ length = len(text)
34
+ truncated = length > MAX_BODY_LOG_CHARS
35
+ preview = f"{text[:MAX_BODY_LOG_CHARS]}...[truncated]" if truncated else text
36
+ return preview, length, truncated
37
+
38
+
39
+ class HooksMiddleware(BaseHTTPMiddleware):
40
+ """Middleware that emits hook lifecycle events for requests.
41
+
42
+ This middleware wraps the entire request-response cycle and emits:
43
+ - REQUEST_STARTED before processing request
44
+ - REQUEST_COMPLETED on successful response
45
+ - REQUEST_FAILED on error
46
+
47
+ It maintains RequestContext compatibility and provides centralized
48
+ hook emission for both regular and streaming responses.
49
+ """
50
+
51
+ def __init__(self, app: Any, hook_manager: HookManager | None = None) -> None:
52
+ """Initialize the hooks middleware.
53
+
54
+ Args:
55
+ app: ASGI application
56
+ hook_manager: Hook manager for emitting events
57
+ """
58
+ super().__init__(app)
59
+ self.hook_manager = hook_manager
60
+
61
+ async def dispatch(self, request: Request, call_next: Any) -> Response:
62
+ """Dispatch the request with hook emission.
63
+
64
+ Args:
65
+ request: The incoming request
66
+ call_next: The next middleware/handler in the chain
67
+
68
+ Returns:
69
+ The response from downstream handlers
70
+ """
71
+ # Get hook manager from app state if not set during init
72
+ hook_manager = self.hook_manager
73
+ if not hook_manager and hasattr(request.app.state, "hook_manager"):
74
+ hook_manager = request.app.state.hook_manager
75
+
76
+ # Skip hook emission if no hook manager available
77
+ if not hook_manager:
78
+ return cast(Response, await call_next(request))
79
+
80
+ # Extract request_id from ASGI scope extensions
81
+ request_id = getattr(request.state, "request_id", None)
82
+ if not request_id:
83
+ # Fallback to headers or generate one
84
+ request_id = request.headers.get(
85
+ "X-Request-ID", f"req-{int(time.time() * 1000)}"
86
+ )
87
+
88
+ # Get or create RequestContext
89
+ from ccproxy.core.request_context import RequestContext
90
+
91
+ request_context = RequestContext.get_current()
92
+ if not request_context:
93
+ # Create minimal context if none exists
94
+ start_time_perf = time.perf_counter()
95
+ request_context = RequestContext(
96
+ request_id=request_id,
97
+ start_time=start_time_perf,
98
+ logger=logger,
99
+ )
100
+
101
+ # Wall-clock time for human-readable timestamps
102
+ start_time = time.time()
103
+
104
+ # Create hook context for the request
105
+ logger.debug("headers_on_request_start", headers=dict(request.headers))
106
+ hook_context = HookContext(
107
+ event=HookEvent.REQUEST_STARTED, # Will be overridden in emit calls
108
+ timestamp=datetime.fromtimestamp(start_time),
109
+ data={
110
+ "request_id": request_id,
111
+ "method": request.method,
112
+ "url": str(request.url),
113
+ # Extract headers using utility function
114
+ "headers": extract_request_headers(request),
115
+ },
116
+ metadata=getattr(request_context, "metadata", {}),
117
+ request=request,
118
+ )
119
+
120
+ try:
121
+ # Emit REQUEST_STARTED before processing
122
+ await hook_manager.emit_with_context(hook_context)
123
+
124
+ # Capture and emit HTTP_REQUEST hook with body
125
+ (
126
+ body_preview,
127
+ body_size,
128
+ body_truncated,
129
+ body_is_json,
130
+ ) = await self._emit_http_request_hook(hook_manager, request, hook_context)
131
+
132
+ accept_header = request.headers.get("accept", "").lower()
133
+ if "text/event-stream" not in accept_header:
134
+ logger.info(
135
+ "request_started",
136
+ request_id=request_id,
137
+ method=request.method,
138
+ url=str(request.url),
139
+ has_body=body_preview is not None,
140
+ body_size=body_size,
141
+ body_truncated=body_truncated,
142
+ is_json=body_is_json,
143
+ origin="client",
144
+ streaming=False,
145
+ category="http",
146
+ )
147
+
148
+ # Process the request
149
+ response = cast(Response, await call_next(request))
150
+
151
+ # Update hook context with response information
152
+ end_time = time.time()
153
+ response_hook_context = HookContext(
154
+ event=HookEvent.REQUEST_COMPLETED, # Will be overridden in emit calls
155
+ timestamp=datetime.fromtimestamp(start_time),
156
+ data={
157
+ "request_id": request_id,
158
+ "method": request.method,
159
+ "url": str(request.url),
160
+ "headers": extract_request_headers(request),
161
+ "response_status": getattr(response, "status_code", 200),
162
+ # Response headers preserved via extract_response_headers
163
+ "response_headers": extract_response_headers(response),
164
+ "duration": end_time - start_time,
165
+ },
166
+ metadata=getattr(request_context, "metadata", {}),
167
+ request=request,
168
+ response=response,
169
+ )
170
+
171
+ # Handle streaming responses specially
172
+ # Check if it's a streaming response (including middleware wrapped streaming responses)
173
+ is_streaming = (
174
+ isinstance(response, StreamingResponse)
175
+ or type(response).__name__ == "_StreamingResponse"
176
+ )
177
+ logger.debug(
178
+ "hooks_middleware_checking_response_type",
179
+ response_type=type(response).__name__,
180
+ response_class=str(type(response)),
181
+ is_streaming=is_streaming,
182
+ request_id=request_id,
183
+ )
184
+ if is_streaming:
185
+ # For streaming responses, wrap with hook emission on completion
186
+ # Don't emit REQUEST_COMPLETED here - it will be emitted when streaming actually completes
187
+
188
+ logger.debug(
189
+ "hooks_middleware_wrapping_streaming_response",
190
+ request_id=request_id,
191
+ method=request.method,
192
+ url=str(request.url),
193
+ status_code=getattr(response, "status_code", 200),
194
+ duration=end_time - start_time,
195
+ response_type="streaming",
196
+ category="hooks",
197
+ )
198
+
199
+ # Wrap the streaming response to emit hooks on completion
200
+ request_data = {
201
+ "method": request.method,
202
+ "url": str(request.url),
203
+ "headers": extract_request_headers(request),
204
+ }
205
+
206
+ # Include RequestContext metadata if available
207
+ request_metadata: dict[str, Any] = {}
208
+ if request_context:
209
+ request_metadata = getattr(request_context, "metadata", {})
210
+
211
+ response_stream = cast(StreamingResponse, response)
212
+ is_sse = self._is_sse_response(response_stream)
213
+
214
+ if is_sse:
215
+ logger.info(
216
+ "sse_connection_started",
217
+ request_id=request_id,
218
+ method=request.method,
219
+ url=str(request.url),
220
+ origin="client",
221
+ streaming=True,
222
+ has_body=body_preview is not None,
223
+ body_size=body_size,
224
+ body_truncated=body_truncated,
225
+ is_json=body_is_json,
226
+ category="http",
227
+ )
228
+
229
+ # Coerce body iterator to AsyncGenerator[bytes]
230
+ async def _coerce_bytes() -> Any:
231
+ async for chunk in response_stream.body_iterator:
232
+ if isinstance(chunk, bytes):
233
+ yield chunk
234
+ elif isinstance(chunk, memoryview):
235
+ yield bytes(chunk)
236
+ else:
237
+ yield str(chunk).encode("utf-8", errors="replace")
238
+
239
+ wrapped_response = StreamingResponseWithHooks(
240
+ content=_coerce_bytes(),
241
+ hook_manager=hook_manager,
242
+ request_id=request_id,
243
+ request_data=request_data,
244
+ request_metadata=request_metadata,
245
+ start_time=start_time,
246
+ status_code=response_stream.status_code,
247
+ origin="client",
248
+ is_sse=is_sse,
249
+ headers=dict(response_stream.headers),
250
+ media_type=response_stream.media_type,
251
+ )
252
+
253
+ return wrapped_response
254
+ else:
255
+ # For regular responses, emit HTTP_RESPONSE and REQUEST_COMPLETED
256
+ await self._emit_http_response_hook(
257
+ hook_manager, request, response, hook_context
258
+ )
259
+ await hook_manager.emit_with_context(response_hook_context)
260
+
261
+ duration_ms = round((end_time - start_time) * 1000, 3)
262
+ logger.info(
263
+ "request_completed",
264
+ request_id=request_id,
265
+ method=request.method,
266
+ url=str(request.url),
267
+ status_code=getattr(response, "status_code", 200),
268
+ duration_ms=duration_ms,
269
+ origin="client",
270
+ streaming=False,
271
+ success=True,
272
+ category="http",
273
+ )
274
+
275
+ logger.debug(
276
+ "hooks_middleware_request_completed",
277
+ request_id=request_id,
278
+ method=request.method,
279
+ url=str(request.url),
280
+ status_code=getattr(response, "status_code", 200),
281
+ duration=end_time - start_time,
282
+ response_type="regular",
283
+ category="hooks",
284
+ )
285
+
286
+ return response
287
+
288
+ except Exception as e:
289
+ # Update hook context with error information
290
+ end_time = time.time()
291
+ error_hook_context = HookContext(
292
+ event=HookEvent.REQUEST_FAILED, # Will be overridden in emit calls
293
+ timestamp=datetime.fromtimestamp(start_time),
294
+ data={
295
+ "request_id": request_id,
296
+ "method": request.method,
297
+ "url": str(request.url),
298
+ "headers": extract_request_headers(request),
299
+ "duration": end_time - start_time,
300
+ },
301
+ metadata=getattr(request_context, "metadata", {}),
302
+ request=request,
303
+ error=e,
304
+ )
305
+
306
+ # Emit REQUEST_FAILED on error
307
+ try:
308
+ await hook_manager.emit_with_context(error_hook_context)
309
+ except Exception as hook_error:
310
+ logger.error(
311
+ "hooks_middleware_hook_emission_failed",
312
+ request_id=request_id,
313
+ original_error=str(e),
314
+ hook_error=str(hook_error),
315
+ category="hooks",
316
+ )
317
+
318
+ logger.debug(
319
+ "hooks_middleware_request_failed",
320
+ request_id=request_id,
321
+ method=request.method,
322
+ url=str(request.url),
323
+ error=str(e),
324
+ duration=end_time - start_time,
325
+ category="hooks",
326
+ )
327
+
328
+ duration_ms = round((end_time - start_time) * 1000, 3)
329
+ status_code = getattr(e, "status_code", None)
330
+ logger.info(
331
+ "request_completed",
332
+ request_id=request_id,
333
+ method=request.method,
334
+ url=str(request.url),
335
+ status_code=status_code,
336
+ duration_ms=duration_ms,
337
+ origin="client",
338
+ streaming=False,
339
+ success=False,
340
+ error_type=type(e).__name__,
341
+ category="http",
342
+ )
343
+
344
+ # Re-raise the original exception
345
+ raise
346
+
347
+ async def _emit_http_request_hook(
348
+ self, hook_manager: HookManager, request: Request, base_context: HookContext
349
+ ) -> tuple[str | None, int, bool, bool]:
350
+ """Emit HTTP_REQUEST hook with request body capture.
351
+
352
+ Args:
353
+ hook_manager: Hook manager for emitting events
354
+ request: FastAPI request object
355
+ base_context: Base hook context for request metadata
356
+ """
357
+ try:
358
+ # Capture request body - this may be empty for GET requests
359
+ request_body = await self._capture_request_body(request)
360
+
361
+ # Build HTTP request context
362
+ http_request_context = {
363
+ "request_id": base_context.data.get("request_id"),
364
+ "method": request.method,
365
+ "url": str(request.url),
366
+ "headers": extract_request_headers(request),
367
+ "is_client_request": True, # Distinguish from provider requests
368
+ }
369
+
370
+ # Add body information if available - pass raw data to let formatters handle conversion
371
+ if request_body:
372
+ http_request_context["body"] = request_body
373
+ # Set content type for formatters to use
374
+ content_type = request.headers.get("content-type", "")
375
+ http_request_context["is_json"] = "application/json" in content_type
376
+
377
+ preview, length, truncated = _stringify_raw_body(request_body)
378
+ logger.debug(
379
+ "client_http_request",
380
+ request_id=base_context.data.get("request_id"),
381
+ method=request.method,
382
+ url=str(request.url),
383
+ body_preview=preview,
384
+ body_size=length,
385
+ body_truncated=truncated,
386
+ category="http",
387
+ )
388
+
389
+ # Emit HTTP_REQUEST hook
390
+ await hook_manager.emit(HookEvent.HTTP_REQUEST, http_request_context)
391
+
392
+ return (
393
+ preview,
394
+ length,
395
+ truncated,
396
+ bool(http_request_context.get("is_json", False)),
397
+ )
398
+
399
+ except Exception as e:
400
+ logger.debug(
401
+ "http_request_hook_emission_failed",
402
+ error=str(e),
403
+ request_id=base_context.data.get("request_id"),
404
+ method=request.method,
405
+ category="hooks",
406
+ )
407
+ return (None, 0, False, False)
408
+
409
+ async def _emit_http_response_hook(
410
+ self,
411
+ hook_manager: HookManager,
412
+ request: Request,
413
+ response: Response,
414
+ base_context: HookContext,
415
+ ) -> None:
416
+ """Emit HTTP_RESPONSE hook with response body capture.
417
+
418
+ Args:
419
+ hook_manager: Hook manager for emitting events
420
+ request: FastAPI request object
421
+ response: FastAPI response object
422
+ base_context: Base hook context for request metadata
423
+ """
424
+ try:
425
+ # Build HTTP response context
426
+ http_response_context = {
427
+ "request_id": base_context.data.get("request_id"),
428
+ "method": request.method,
429
+ "url": str(request.url),
430
+ "headers": extract_request_headers(request),
431
+ "status_code": getattr(response, "status_code", 200),
432
+ "response_headers": dict(getattr(response, "headers", {})),
433
+ "is_client_response": True, # Distinguish from provider responses
434
+ }
435
+
436
+ # Capture response body for non-streaming responses
437
+ response_body = await self._capture_response_body(response)
438
+ if response_body is not None:
439
+ http_response_context["response_body"] = response_body
440
+
441
+ preview, length, truncated = _stringify_raw_body(response_body)
442
+ logger.debug(
443
+ "client_http_response",
444
+ request_id=base_context.data.get("request_id"),
445
+ method=request.method,
446
+ url=str(request.url),
447
+ status_code=getattr(response, "status_code", 200),
448
+ body_preview=preview,
449
+ body_size=length,
450
+ body_truncated=truncated,
451
+ category="http",
452
+ )
453
+
454
+ # Emit HTTP_RESPONSE hook
455
+ await hook_manager.emit(HookEvent.HTTP_RESPONSE, http_response_context)
456
+
457
+ except Exception as e:
458
+ logger.debug(
459
+ "http_response_hook_emission_failed",
460
+ error=str(e),
461
+ request_id=base_context.data.get("request_id"),
462
+ status_code=getattr(response, "status_code", 200),
463
+ category="hooks",
464
+ )
465
+
466
+ async def _capture_request_body(self, request: Request) -> bytes:
467
+ """Capture request body, handling caching for multiple reads.
468
+
469
+ Args:
470
+ request: FastAPI request object
471
+
472
+ Returns:
473
+ Request body as bytes
474
+ """
475
+ try:
476
+ # Check if body is already cached
477
+ if hasattr(request.state, "cached_body"):
478
+ return cast(bytes, request.state.cached_body)
479
+
480
+ # Read and cache body for future use
481
+ body = await request.body()
482
+ request.state.cached_body = body
483
+ return body
484
+
485
+ except Exception as e:
486
+ logger.debug(
487
+ "request_body_capture_failed",
488
+ error=str(e),
489
+ method=request.method,
490
+ url=str(request.url),
491
+ )
492
+ return b""
493
+
494
+ async def _capture_response_body(self, response: Response) -> bytes | None:
495
+ """Capture response body for non-streaming responses.
496
+
497
+ Args:
498
+ response: FastAPI response object
499
+
500
+ Returns:
501
+ Response body as raw bytes or None if unavailable
502
+ """
503
+ try:
504
+ # For regular Response objects, try to get body
505
+ if hasattr(response, "body") and response.body:
506
+ body_data = response.body
507
+ logger.debug(
508
+ "response_body_capture_debug",
509
+ body_type=type(body_data).__name__,
510
+ body_size=len(body_data)
511
+ if hasattr(body_data, "__len__")
512
+ else "no_len",
513
+ has_body_attr=hasattr(response, "body"),
514
+ body_truthy=bool(response.body),
515
+ )
516
+ # Ensure return type is bytes
517
+ if isinstance(body_data, memoryview):
518
+ return body_data.tobytes()
519
+ return body_data
520
+
521
+ logger.debug(
522
+ "response_body_capture_none",
523
+ has_body_attr=hasattr(response, "body"),
524
+ body_truthy=bool(getattr(response, "body", None)),
525
+ response_type=type(response).__name__,
526
+ )
527
+ return None
528
+
529
+ except Exception as e:
530
+ logger.debug(
531
+ "response_body_capture_failed",
532
+ error=str(e),
533
+ status_code=getattr(response, "status_code", 200),
534
+ )
535
+ return None
536
+
537
+ @staticmethod
538
+ def _is_sse_response(response: StreamingResponse) -> bool:
539
+ """Determine whether a streaming response is Server-Sent Events."""
540
+ media_type = (response.media_type or "").lower() if response.media_type else ""
541
+ if "text/event-stream" in media_type:
542
+ return True
543
+ content_type = response.headers.get("content-type", "")
544
+ return "text/event-stream" in content_type.lower()
545
+
546
+
547
+ def create_hooks_middleware(
548
+ hook_manager: HookManager | None = None,
549
+ ) -> type[HooksMiddleware]:
550
+ """Create a hooks middleware class with the provided hook manager.
551
+
552
+ Args:
553
+ hook_manager: Hook manager for emitting events
554
+
555
+ Returns:
556
+ HooksMiddleware class configured with the hook manager
557
+ """
558
+
559
+ class ConfiguredHooksMiddleware(HooksMiddleware):
560
+ def __init__(self, app: Any) -> None:
561
+ super().__init__(app, hook_manager)
562
+
563
+ return ConfiguredHooksMiddleware
@@ -0,0 +1,59 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import MutableMapping
4
+ from typing import Any
5
+
6
+ from starlette.types import ASGIApp, Receive, Scope, Send
7
+
8
+ from ccproxy.core.logging import get_logger
9
+
10
+
11
+ logger = get_logger()
12
+
13
+
14
+ class NormalizeHeadersMiddleware:
15
+ """Middleware to normalize outgoing response headers.
16
+
17
+ - Strips unsafe/mismatched headers (Content-Length, Transfer-Encoding)
18
+ """
19
+
20
+ def __init__(self, app: ASGIApp) -> None:
21
+ self.app = app
22
+
23
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
24
+ if scope["type"] != "http":
25
+ await self.app(scope, receive, send)
26
+ return
27
+
28
+ send_called = False
29
+
30
+ async def send_wrapper(message: MutableMapping[str, Any]) -> None:
31
+ nonlocal send_called
32
+ if message.get("type") == "http.response.start":
33
+ headers = message.get("headers", [])
34
+ # Filter out content-length and transfer-encoding
35
+ filtered: list[tuple[bytes, bytes]] = []
36
+ has_server = False
37
+ for name, value in headers:
38
+ lower = name.lower()
39
+ if lower in (b"content-length", b"transfer-encoding"):
40
+ continue
41
+ if lower == b"server":
42
+ has_server = True
43
+ filtered.append((name, value))
44
+
45
+ # Ensure a Server header exists; default to "ccproxy"
46
+ if not has_server:
47
+ filtered.append((b"server", b"ccproxy"))
48
+
49
+ message = {**message, "headers": filtered}
50
+ send_called = True
51
+ await send(message)
52
+
53
+ # Call downstream app
54
+ await self.app(scope, receive, send_wrapper)
55
+
56
+ # Note: We are not re-wrapping to ProxyResponse here because we operate
57
+ # at ASGI message level. Header normalization is sufficient; Starlette
58
+ # computes Content-Length automatically from body when omitted.
59
+ return