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,1045 @@
1
+ import contextlib
2
+ import json
3
+ from abc import abstractmethod
4
+ from collections.abc import Mapping, Sequence
5
+ from datetime import datetime
6
+ from typing import Any, Literal, cast
7
+ from urllib.parse import urlparse
8
+
9
+ import httpx
10
+ from fastapi import HTTPException, Request
11
+ from starlette.responses import JSONResponse, Response, StreamingResponse
12
+
13
+ from ccproxy.core.logging import get_plugin_logger
14
+ from ccproxy.core.plugins.hooks.base import HookContext
15
+ from ccproxy.core.plugins.hooks.events import HookEvent
16
+ from ccproxy.models.provider import ProviderConfig
17
+ from ccproxy.services.adapters.base import BaseAdapter
18
+ from ccproxy.services.adapters.chain_composer import compose_from_chain
19
+ from ccproxy.services.handler_config import HandlerConfig
20
+ from ccproxy.streaming import DeferredStreaming
21
+ from ccproxy.streaming.handler import StreamingHandler
22
+ from ccproxy.utils.headers import extract_request_headers, filter_response_headers
23
+ from ccproxy.utils.model_mapper import (
24
+ ModelMapper,
25
+ add_model_alias,
26
+ restore_model_aliases,
27
+ )
28
+
29
+
30
+ logger = get_plugin_logger()
31
+
32
+
33
+ class BaseHTTPAdapter(BaseAdapter):
34
+ """Simplified HTTP adapter with format chain support."""
35
+
36
+ def __init__(
37
+ self,
38
+ config: ProviderConfig,
39
+ auth_manager: Any,
40
+ http_pool_manager: Any,
41
+ streaming_handler: StreamingHandler | None = None,
42
+ **kwargs: Any,
43
+ ) -> None:
44
+ # Call parent constructor to properly initialize config
45
+ super().__init__(config=config, **kwargs)
46
+ self.auth_manager = auth_manager
47
+ self.http_pool_manager = http_pool_manager
48
+ self.streaming_handler = streaming_handler
49
+ self.format_registry = kwargs.get("format_registry")
50
+ self.context = kwargs.get("context")
51
+ self.model_mapper = kwargs.get("model_mapper")
52
+
53
+ logger.debug(
54
+ "base_http_adapter_initialized",
55
+ has_streaming_handler=streaming_handler is not None,
56
+ has_format_registry=self.format_registry is not None,
57
+ )
58
+
59
+ async def handle_request(
60
+ self, request: Request
61
+ ) -> Response | StreamingResponse | DeferredStreaming:
62
+ """Handle request with streaming detection and format chain support."""
63
+
64
+ # Get context from middleware (already initialized)
65
+ ctx = request.state.context
66
+ self._ensure_tool_accumulator(ctx)
67
+
68
+ # Step 1: Extract request data
69
+ body = await request.body()
70
+ body = await self._map_request_model(ctx, body)
71
+ headers = extract_request_headers(request)
72
+ method = request.method
73
+ endpoint = ctx.metadata.get("endpoint", "")
74
+
75
+ # Fail fast if a format chain is configured without a registry
76
+ self._ensure_format_registry(ctx.format_chain, endpoint)
77
+
78
+ # Extra debug breadcrumbs to confirm code path and detection inputs
79
+ logger.debug(
80
+ "http_adapter_handle_request_entry",
81
+ endpoint=endpoint,
82
+ method=method,
83
+ content_type=headers.get("content-type"),
84
+ has_streaming_handler=bool(self.streaming_handler),
85
+ category="stream_detection",
86
+ )
87
+
88
+ # Step 2: Early streaming detection
89
+ if self.streaming_handler:
90
+ logger.debug(
91
+ "checking_should_stream",
92
+ endpoint=endpoint,
93
+ has_streaming_handler=True,
94
+ content_type=headers.get("content-type"),
95
+ category="stream_detection",
96
+ )
97
+ # Detect streaming via Accept header and/or body flag stream:true
98
+ body_wants_stream = False
99
+ parsed_payload: dict[str, Any] | None = None
100
+ try:
101
+ parsed_payload = json.loads(body.decode()) if body else {}
102
+ body_wants_stream = bool(parsed_payload.get("stream", False))
103
+ except Exception:
104
+ body_wants_stream = False
105
+ header_wants_stream = self.streaming_handler.should_stream_response(headers)
106
+ logger.debug(
107
+ "should_stream_results",
108
+ body_wants_stream=body_wants_stream,
109
+ header_wants_stream=header_wants_stream,
110
+ endpoint=endpoint,
111
+ category="stream_detection",
112
+ )
113
+ if body_wants_stream or header_wants_stream:
114
+ logger.debug(
115
+ "streaming_request_detected",
116
+ endpoint=endpoint,
117
+ detected_via=(
118
+ "content_type_sse"
119
+ if header_wants_stream
120
+ else "body_stream_flag"
121
+ ),
122
+ category="stream_detection",
123
+ )
124
+ if isinstance(parsed_payload, dict):
125
+ self._record_tool_definitions(ctx, parsed_payload)
126
+ return await self.handle_streaming(request, endpoint)
127
+ else:
128
+ logger.debug(
129
+ "not_streaming_request",
130
+ endpoint=endpoint,
131
+ category="stream_detection",
132
+ )
133
+
134
+ # Step 3: Execute format chain if specified (non-streaming)
135
+ request_payload: dict[str, Any] | None = None
136
+ if ctx.format_chain and len(ctx.format_chain) > 1:
137
+ try:
138
+ request_payload = self._decode_json_body(body, context="request")
139
+ except ValueError as exc:
140
+ logger.error(
141
+ "format_chain_request_parse_failed",
142
+ error=str(exc),
143
+ endpoint=endpoint,
144
+ category="transform",
145
+ )
146
+ return JSONResponse(
147
+ status_code=400,
148
+ content={
149
+ "error": {
150
+ "type": "invalid_request_error",
151
+ "message": "Failed to parse request body for format conversion",
152
+ "details": str(exc),
153
+ }
154
+ },
155
+ )
156
+
157
+ self._record_tool_definitions(ctx, request_payload)
158
+
159
+ try:
160
+ logger.debug(
161
+ "format_chain_request_about_to_convert",
162
+ chain=ctx.format_chain,
163
+ endpoint=endpoint,
164
+ category="transform",
165
+ )
166
+ request_payload = await self._apply_format_chain(
167
+ data=request_payload,
168
+ format_chain=ctx.format_chain,
169
+ stage="request",
170
+ )
171
+ body = self._encode_json_body(request_payload)
172
+ logger.trace(
173
+ "format_chain_request_converted",
174
+ from_format=ctx.format_chain[0],
175
+ to_format=ctx.format_chain[-1],
176
+ keys=list(request_payload.keys()),
177
+ size_bytes=len(body),
178
+ category="transform",
179
+ )
180
+ logger.info(
181
+ "format_chain_applied",
182
+ stage="request",
183
+ endpoint=endpoint,
184
+ chain=ctx.format_chain,
185
+ steps=len(ctx.format_chain) - 1,
186
+ category="format",
187
+ )
188
+ except Exception as e:
189
+ logger.error(
190
+ "format_chain_request_failed",
191
+ error=str(e),
192
+ endpoint=endpoint,
193
+ exc_info=e,
194
+ category="transform",
195
+ )
196
+ return JSONResponse(
197
+ status_code=400,
198
+ content={
199
+ "error": {
200
+ "type": "invalid_request_error",
201
+ "message": "Failed to convert request using format chain",
202
+ "details": str(e),
203
+ }
204
+ },
205
+ )
206
+ # Step 4: Provider-specific preparation
207
+ prepared_body, prepared_headers = await self.prepare_provider_request(
208
+ body, headers, endpoint
209
+ )
210
+ with contextlib.suppress(Exception):
211
+ logger.trace(
212
+ "provider_request_prepared",
213
+ endpoint=endpoint,
214
+ header_keys=list(prepared_headers.keys()),
215
+ body_size=len(prepared_body or b""),
216
+ category="http",
217
+ )
218
+
219
+ # Step 5: Execute HTTP request
220
+ target_url = await self.get_target_url(endpoint)
221
+ (
222
+ method,
223
+ target_url,
224
+ prepared_body,
225
+ prepared_headers,
226
+ ) = await self._emit_provider_request_prepared(
227
+ request_obj=request,
228
+ ctx=ctx,
229
+ method=method,
230
+ endpoint=endpoint,
231
+ target_url=target_url,
232
+ prepared_body=prepared_body,
233
+ prepared_headers=prepared_headers,
234
+ is_streaming=False,
235
+ )
236
+ provider_response = await self._execute_http_request(
237
+ method,
238
+ target_url,
239
+ prepared_headers,
240
+ prepared_body,
241
+ )
242
+ logger.trace(
243
+ "provider_response_received",
244
+ status_code=getattr(provider_response, "status_code", None),
245
+ content_type=getattr(provider_response, "headers", {}).get(
246
+ "content-type", None
247
+ ),
248
+ category="http",
249
+ )
250
+
251
+ # Step 6: Provider-specific response processing
252
+ response = await self.process_provider_response(provider_response, endpoint)
253
+
254
+ # filter out hop-by-hop headers
255
+ headers = filter_response_headers(dict(provider_response.headers))
256
+
257
+ # Step 7: Format the response
258
+ if isinstance(response, StreamingResponse):
259
+ logger.debug("process_provider_response_streaming")
260
+ return await self._convert_streaming_response(
261
+ response, ctx.format_chain, ctx
262
+ )
263
+ elif isinstance(response, Response):
264
+ logger.debug("process_provider_response")
265
+ response = self._restore_model_response(response, ctx)
266
+
267
+ # httpx has already decoded provider payloads, so strip encoding
268
+ # headers that no longer match the body we forward to clients.
269
+ for header in ("content-encoding", "transfer-encoding", "content-length"):
270
+ with contextlib.suppress(KeyError):
271
+ del response.headers[header]
272
+ if ctx.format_chain and len(ctx.format_chain) > 1:
273
+ stage: Literal["response", "error"] = (
274
+ "error" if provider_response.status_code >= 400 else "response"
275
+ )
276
+ try:
277
+ payload = self._decode_json_body(
278
+ cast(bytes, response.body), context=stage
279
+ )
280
+ except ValueError as exc:
281
+ logger.error(
282
+ "format_chain_response_parse_failed",
283
+ error=str(exc),
284
+ endpoint=endpoint,
285
+ stage=stage,
286
+ category="transform",
287
+ )
288
+ return response
289
+
290
+ try:
291
+ payload = await self._apply_format_chain(
292
+ data=payload,
293
+ format_chain=ctx.format_chain,
294
+ stage=stage,
295
+ )
296
+ metadata = getattr(ctx, "metadata", None)
297
+ if isinstance(metadata, dict):
298
+ alias_map = metadata.get("_model_alias_map")
299
+ else:
300
+ alias_map = None
301
+ if not alias_map:
302
+ alias_map = getattr(ctx, "_model_alias_map", None)
303
+ if isinstance(metadata, dict):
304
+ if (
305
+ isinstance(payload, dict)
306
+ and isinstance(alias_map, Mapping)
307
+ and isinstance(payload.get("model"), str)
308
+ ):
309
+ payload["model"] = alias_map.get(
310
+ payload["model"], payload["model"]
311
+ )
312
+ restore_model_aliases(payload, metadata)
313
+ body_bytes = self._encode_json_body(payload)
314
+ logger.info(
315
+ "format_chain_applied",
316
+ stage=stage,
317
+ endpoint=endpoint,
318
+ chain=ctx.format_chain,
319
+ steps=len(ctx.format_chain) - 1,
320
+ category="format",
321
+ )
322
+ restored = Response(
323
+ content=body_bytes,
324
+ status_code=provider_response.status_code,
325
+ headers=headers,
326
+ media_type=provider_response.headers.get(
327
+ "content-type", "application/json"
328
+ ),
329
+ )
330
+ return self._restore_model_response(restored, ctx)
331
+ except Exception as e:
332
+ logger.error(
333
+ "format_chain_response_failed",
334
+ error=str(e),
335
+ endpoint=endpoint,
336
+ stage=stage,
337
+ exc_info=e,
338
+ category="transform",
339
+ )
340
+ # Return proper error instead of potentially malformed response
341
+ return JSONResponse(
342
+ status_code=500,
343
+ content={
344
+ "error": {
345
+ "type": "internal_server_error",
346
+ "message": "Failed to convert response format",
347
+ "details": str(e),
348
+ }
349
+ },
350
+ )
351
+ else:
352
+ logger.debug("format_chain_skipped", reason="no forward chain")
353
+ return self._restore_model_response(response, ctx)
354
+ else:
355
+ logger.warning(
356
+ "unexpected_provider_response_type", type=type(response).__name__
357
+ )
358
+ restored = Response(
359
+ content=provider_response.content,
360
+ status_code=provider_response.status_code,
361
+ headers=headers,
362
+ media_type=headers.get("content-type", "application/json"),
363
+ )
364
+ return self._restore_model_response(restored, ctx)
365
+ # raise ValueError(
366
+ # "process_provider_response must return httpx.Response for non-streaming",
367
+ # )
368
+
369
+ async def handle_streaming(
370
+ self, request: Request, endpoint: str, **kwargs: Any
371
+ ) -> StreamingResponse | DeferredStreaming:
372
+ """Handle a streaming request using StreamingHandler with format chain support."""
373
+
374
+ logger.debug("handle_streaming_called", endpoint=endpoint)
375
+
376
+ if not self.streaming_handler:
377
+ logger.error(
378
+ "streaming_handler_missing",
379
+ endpoint=endpoint,
380
+ category="streaming",
381
+ )
382
+ raise HTTPException(
383
+ status_code=500,
384
+ detail={
385
+ "error": {
386
+ "type": "configuration_error",
387
+ "message": "Streaming handler is not configured for this provider.",
388
+ "details": {
389
+ "endpoint": endpoint,
390
+ },
391
+ }
392
+ },
393
+ )
394
+
395
+ # Get context from middleware
396
+ ctx = request.state.context
397
+ method = request.method
398
+ self._ensure_tool_accumulator(ctx)
399
+
400
+ # Extract request data
401
+ body = await request.body()
402
+ body = await self._map_request_model(ctx, body)
403
+ headers = extract_request_headers(request)
404
+
405
+ # Fail fast on missing format registry if chain configured
406
+ self._ensure_format_registry(ctx.format_chain, endpoint)
407
+
408
+ # Step 1: Execute request-side format chain if specified (streaming)
409
+ if ctx.format_chain and len(ctx.format_chain) > 1:
410
+ try:
411
+ stream_payload = self._decode_json_body(body, context="stream_request")
412
+ stream_payload = await self._apply_format_chain(
413
+ data=stream_payload,
414
+ format_chain=ctx.format_chain,
415
+ stage="request",
416
+ )
417
+ self._record_tool_definitions(ctx, stream_payload)
418
+ body = self._encode_json_body(stream_payload)
419
+ logger.trace(
420
+ "format_chain_stream_request_converted",
421
+ from_format=ctx.format_chain[0],
422
+ to_format=ctx.format_chain[-1],
423
+ keys=list(stream_payload.keys()),
424
+ size_bytes=len(body),
425
+ category="transform",
426
+ )
427
+ logger.info(
428
+ "format_chain_applied",
429
+ stage="stream_request",
430
+ endpoint=endpoint,
431
+ chain=ctx.format_chain,
432
+ steps=len(ctx.format_chain) - 1,
433
+ category="format",
434
+ )
435
+ except Exception as e:
436
+ logger.error(
437
+ "format_chain_stream_request_failed",
438
+ error=str(e),
439
+ endpoint=endpoint,
440
+ exc_info=e,
441
+ category="transform",
442
+ )
443
+ raise HTTPException(
444
+ status_code=400,
445
+ detail={
446
+ "error": {
447
+ "type": "invalid_request_error",
448
+ "message": "Failed to convert streaming request using format chain",
449
+ "details": str(e),
450
+ }
451
+ },
452
+ )
453
+
454
+ # Step 2: Provider-specific preparation (add auth headers, etc.)
455
+ prepared_body, prepared_headers = await self.prepare_provider_request(
456
+ body, headers, endpoint
457
+ )
458
+ try:
459
+ original_payload = json.loads(body.decode()) if body else {}
460
+ if isinstance(original_payload, dict):
461
+ self._record_tool_definitions(ctx, original_payload)
462
+ except Exception:
463
+ pass
464
+
465
+ # Get format adapter for streaming if format chain exists
466
+ # Important: Do NOT reverse the chain. Adapters are defined for the
467
+ # declared flow and handle response/streaming internally.
468
+ streaming_format_adapter = None
469
+ if ctx.format_chain and self.format_registry:
470
+ # For streaming responses, we need to reverse the format chain direction
471
+ # Request: client_format → provider_format
472
+ # Stream Response: provider_format → client_format
473
+ from_format = ctx.format_chain[-1] # provider format (e.g., "anthropic")
474
+ to_format = ctx.format_chain[
475
+ 0
476
+ ] # client format (e.g., "openai.chat_completions")
477
+ streaming_format_adapter = self.format_registry.get_if_exists(
478
+ from_format, to_format
479
+ )
480
+
481
+ logger.debug(
482
+ "streaming_adapter_lookup",
483
+ format_chain=ctx.format_chain,
484
+ from_format=from_format,
485
+ to_format=to_format,
486
+ adapter_found=streaming_format_adapter is not None,
487
+ adapter_type=type(streaming_format_adapter).__name__
488
+ if streaming_format_adapter
489
+ else None,
490
+ )
491
+
492
+ # Build handler config for streaming with a composed format adapter derived from chain
493
+ # Import here to avoid circular imports
494
+ composed_adapter = (
495
+ compose_from_chain(registry=self.format_registry, chain=ctx.format_chain)
496
+ if self.format_registry and ctx.format_chain
497
+ else streaming_format_adapter
498
+ )
499
+
500
+ if ctx.format_chain and len(ctx.format_chain) > 1 and composed_adapter is None:
501
+ logger.error(
502
+ "streaming_adapter_missing",
503
+ endpoint=endpoint,
504
+ chain=ctx.format_chain,
505
+ category="format",
506
+ )
507
+ raise HTTPException(
508
+ status_code=500,
509
+ detail={
510
+ "error": {
511
+ "type": "configuration_error",
512
+ "message": "No streaming format adapter available for configured format chain.",
513
+ "details": {
514
+ "endpoint": endpoint,
515
+ "format_chain": ctx.format_chain,
516
+ },
517
+ }
518
+ },
519
+ )
520
+
521
+ if composed_adapter is not None and ctx.format_chain:
522
+ logger.debug(
523
+ "streaming_format_adapter_selected",
524
+ endpoint=endpoint,
525
+ chain=ctx.format_chain,
526
+ adapter_type=type(composed_adapter).__name__,
527
+ category="format",
528
+ )
529
+
530
+ handler_config = HandlerConfig(
531
+ supports_streaming=True,
532
+ request_transformer=None,
533
+ response_adapter=composed_adapter, # use composed adapter when available
534
+ format_context=None,
535
+ )
536
+
537
+ # Get target URL for proper client pool management
538
+ target_url = await self.get_target_url(endpoint)
539
+
540
+ (
541
+ method,
542
+ target_url,
543
+ prepared_body,
544
+ prepared_headers,
545
+ ) = await self._emit_provider_request_prepared(
546
+ request_obj=request,
547
+ ctx=ctx,
548
+ method=method,
549
+ endpoint=endpoint,
550
+ target_url=target_url,
551
+ prepared_body=prepared_body,
552
+ prepared_headers=prepared_headers,
553
+ is_streaming=True,
554
+ )
555
+
556
+ # Get HTTP client from pool manager with base URL for hook integration
557
+ parsed_url = urlparse(target_url)
558
+ base_url = f"{parsed_url.scheme}://{parsed_url.netloc}"
559
+
560
+ # Delegate to StreamingHandler - no format chain needed since adapter is in config
561
+ return await self.streaming_handler.handle_streaming_request(
562
+ method=method,
563
+ url=target_url,
564
+ headers=prepared_headers, # Use prepared headers with auth
565
+ body=prepared_body, # Use prepared body
566
+ handler_config=handler_config,
567
+ request_context=ctx,
568
+ client=await self.http_pool_manager.get_client(base_url=base_url),
569
+ )
570
+
571
+ async def _convert_streaming_response(
572
+ self, response: StreamingResponse, format_chain: list[str], ctx: Any
573
+ ) -> StreamingResponse:
574
+ """Convert streaming response through reverse format chain."""
575
+ # Streaming responses are already converted inside DeferredStreaming
576
+ # via the configured format adapters; no additional work required here.
577
+ logger.debug(
578
+ "reverse_streaming_format_chain_disabled",
579
+ reason="complex_sse_parsing_disabled",
580
+ format_chain=format_chain,
581
+ )
582
+ return response
583
+
584
+ async def _map_request_model(self, ctx: Any, body: bytes) -> bytes:
585
+ """Apply provider model mapping to request payload if configured."""
586
+
587
+ mapper = getattr(self, "model_mapper", None)
588
+ if mapper is None and hasattr(self, "config"):
589
+ config_rules = getattr(self.config, "model_mappings", None)
590
+ if config_rules:
591
+ mapper = ModelMapper(config_rules)
592
+ self.model_mapper = mapper
593
+ if mapper is None or not getattr(mapper, "has_rules", False) or not body:
594
+ if body:
595
+ model_value = None
596
+ try:
597
+ parsed = json.loads(body.decode())
598
+ if isinstance(parsed, dict):
599
+ model_value = parsed.get("model")
600
+ except Exception:
601
+ model_value = None
602
+ logger.debug(
603
+ "model_mapper_missing",
604
+ has_mapper=bool(mapper),
605
+ has_rules=getattr(mapper, "has_rules", False),
606
+ request_id=getattr(ctx, "request_id", None),
607
+ client_model=model_value,
608
+ )
609
+ return body
610
+
611
+ try:
612
+ payload = json.loads(body.decode())
613
+ except Exception:
614
+ return body
615
+
616
+ if not isinstance(payload, dict):
617
+ return body
618
+
619
+ model_value = payload.get("model")
620
+ if not isinstance(model_value, str):
621
+ return body
622
+
623
+ match = mapper.map(model_value)
624
+ if match.mapped == match.original:
625
+ return body
626
+
627
+ metadata = getattr(ctx, "metadata", None)
628
+ if metadata is None or not isinstance(metadata, dict):
629
+ metadata = {}
630
+ ctx.metadata = metadata
631
+ logger.debug(
632
+ "model_mapping_metadata_initialized",
633
+ context_type=type(ctx).__name__,
634
+ )
635
+
636
+ add_model_alias(metadata, original=match.original, mapped=match.mapped)
637
+ alias_map_ctx = getattr(ctx, "_model_alias_map", None)
638
+ if not isinstance(alias_map_ctx, dict):
639
+ alias_map_ctx = {}
640
+ ctx._model_alias_map = alias_map_ctx
641
+ alias_map_ctx[match.mapped] = match.original
642
+ metadata["_last_client_model"] = match.original
643
+ metadata["_last_provider_model"] = match.mapped
644
+ payload["model"] = match.mapped
645
+
646
+ logger.debug(
647
+ "model_mapping_applied",
648
+ original_model=match.original,
649
+ mapped_model=match.mapped,
650
+ alias_map=alias_map_ctx,
651
+ category="model_mapping",
652
+ )
653
+
654
+ return self._encode_json_body(payload)
655
+
656
+ async def _emit_provider_request_prepared(
657
+ self,
658
+ *,
659
+ request_obj: Request | None,
660
+ ctx: Any,
661
+ method: str,
662
+ endpoint: str,
663
+ target_url: str,
664
+ prepared_body: bytes,
665
+ prepared_headers: dict[str, str],
666
+ is_streaming: bool,
667
+ ) -> tuple[str, str, bytes, dict[str, str]]:
668
+ """Emit hook before provider request is dispatched, allowing mutation."""
669
+
670
+ hook_manager = getattr(self.http_pool_manager, "hook_manager", None)
671
+ if not hook_manager:
672
+ return method, target_url, prepared_body, prepared_headers
673
+
674
+ provider_name = getattr(self.config, "name", None)
675
+ body_for_hooks, body_kind = self._prepare_body_for_hook(prepared_body)
676
+ hook_data: dict[str, Any] = {
677
+ "method": method,
678
+ "url": target_url,
679
+ "headers": dict(prepared_headers),
680
+ "body": body_for_hooks,
681
+ "body_raw": None,
682
+ "original_body_raw": prepared_body,
683
+ "body_kind": body_kind,
684
+ "is_streaming": is_streaming,
685
+ "endpoint": endpoint,
686
+ }
687
+
688
+ hook_metadata: dict[str, Any] = {}
689
+ request_id = getattr(ctx, "request_id", None)
690
+ if request_id:
691
+ hook_metadata["request_id"] = request_id
692
+ if endpoint:
693
+ hook_metadata["endpoint"] = endpoint
694
+
695
+ ctx_metadata = getattr(ctx, "metadata", None)
696
+ if isinstance(ctx_metadata, dict):
697
+ provider_model = ctx_metadata.get(
698
+ "_last_provider_model"
699
+ ) or ctx_metadata.get("model")
700
+ if provider_model:
701
+ hook_metadata.setdefault("provider_model", provider_model)
702
+ client_model = ctx_metadata.get("_last_client_model")
703
+ if client_model:
704
+ hook_metadata.setdefault("client_model", client_model)
705
+ alias_map = ctx_metadata.get("_model_alias_map")
706
+ if isinstance(alias_map, dict) and alias_map:
707
+ hook_metadata.setdefault("_model_alias_map", dict(alias_map))
708
+
709
+ hook_context = HookContext(
710
+ event=HookEvent.PROVIDER_REQUEST_PREPARED,
711
+ timestamp=datetime.utcnow(),
712
+ data=hook_data,
713
+ metadata=hook_metadata,
714
+ request=request_obj,
715
+ provider=provider_name,
716
+ )
717
+
718
+ try:
719
+ await hook_manager.emit_with_context(hook_context, fire_and_forget=False)
720
+ except Exception as exc: # pragma: no cover - defensive fallback
721
+ logger.debug(
722
+ "provider_request_prepared_hook_failed",
723
+ provider=provider_name,
724
+ error=str(exc),
725
+ )
726
+ return method, target_url, prepared_body, prepared_headers
727
+
728
+ mutated = hook_context.data or {}
729
+ mutated_method = str(mutated.get("method", method))
730
+ mutated_url = str(mutated.get("url", target_url))
731
+ mutated_headers = self._coerce_hook_headers(
732
+ mutated.get("headers"),
733
+ prepared_headers,
734
+ )
735
+ mutated_body = self._coerce_hook_body(
736
+ mutated.get("body"),
737
+ mutated.get("body_kind", body_kind),
738
+ mutated.get("body_raw"),
739
+ prepared_body,
740
+ )
741
+
742
+ return mutated_method, mutated_url, mutated_body, mutated_headers
743
+
744
+ def _prepare_body_for_hook(self, body: bytes) -> tuple[Any, str]:
745
+ """Return hook-friendly body representation and its kind."""
746
+
747
+ if not body:
748
+ return b"", "bytes"
749
+
750
+ try:
751
+ decoded = body.decode()
752
+ except UnicodeDecodeError:
753
+ return body, "bytes"
754
+
755
+ try:
756
+ parsed = json.loads(decoded)
757
+ except json.JSONDecodeError:
758
+ return decoded, "text"
759
+
760
+ return parsed, "json"
761
+
762
+ def _coerce_hook_body(
763
+ self,
764
+ body: Any,
765
+ body_kind: str,
766
+ body_raw: Any,
767
+ original: bytes,
768
+ ) -> bytes:
769
+ """Convert hook-mutated body back to bytes safely."""
770
+
771
+ coerced_raw = self._ensure_bytes(body_raw)
772
+ if coerced_raw is not None:
773
+ return coerced_raw
774
+
775
+ converted = self._convert_hook_body_payload(body, body_kind)
776
+ if converted is not None and converted != original:
777
+ return converted
778
+
779
+ if converted is not None:
780
+ return converted
781
+
782
+ return original
783
+
784
+ def _ensure_bytes(self, value: Any) -> bytes | None:
785
+ """Best-effort conversion to bytes."""
786
+
787
+ if value is None:
788
+ return None
789
+ if isinstance(value, bytes):
790
+ return value
791
+ if isinstance(value, bytearray):
792
+ return bytes(value)
793
+ if isinstance(value, memoryview):
794
+ return value.tobytes()
795
+ if isinstance(value, str):
796
+ return value.encode()
797
+ return None
798
+
799
+ def _coerce_hook_headers(
800
+ self,
801
+ headers: Any,
802
+ original: dict[str, str],
803
+ ) -> dict[str, str]:
804
+ """Sanitize hook-mutated headers."""
805
+
806
+ if headers is None:
807
+ return original
808
+
809
+ items: Sequence[tuple[Any, Any]] | None = None
810
+ if isinstance(headers, Mapping):
811
+ items = list(headers.items())
812
+ elif isinstance(headers, Sequence):
813
+ try:
814
+ items = [tuple(pair) for pair in headers]
815
+ except Exception: # pragma: no cover - defensive
816
+ items = None
817
+
818
+ if not items:
819
+ return original
820
+
821
+ coerced: dict[str, str] = {}
822
+ for key, value in items:
823
+ try:
824
+ coerced_key = str(key).lower()
825
+ coerced_value = str(value)
826
+ except Exception:
827
+ logger.debug(
828
+ "provider_request_prepared_header_dropped",
829
+ header_key=key,
830
+ )
831
+ continue
832
+ coerced[coerced_key] = coerced_value
833
+
834
+ return coerced or original
835
+
836
+ def _convert_hook_body_payload(self, body: Any, body_kind: str) -> bytes | None:
837
+ """Convert hook-provided body payload into bytes when possible."""
838
+
839
+ if body is None:
840
+ return None
841
+
842
+ direct = self._ensure_bytes(body)
843
+ if direct is not None:
844
+ return direct
845
+
846
+ try:
847
+ if isinstance(body, dict | list) or body_kind == "json":
848
+ return json.dumps(body).encode()
849
+ if isinstance(body, int | float | bool):
850
+ return json.dumps(body).encode()
851
+ if isinstance(body, str):
852
+ return body.encode()
853
+ except (TypeError, ValueError) as exc:
854
+ logger.debug(
855
+ "provider_request_prepared_body_conversion_failed",
856
+ error=str(exc),
857
+ )
858
+ return None
859
+
860
+ logger.debug(
861
+ "provider_request_prepared_body_unmodified",
862
+ reason="unsupported_type",
863
+ body_type=type(body).__name__,
864
+ )
865
+ return None
866
+
867
+ def _restore_model_response(self, response: Response, ctx: Any) -> Response:
868
+ """Restore original model identifiers in JSON responses."""
869
+
870
+ metadata = getattr(ctx, "metadata", None)
871
+ if not isinstance(metadata, dict) or "_model_alias_map" not in metadata:
872
+ return response
873
+
874
+ try:
875
+ payload = self._decode_json_body(
876
+ cast(bytes, response.body), context="restore"
877
+ )
878
+ except ValueError:
879
+ return response
880
+
881
+ alias_map = (
882
+ metadata.get("_model_alias_map") if isinstance(metadata, dict) else None
883
+ )
884
+ if not alias_map:
885
+ alias_map = getattr(ctx, "_model_alias_map", None)
886
+ if (
887
+ isinstance(payload, dict)
888
+ and isinstance(alias_map, Mapping)
889
+ and isinstance(payload.get("model"), str)
890
+ ):
891
+ payload["model"] = alias_map.get(payload["model"], payload["model"])
892
+
893
+ restore_model_aliases(payload, metadata)
894
+ response.body = self._encode_json_body(payload)
895
+ return response
896
+
897
+ @abstractmethod
898
+ async def prepare_provider_request(
899
+ self, body: bytes, headers: dict[str, str], endpoint: str
900
+ ) -> tuple[bytes, dict[str, str]]:
901
+ """Provider prepares request. Headers have lowercase keys."""
902
+ pass
903
+
904
+ @abstractmethod
905
+ async def process_provider_response(
906
+ self, response: httpx.Response, endpoint: str
907
+ ) -> Response | StreamingResponse:
908
+ """Provider processes response."""
909
+ pass
910
+
911
+ @abstractmethod
912
+ async def get_target_url(self, endpoint: str) -> str:
913
+ """Get target URL for this provider."""
914
+ pass
915
+
916
+ async def _apply_format_chain(
917
+ self,
918
+ *,
919
+ data: dict[str, Any],
920
+ format_chain: list[str],
921
+ stage: Literal["request", "response", "error"],
922
+ ) -> dict[str, Any]:
923
+ if not self.format_registry:
924
+ raise RuntimeError("Format registry is not configured")
925
+
926
+ pairs = self._build_chain_pairs(format_chain, stage)
927
+ current = data
928
+ for step_index, (from_format, to_format) in enumerate(pairs, start=1):
929
+ adapter = self.format_registry.get(from_format, to_format)
930
+ logger.debug(
931
+ "format_chain_step_start",
932
+ from_format=from_format,
933
+ to_format=to_format,
934
+ stage=stage,
935
+ step=step_index,
936
+ )
937
+
938
+ if stage == "request":
939
+ current = await adapter.convert_request(current)
940
+ elif stage == "response":
941
+ current = await adapter.convert_response(current)
942
+ elif stage == "error":
943
+ current = await adapter.convert_error(current)
944
+ else: # pragma: no cover - defensive
945
+ raise ValueError(f"Unsupported format chain stage: {stage}")
946
+
947
+ logger.debug(
948
+ "format_chain_step_completed",
949
+ from_format=from_format,
950
+ to_format=to_format,
951
+ stage=stage,
952
+ step=step_index,
953
+ )
954
+
955
+ return current
956
+
957
+ def _build_chain_pairs(
958
+ self, format_chain: list[str], stage: Literal["request", "response", "error"]
959
+ ) -> list[tuple[str, str]]:
960
+ if len(format_chain) < 2:
961
+ return []
962
+
963
+ if stage == "response":
964
+ pairs = [
965
+ (format_chain[i + 1], format_chain[i])
966
+ for i in range(len(format_chain) - 1)
967
+ ]
968
+ pairs.reverse()
969
+ return pairs
970
+
971
+ return [
972
+ (format_chain[i], format_chain[i + 1]) for i in range(len(format_chain) - 1)
973
+ ]
974
+
975
+ def _ensure_format_registry(
976
+ self, format_chain: list[str] | None, endpoint: str
977
+ ) -> None:
978
+ """Ensure format registry is available when a format chain is provided."""
979
+
980
+ if format_chain and len(format_chain) > 1 and self.format_registry is None:
981
+ logger.error(
982
+ "format_registry_missing_for_chain",
983
+ endpoint=endpoint,
984
+ chain=format_chain,
985
+ category="format",
986
+ )
987
+ raise HTTPException(
988
+ status_code=500,
989
+ detail={
990
+ "error": {
991
+ "type": "configuration_error",
992
+ "message": "Format registry is not configured but a format chain was requested.",
993
+ "details": {
994
+ "endpoint": endpoint,
995
+ "format_chain": format_chain,
996
+ },
997
+ }
998
+ },
999
+ )
1000
+
1001
+ def _decode_json_body(self, body: bytes, *, context: str) -> dict[str, Any]:
1002
+ if not body:
1003
+ return {}
1004
+
1005
+ try:
1006
+ parsed = json.loads(body.decode())
1007
+ except (json.JSONDecodeError, UnicodeDecodeError) as exc: # pragma: no cover
1008
+ raise ValueError(f"{context} body is not valid JSON: {exc}") from exc
1009
+
1010
+ if not isinstance(parsed, dict):
1011
+ raise ValueError(
1012
+ f"{context} body must be a JSON object, got {type(parsed).__name__}"
1013
+ )
1014
+
1015
+ return parsed
1016
+
1017
+ def _encode_json_body(self, data: dict[str, Any]) -> bytes:
1018
+ try:
1019
+ return json.dumps(data).encode()
1020
+ except (TypeError, ValueError) as exc: # pragma: no cover - defensive
1021
+ raise ValueError(f"Failed to serialize format chain output: {exc}") from exc
1022
+
1023
+ async def _execute_http_request(
1024
+ self, method: str, url: str, headers: dict[str, str], body: bytes
1025
+ ) -> httpx.Response:
1026
+ """Execute HTTP request."""
1027
+ # Convert to canonical headers for HTTP
1028
+ canonical_headers = headers
1029
+
1030
+ # Get HTTP client
1031
+ client = await self.http_pool_manager.get_client()
1032
+
1033
+ # Execute
1034
+ response: httpx.Response = await client.request(
1035
+ method=method,
1036
+ url=url,
1037
+ headers=canonical_headers,
1038
+ content=body,
1039
+ timeout=120.0,
1040
+ )
1041
+ return response
1042
+
1043
+ async def cleanup(self) -> None:
1044
+ """Cleanup resources."""
1045
+ logger.debug("adapter_cleanup_completed")