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,897 @@
1
+ """Deferred streaming response that preserves headers.
2
+
3
+ This implementation solves the header timing issue and supports SSE processing.
4
+ """
5
+
6
+ import contextlib
7
+ import json
8
+ from collections.abc import AsyncGenerator, AsyncIterator, Callable
9
+ from datetime import datetime
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ import httpx
13
+ import structlog
14
+ from starlette.responses import JSONResponse, Response, StreamingResponse
15
+
16
+ from ccproxy.core.plugins.hooks import HookEvent, HookManager
17
+ from ccproxy.core.plugins.hooks.base import HookContext
18
+ from ccproxy.llms.streaming.accumulators import StreamAccumulator
19
+ from ccproxy.streaming.sse import serialize_json_to_sse_stream
20
+ from ccproxy.utils.model_mapper import restore_model_aliases
21
+
22
+
23
+ if TYPE_CHECKING:
24
+ from ccproxy.core.request_context import RequestContext
25
+ from ccproxy.services.handler_config import HandlerConfig
26
+
27
+
28
+ logger = structlog.get_logger(__name__)
29
+
30
+
31
+ class DeferredStreaming(StreamingResponse):
32
+ """Deferred response that starts the stream to get headers and processes SSE."""
33
+
34
+ def __init__(
35
+ self,
36
+ method: str,
37
+ url: str,
38
+ headers: dict[str, str],
39
+ body: bytes,
40
+ client: httpx.AsyncClient,
41
+ media_type: str = "text/event-stream",
42
+ handler_config: "HandlerConfig | None" = None,
43
+ request_context: "RequestContext | None" = None,
44
+ hook_manager: HookManager | None = None,
45
+ close_client_on_finish: bool = False,
46
+ on_headers: Any | None = None,
47
+ ):
48
+ """Store request details to execute later.
49
+
50
+ Args:
51
+ method: HTTP method
52
+ url: Target URL
53
+ headers: Request headers
54
+ body: Request body
55
+ client: HTTP client to use
56
+ media_type: Response media type
57
+ handler_config: Optional handler config for SSE processing
58
+ request_context: Optional request context for tracking
59
+ hook_manager: Optional hook manager for emitting stream events
60
+ """
61
+ # Store attributes first
62
+ self.method = method
63
+ self.url = url
64
+ self.request_headers = headers
65
+ self.body = body
66
+ self.client = client
67
+ self.media_type = media_type
68
+ self.handler_config = handler_config
69
+ self.request_context = request_context
70
+ self.hook_manager = hook_manager
71
+ self._close_client_on_finish = close_client_on_finish
72
+ self.on_headers = on_headers
73
+ self._stream_accumulator: StreamAccumulator | None = None
74
+
75
+ # Create an async generator for the streaming content
76
+ async def generate_content() -> AsyncGenerator[bytes, None]:
77
+ # This will be replaced when __call__ is invoked
78
+ yield b""
79
+
80
+ # Initialize StreamingResponse with a generator
81
+ super().__init__(content=generate_content(), media_type=media_type)
82
+
83
+ async def __call__(self, scope: Any, receive: Any, send: Any) -> None:
84
+ """Execute the request when ASGI calls us."""
85
+
86
+ # Prepare extensions for request ID tracking
87
+ extensions = {}
88
+ request_id = None
89
+ if self.request_context and hasattr(self.request_context, "request_id"):
90
+ request_id = self.request_context.request_id
91
+ extensions["request_id"] = request_id
92
+
93
+ if self.request_context:
94
+ accumulator_cls = getattr(
95
+ self.request_context, "_tool_accumulator_class", None
96
+ )
97
+ if callable(accumulator_cls):
98
+ try:
99
+ self._stream_accumulator = accumulator_cls()
100
+ except Exception as exc: # pragma: no cover - defensive logging
101
+ logger.debug(
102
+ "stream_accumulator_init_failed",
103
+ error=str(exc),
104
+ request_id=request_id,
105
+ )
106
+ self._stream_accumulator = None
107
+
108
+ # Start the streaming request
109
+ async with self.client.stream(
110
+ method=self.method,
111
+ url=self.url,
112
+ headers=self.request_headers,
113
+ content=bytes(self.body)
114
+ if isinstance(self.body, memoryview)
115
+ else self.body,
116
+ timeout=httpx.Timeout(300.0),
117
+ extensions=extensions,
118
+ ) as response:
119
+ # Get all headers from upstream
120
+ upstream_headers = dict(response.headers)
121
+
122
+ # Invoke on_headers hook (allows choosing adapter/behavior based on upstream)
123
+ if callable(self.on_headers):
124
+ try:
125
+ result = self.on_headers(upstream_headers, self.request_context)
126
+ if hasattr(result, "__await__"):
127
+ result = await result # support async
128
+ # If hook returns a new response adapter, set it
129
+ if result is not None and self.handler_config is not None:
130
+ try:
131
+ # If result is a tuple (adapter, media_type), unpack
132
+ if isinstance(result, tuple):
133
+ adapter, media_type = result
134
+ self.handler_config = type(self.handler_config)(
135
+ supports_streaming=self.handler_config.supports_streaming,
136
+ request_transformer=self.handler_config.request_transformer,
137
+ response_adapter=adapter,
138
+ response_transformer=self.handler_config.response_transformer,
139
+ preserve_header_case=self.handler_config.preserve_header_case,
140
+ sse_parser=self.handler_config.sse_parser,
141
+ format_context=self.handler_config.format_context,
142
+ )
143
+ if media_type:
144
+ self.media_type = media_type
145
+ else:
146
+ self.handler_config = type(self.handler_config)(
147
+ supports_streaming=self.handler_config.supports_streaming,
148
+ request_transformer=self.handler_config.request_transformer,
149
+ response_adapter=result,
150
+ response_transformer=self.handler_config.response_transformer,
151
+ preserve_header_case=self.handler_config.preserve_header_case,
152
+ sse_parser=self.handler_config.sse_parser,
153
+ format_context=self.handler_config.format_context,
154
+ )
155
+ except Exception:
156
+ # If we can't rebuild dataclass (frozen, etc.), skip updating
157
+ pass
158
+ except Exception as e:
159
+ logger.debug(
160
+ "on_headers_hook_failed",
161
+ error=str(e),
162
+ category="streaming_headers",
163
+ )
164
+
165
+ # Store headers in request context
166
+ if self.request_context and hasattr(self.request_context, "metadata"):
167
+ self.request_context.metadata["response_headers"] = upstream_headers
168
+
169
+ # Remove hop-by-hop headers
170
+ for key in [
171
+ "content-length",
172
+ "transfer-encoding",
173
+ "connection",
174
+ "content-encoding",
175
+ ]:
176
+ upstream_headers.pop(key, None)
177
+
178
+ # Add headers; for errors, preserve provider content-type
179
+ is_error_status = response.status_code >= 400
180
+ content_type_header = (
181
+ response.headers.get("content-type") if is_error_status else None
182
+ )
183
+ final_headers: dict[str, str] = {
184
+ **upstream_headers,
185
+ "Content-Type": content_type_header
186
+ or (self.media_type or "text/event-stream"),
187
+ }
188
+ if request_id:
189
+ final_headers["X-Request-ID"] = request_id
190
+
191
+ # Create generator for the body
192
+ async def body_generator() -> AsyncGenerator[bytes, None]:
193
+ total_chunks = 0
194
+ total_bytes = 0
195
+ upstream_raw_chunks: list[bytes] = []
196
+
197
+ # Emit PROVIDER_STREAM_START hook
198
+ if self.hook_manager:
199
+ try:
200
+ # Extract provider from URL or context
201
+ provider = "unknown"
202
+ if self.request_context and hasattr(
203
+ self.request_context, "metadata"
204
+ ):
205
+ provider = self.request_context.metadata.get(
206
+ "service_type", "unknown"
207
+ )
208
+
209
+ stream_start_context = HookContext(
210
+ event=HookEvent.PROVIDER_STREAM_START,
211
+ timestamp=datetime.now(),
212
+ provider=provider,
213
+ data={
214
+ "url": self.url,
215
+ "method": self.method,
216
+ "headers": dict(self.request_headers),
217
+ "request_id": request_id,
218
+ },
219
+ metadata={
220
+ "request_id": request_id,
221
+ },
222
+ )
223
+ await self.hook_manager.emit_with_context(stream_start_context)
224
+ except Exception as e:
225
+ logger.debug(
226
+ "hook_emission_failed",
227
+ event_type="PROVIDER_STREAM_START",
228
+ error=str(e),
229
+ category="hooks",
230
+ )
231
+
232
+ # Local helper to adapt and emit an error SSE event (single chunk)
233
+ async def _emit_error_sse(
234
+ error_obj: dict[str, Any],
235
+ ) -> AsyncGenerator[bytes, None]:
236
+ adapted: dict[str, Any] | None = None
237
+ try:
238
+ if self.handler_config and self.handler_config.response_adapter:
239
+ # For now, skip adapter-based error processing to avoid type issues
240
+ # Just use the error as-is until we fully resolve adapter interfaces
241
+ adapted = error_obj
242
+ else:
243
+ adapted = error_obj
244
+ except Exception as e:
245
+ logger.debug(
246
+ "streaming_error_adaptation_failed",
247
+ error=str(e),
248
+ category="streaming_conversion",
249
+ )
250
+ adapted = error_obj
251
+
252
+ async def _single() -> AsyncIterator[dict[str, Any]]:
253
+ yield adapted or error_obj
254
+
255
+ async for sse_bytes in self._serialize_json_to_sse_stream(
256
+ _single(), include_done=False
257
+ ):
258
+ yield sse_bytes
259
+
260
+ try:
261
+ # Check for error status
262
+ if response.status_code >= 400:
263
+ # Forward provider error body as-is (no SSE wrapping)
264
+ raw_error = await response.aread()
265
+ yield raw_error
266
+ return
267
+
268
+ # Stream the response with optional SSE processing
269
+ if self.handler_config and self.handler_config.response_adapter:
270
+ logger.debug(
271
+ "streaming_format_adapter_detected",
272
+ adapter_type=type(
273
+ self.handler_config.response_adapter
274
+ ).__name__,
275
+ request_id=request_id,
276
+ url=self.url,
277
+ category="streaming_conversion",
278
+ )
279
+ # Process SSE events with format adaptation
280
+ async for chunk in self._process_sse_events(
281
+ response,
282
+ self.handler_config.response_adapter,
283
+ raw_event_consumer=upstream_raw_chunks.append,
284
+ ):
285
+ total_chunks += 1
286
+ total_bytes += len(chunk)
287
+
288
+ # Emit PROVIDER_STREAM_CHUNK hook
289
+ if self.hook_manager:
290
+ try:
291
+ provider = "unknown"
292
+ if self.request_context and hasattr(
293
+ self.request_context, "metadata"
294
+ ):
295
+ provider = self.request_context.metadata.get(
296
+ "service_type", "unknown"
297
+ )
298
+
299
+ chunk_context = HookContext(
300
+ event=HookEvent.PROVIDER_STREAM_CHUNK,
301
+ timestamp=datetime.now(),
302
+ provider=provider,
303
+ data={
304
+ "chunk": chunk,
305
+ "chunk_number": total_chunks,
306
+ "chunk_size": len(chunk),
307
+ "request_id": request_id,
308
+ },
309
+ metadata={"request_id": request_id},
310
+ )
311
+ await self.hook_manager.emit_with_context(
312
+ chunk_context
313
+ )
314
+ except Exception as e:
315
+ logger.trace(
316
+ "hook_emission_failed",
317
+ event_type="PROVIDER_STREAM_CHUNK",
318
+ error=str(e),
319
+ )
320
+
321
+ yield chunk
322
+ else:
323
+ # Check if response is SSE format based on content-type OR if
324
+ # it's Codex
325
+ content_type = response.headers.get("content-type", "").lower()
326
+ # Codex doesn't send content-type header but uses SSE format
327
+ is_codex = (
328
+ self.request_context
329
+ and self.request_context.metadata.get("service_type")
330
+ == "codex"
331
+ )
332
+ is_sse_format = "text/event-stream" in content_type or is_codex
333
+
334
+ logger.debug(
335
+ "streaming_no_format_adapter",
336
+ content_type=content_type,
337
+ is_codex=is_codex,
338
+ is_sse_format=is_sse_format,
339
+ request_id=request_id,
340
+ category="streaming_conversion",
341
+ )
342
+
343
+ if is_sse_format:
344
+ # Buffer and parse SSE events for metrics extraction
345
+ sse_buffer = b""
346
+ async for chunk in response.aiter_bytes():
347
+ total_chunks += 1
348
+ total_bytes += len(chunk)
349
+ sse_buffer += chunk
350
+
351
+ # Process complete SSE events in buffer
352
+ while b"\n\n" in sse_buffer:
353
+ event_end = sse_buffer.index(b"\n\n") + 2
354
+ event_data = sse_buffer[:event_end]
355
+ sse_buffer = sse_buffer[event_end:]
356
+
357
+ # Capture raw upstream chunk
358
+ upstream_raw_chunks.append(event_data)
359
+
360
+ # Process the complete SSE event with collector
361
+
362
+ # Emit PROVIDER_STREAM_CHUNK hook for SSE event
363
+ if self.hook_manager:
364
+ try:
365
+ provider = "unknown"
366
+ if self.request_context and hasattr(
367
+ self.request_context, "metadata"
368
+ ):
369
+ provider = (
370
+ self.request_context.metadata.get(
371
+ "service_type", "unknown"
372
+ )
373
+ )
374
+
375
+ chunk_context = HookContext(
376
+ event=HookEvent.PROVIDER_STREAM_CHUNK,
377
+ timestamp=datetime.now(),
378
+ provider=provider,
379
+ data={
380
+ "chunk": event_data,
381
+ "chunk_number": total_chunks,
382
+ "chunk_size": len(event_data),
383
+ "request_id": request_id,
384
+ },
385
+ metadata={"request_id": request_id},
386
+ )
387
+ await self.hook_manager.emit_with_context(
388
+ chunk_context
389
+ )
390
+ except Exception as e:
391
+ logger.trace(
392
+ "hook_emission_failed",
393
+ event_type="PROVIDER_STREAM_CHUNK",
394
+ error=str(e),
395
+ )
396
+
397
+ # Yield the complete event
398
+ self._record_sse_bytes(event_data)
399
+ yield event_data
400
+
401
+ # Yield any remaining data in buffer
402
+ if sse_buffer:
403
+ upstream_raw_chunks.append(sse_buffer)
404
+ self._record_sse_bytes(sse_buffer)
405
+ yield sse_buffer
406
+ else:
407
+ # Stream the raw response without SSE parsing
408
+ async for chunk in response.aiter_bytes():
409
+ total_chunks += 1
410
+ total_bytes += len(chunk)
411
+ upstream_raw_chunks.append(chunk)
412
+
413
+ # Emit PROVIDER_STREAM_CHUNK hook
414
+ if self.hook_manager:
415
+ try:
416
+ provider = "unknown"
417
+ if self.request_context and hasattr(
418
+ self.request_context, "metadata"
419
+ ):
420
+ provider = (
421
+ self.request_context.metadata.get(
422
+ "service_type", "unknown"
423
+ )
424
+ )
425
+
426
+ chunk_context = HookContext(
427
+ event=HookEvent.PROVIDER_STREAM_CHUNK,
428
+ timestamp=datetime.now(),
429
+ provider=provider,
430
+ data={
431
+ "chunk": chunk,
432
+ "chunk_number": total_chunks,
433
+ "chunk_size": len(chunk),
434
+ "request_id": request_id,
435
+ },
436
+ metadata={"request_id": request_id},
437
+ )
438
+ await self.hook_manager.emit_with_context(
439
+ chunk_context
440
+ )
441
+ except Exception as e:
442
+ logger.trace(
443
+ "hook_emission_failed",
444
+ event_type="PROVIDER_STREAM_CHUNK",
445
+ error=str(e),
446
+ )
447
+
448
+ self._record_sse_bytes(chunk)
449
+ yield chunk
450
+
451
+ # Update metrics if available
452
+ if self.request_context and hasattr(
453
+ self.request_context, "metrics"
454
+ ):
455
+ self.request_context.metrics["stream_chunks"] = total_chunks
456
+ self.request_context.metrics["stream_bytes"] = total_bytes
457
+
458
+ # Emit PROVIDER_STREAM_END hook
459
+ if self.hook_manager:
460
+ try:
461
+ provider = "unknown"
462
+ if self.request_context and hasattr(
463
+ self.request_context, "metadata"
464
+ ):
465
+ provider = self.request_context.metadata.get(
466
+ "service_type", "unknown"
467
+ )
468
+
469
+ logger.debug(
470
+ "emitting_provider_stream_end_hook",
471
+ request_id=request_id,
472
+ provider=provider,
473
+ total_chunks=total_chunks,
474
+ total_bytes=total_bytes,
475
+ )
476
+
477
+ upstream_stream_text: str | None = None
478
+ if upstream_raw_chunks:
479
+ upstream_stream_text = b"".join(
480
+ upstream_raw_chunks
481
+ ).decode("utf-8", errors="replace")
482
+
483
+ stream_end_context = HookContext(
484
+ event=HookEvent.PROVIDER_STREAM_END,
485
+ timestamp=datetime.now(),
486
+ provider=provider,
487
+ data={
488
+ "url": self.url,
489
+ "method": self.method,
490
+ "request_id": request_id,
491
+ "total_chunks": total_chunks,
492
+ "total_bytes": total_bytes,
493
+ "upstream_stream_text": upstream_stream_text,
494
+ },
495
+ metadata={
496
+ "request_id": request_id,
497
+ },
498
+ )
499
+ await self.hook_manager.emit_with_context(
500
+ stream_end_context
501
+ )
502
+ logger.debug(
503
+ "provider_stream_end_hook_emitted",
504
+ request_id=request_id,
505
+ )
506
+ except Exception as e:
507
+ logger.error(
508
+ "hook_emission_failed",
509
+ event_type="PROVIDER_STREAM_END",
510
+ error=str(e),
511
+ category="hooks",
512
+ exc_info=e,
513
+ )
514
+ else:
515
+ logger.debug(
516
+ "no_hook_manager_for_stream_end",
517
+ request_id=request_id,
518
+ )
519
+
520
+ except httpx.TimeoutException as e:
521
+ logger.error(
522
+ "streaming_request_timeout",
523
+ url=self.url,
524
+ error=str(e),
525
+ exc_info=e,
526
+ )
527
+ async for error_chunk in _emit_error_sse(
528
+ {
529
+ "error": {
530
+ "type": "timeout_error",
531
+ "message": "Request timeout",
532
+ }
533
+ }
534
+ ):
535
+ yield error_chunk
536
+ except httpx.ConnectError as e:
537
+ logger.error(
538
+ "streaming_connect_error",
539
+ url=self.url,
540
+ error=str(e),
541
+ exc_info=e,
542
+ )
543
+ async for error_chunk in _emit_error_sse(
544
+ {
545
+ "error": {
546
+ "type": "connection_error",
547
+ "message": "Connection failed",
548
+ }
549
+ }
550
+ ):
551
+ yield error_chunk
552
+ except httpx.HTTPError as e:
553
+ logger.error(
554
+ "streaming_http_error", url=self.url, error=str(e), exc_info=e
555
+ )
556
+ async for error_chunk in _emit_error_sse(
557
+ {
558
+ "error": {
559
+ "type": "http_error",
560
+ "message": f"HTTP error: {str(e)}",
561
+ }
562
+ }
563
+ ):
564
+ yield error_chunk
565
+ except Exception as e:
566
+ logger.error(
567
+ "streaming_request_unexpected_error",
568
+ url=self.url,
569
+ error=str(e),
570
+ exc_info=e,
571
+ )
572
+ async for error_chunk in _emit_error_sse(
573
+ {"error": {"type": "internal_server_error", "message": str(e)}}
574
+ ):
575
+ yield error_chunk
576
+
577
+ # Create the actual streaming response with headers
578
+ # Access logging now handled by hooks
579
+ actual_response: Response
580
+ if self.request_context:
581
+ actual_response = StreamingResponse(
582
+ content=body_generator(),
583
+ status_code=response.status_code,
584
+ headers=final_headers,
585
+ media_type=self.media_type,
586
+ )
587
+ else:
588
+ # Use regular StreamingResponse if no request context
589
+ actual_response = StreamingResponse(
590
+ content=body_generator(),
591
+ status_code=response.status_code,
592
+ headers=final_headers,
593
+ media_type=self.media_type,
594
+ )
595
+
596
+ # Delegate to the actual response
597
+ await actual_response(scope, receive, send)
598
+
599
+ if self._stream_accumulator and self.request_context:
600
+ try:
601
+ # Store tool calls in metadata
602
+ tool_calls = self._stream_accumulator.get_complete_tool_calls()
603
+ if tool_calls:
604
+ existing = self.request_context.metadata.get("tool_calls")
605
+ if isinstance(existing, list):
606
+ existing.extend(tool_calls)
607
+ else:
608
+ self.request_context.metadata["tool_calls"] = tool_calls
609
+
610
+ # Store accumulator for potential later use
611
+ self.request_context.metadata["stream_accumulator"] = (
612
+ self._stream_accumulator
613
+ )
614
+ except Exception as exc: # pragma: no cover - defensive logging
615
+ logger.debug(
616
+ "stream_accumulator_finalize_failed",
617
+ error=str(exc),
618
+ request_id=getattr(self.request_context, "request_id", None),
619
+ )
620
+
621
+ # After the streaming context closes, optionally close the client we own
622
+ if self._close_client_on_finish:
623
+ with contextlib.suppress(Exception):
624
+ await self.client.aclose()
625
+
626
+ async def _process_sse_events(
627
+ self,
628
+ response: httpx.Response,
629
+ adapter: Any,
630
+ *,
631
+ raw_event_consumer: Callable[[bytes], None] | None = None,
632
+ ) -> AsyncGenerator[bytes, None]:
633
+ """Parse and adapt SSE events from response stream.
634
+
635
+ - Parse raw SSE bytes to JSON chunks
636
+ - Optionally process raw chunks with metrics collector
637
+ - Pass entire JSON stream through adapter (maintains state)
638
+ - Serialize adapted chunks back to SSE format
639
+ - Optionally process converted chunks with metrics collector
640
+ """
641
+ request_id = None
642
+ if self.request_context and hasattr(self.request_context, "request_id"):
643
+ request_id = self.request_context.request_id
644
+
645
+ logger.debug(
646
+ "sse_processing_pipeline_start",
647
+ adapter_type=type(adapter).__name__,
648
+ request_id=request_id,
649
+ response_status=response.status_code,
650
+ category="streaming_conversion",
651
+ )
652
+
653
+ # Create streaming pipeline:
654
+ # 1. Parse raw SSE bytes to JSON chunks
655
+ json_stream = self._parse_sse_to_json_stream(
656
+ response.aiter_bytes(), raw_event_consumer=raw_event_consumer
657
+ )
658
+
659
+ # 2. Pass entire JSON stream through adapter (maintains state)
660
+ logger.debug(
661
+ "sse_adapter_stream_calling",
662
+ adapter_type=type(adapter).__name__,
663
+ request_id=request_id,
664
+ category="adapter_integration",
665
+ )
666
+
667
+ # Handle both legacy dict-based and new model-based adapters
668
+ if hasattr(adapter, "convert_stream"):
669
+ try:
670
+ adapted_stream = adapter.convert_stream(json_stream)
671
+ except Exception as e:
672
+ logger.error(
673
+ "adapter_stream_conversion_failed",
674
+ adapter_type=type(adapter).__name__,
675
+ error=str(e),
676
+ request_id=request_id,
677
+ category="transform",
678
+ )
679
+ # Return a proper error response instead of malformed passthrough
680
+ error_response = JSONResponse(
681
+ status_code=500,
682
+ content={
683
+ "error": {
684
+ "type": "internal_server_error",
685
+ "message": "Failed to convert streaming response format",
686
+ "details": str(e),
687
+ }
688
+ },
689
+ )
690
+ raise Exception(f"Stream format conversion failed: {e}") from e
691
+ elif hasattr(adapter, "adapt_stream"):
692
+ try:
693
+ adapted_stream = adapter.adapt_stream(json_stream)
694
+ except ValueError as e:
695
+ # Fail fast for missing formatters - don't silently fall back
696
+ if "No stream formatter available" in str(e):
697
+ logger.error(
698
+ "streaming_formatter_missing_failing_fast",
699
+ adapter_type=type(adapter).__name__,
700
+ error=str(e),
701
+ request_id=request_id,
702
+ category="streaming_conversion",
703
+ )
704
+ raise e
705
+ else:
706
+ logger.error(
707
+ "adapter_stream_conversion_failed",
708
+ adapter_type=type(adapter).__name__,
709
+ error=str(e),
710
+ request_id=request_id,
711
+ category="transform",
712
+ )
713
+ # Raise error instead of corrupting response with passthrough
714
+ raise Exception(f"Stream format conversion failed: {e}") from e
715
+ except Exception as e:
716
+ logger.error(
717
+ "adapter_stream_conversion_failed",
718
+ adapter_type=type(adapter).__name__,
719
+ error=str(e),
720
+ request_id=request_id,
721
+ category="transform",
722
+ )
723
+ # Raise error instead of corrupting response with passthrough
724
+ raise Exception(f"Stream format conversion failed: {e}") from e
725
+ else:
726
+ # No adapter, passthrough
727
+ adapted_stream = json_stream
728
+
729
+ # 3. Serialize adapted chunks back to SSE format
730
+ chunk_count = 0
731
+ async for sse_bytes in self._serialize_json_to_sse_stream(adapted_stream):
732
+ chunk_count += 1
733
+ yield sse_bytes
734
+
735
+ logger.debug(
736
+ "sse_processing_pipeline_complete",
737
+ adapter_type=type(adapter).__name__,
738
+ request_id=request_id,
739
+ total_processed_chunks=chunk_count,
740
+ category="streaming_conversion",
741
+ )
742
+
743
+ async def _parse_sse_to_json_stream(
744
+ self,
745
+ raw_stream: AsyncIterator[bytes],
746
+ *,
747
+ raw_event_consumer: Callable[[bytes], None] | None = None,
748
+ ) -> AsyncIterator[dict[str, Any]]:
749
+ """Parse raw SSE bytes stream into JSON chunks.
750
+
751
+ Yields JSON objects extracted from SSE events without buffering
752
+ the entire response.
753
+
754
+ Args:
755
+ raw_stream: Raw bytes stream from provider
756
+ raw_event_consumer: Optional callback invoked with each raw SSE event
757
+ """
758
+ buffer = b""
759
+
760
+ async for chunk in raw_stream:
761
+ buffer += chunk
762
+
763
+ # Process complete SSE events in buffer
764
+ while b"\n\n" in buffer:
765
+ event_end = buffer.index(b"\n\n") + 2
766
+ event_data = buffer[:event_end]
767
+ buffer = buffer[event_end:]
768
+
769
+ if raw_event_consumer:
770
+ raw_event_consumer(event_data)
771
+
772
+ # Parse SSE event
773
+ event_lines = (
774
+ event_data.decode("utf-8", errors="ignore").strip().split("\n")
775
+ )
776
+ data_lines = [
777
+ line[6:] for line in event_lines if line.startswith("data: ")
778
+ ]
779
+ # Capture event type if present
780
+ event_type = None
781
+ for line in event_lines:
782
+ if line.startswith("event:"):
783
+ event_type = line[6:].strip()
784
+
785
+ if data_lines:
786
+ data = "".join(data_lines)
787
+ if data == "[DONE]":
788
+ continue
789
+
790
+ try:
791
+ json_obj = json.loads(data)
792
+ if self.request_context and isinstance(
793
+ self.request_context.metadata, dict
794
+ ):
795
+ restore_model_aliases(
796
+ json_obj, self.request_context.metadata
797
+ )
798
+ last_client_model = self.request_context.metadata.get(
799
+ "_last_client_model"
800
+ )
801
+ if last_client_model and isinstance(json_obj, dict):
802
+ self._override_model_alias(json_obj, last_client_model)
803
+ self._record_tool_event(event_type or "", json_obj)
804
+ # Preserve event type for downstream adapters (if missing)
805
+ if (
806
+ event_type
807
+ and isinstance(json_obj, dict)
808
+ and "type" not in json_obj
809
+ ):
810
+ json_obj["type"] = event_type
811
+ yield json_obj
812
+ except json.JSONDecodeError:
813
+ continue
814
+
815
+ if buffer:
816
+ if raw_event_consumer:
817
+ raw_event_consumer(buffer)
818
+ logger.debug(
819
+ "sse_parser_incomplete_chunk",
820
+ remaining_bytes=len(buffer),
821
+ category="streaming_conversion",
822
+ )
823
+
824
+ async def _serialize_json_to_sse_stream(
825
+ self, json_stream: AsyncIterator[Any], include_done: bool = True
826
+ ) -> AsyncGenerator[bytes, None]:
827
+ """Serialize JSON chunks back to SSE format.
828
+
829
+ Converts JSON objects to appropriate SSE event format:
830
+ - For Anthropic format (has "type" field): event: {type}\ndata: {json}\n\n
831
+ - For OpenAI format: data: {json}\n\n
832
+
833
+ Args:
834
+ json_stream: Stream of JSON objects after format conversion
835
+ """
836
+ async for chunk in serialize_json_to_sse_stream(
837
+ json_stream,
838
+ include_done=include_done,
839
+ request_context=self.request_context,
840
+ ):
841
+ yield chunk
842
+
843
+ def _record_tool_event(self, event_name: str, payload: Any) -> None:
844
+ if not self._stream_accumulator or not isinstance(payload, dict):
845
+ return
846
+
847
+ try:
848
+ self._stream_accumulator.accumulate(event_name or "", payload)
849
+ except Exception as exc: # pragma: no cover - defensive logging
850
+ logger.debug(
851
+ "stream_accumulator_accumulate_failed",
852
+ error=str(exc),
853
+ event_name=event_name,
854
+ request_id=getattr(self.request_context, "request_id", None),
855
+ )
856
+
857
+ def _override_model_alias(self, payload: Any, model_value: str) -> None:
858
+ if isinstance(payload, dict):
859
+ for key, value in payload.items():
860
+ if key == "model" and isinstance(value, str) and value != model_value:
861
+ payload[key] = model_value
862
+ else:
863
+ self._override_model_alias(value, model_value)
864
+ elif isinstance(payload, list):
865
+ for item in payload:
866
+ self._override_model_alias(item, model_value)
867
+
868
+ def _record_sse_bytes(self, event_bytes: bytes) -> None:
869
+ if not self._stream_accumulator:
870
+ return
871
+
872
+ text = event_bytes.decode("utf-8", errors="ignore").strip()
873
+ if not text:
874
+ return
875
+
876
+ event_name = ""
877
+ data_lines: list[str] = []
878
+ for raw_line in text.split("\n"):
879
+ line = raw_line.strip()
880
+ if line.startswith("event:"):
881
+ event_name = line[6:].strip()
882
+ elif line.startswith("data:"):
883
+ payload = line[5:].lstrip()
884
+ if payload == "[DONE]":
885
+ data_lines = []
886
+ break
887
+ data_lines.append(payload)
888
+
889
+ if not data_lines:
890
+ return
891
+
892
+ try:
893
+ payload_obj = json.loads("\n".join(data_lines))
894
+ except json.JSONDecodeError:
895
+ return
896
+
897
+ self._record_tool_event(event_name, payload_obj)