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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (481) hide show
  1. ccproxy/api/__init__.py +1 -15
  2. ccproxy/api/app.py +439 -212
  3. ccproxy/api/bootstrap.py +30 -0
  4. ccproxy/api/decorators.py +85 -0
  5. ccproxy/api/dependencies.py +145 -176
  6. ccproxy/api/format_validation.py +54 -0
  7. ccproxy/api/middleware/cors.py +6 -3
  8. ccproxy/api/middleware/errors.py +402 -530
  9. ccproxy/api/middleware/hooks.py +563 -0
  10. ccproxy/api/middleware/normalize_headers.py +59 -0
  11. ccproxy/api/middleware/request_id.py +35 -16
  12. ccproxy/api/middleware/streaming_hooks.py +292 -0
  13. ccproxy/api/routes/__init__.py +5 -14
  14. ccproxy/api/routes/health.py +39 -672
  15. ccproxy/api/routes/plugins.py +277 -0
  16. ccproxy/auth/__init__.py +2 -19
  17. ccproxy/auth/bearer.py +25 -15
  18. ccproxy/auth/dependencies.py +123 -157
  19. ccproxy/auth/exceptions.py +0 -12
  20. ccproxy/auth/manager.py +35 -49
  21. ccproxy/auth/managers/__init__.py +10 -0
  22. ccproxy/auth/managers/base.py +523 -0
  23. ccproxy/auth/managers/base_enhanced.py +63 -0
  24. ccproxy/auth/managers/token_snapshot.py +77 -0
  25. ccproxy/auth/models/base.py +65 -0
  26. ccproxy/auth/models/credentials.py +40 -0
  27. ccproxy/auth/oauth/__init__.py +4 -18
  28. ccproxy/auth/oauth/base.py +533 -0
  29. ccproxy/auth/oauth/cli_errors.py +37 -0
  30. ccproxy/auth/oauth/flows.py +430 -0
  31. ccproxy/auth/oauth/protocol.py +366 -0
  32. ccproxy/auth/oauth/registry.py +408 -0
  33. ccproxy/auth/oauth/router.py +396 -0
  34. ccproxy/auth/oauth/routes.py +186 -113
  35. ccproxy/auth/oauth/session.py +151 -0
  36. ccproxy/auth/oauth/templates.py +342 -0
  37. ccproxy/auth/storage/__init__.py +2 -5
  38. ccproxy/auth/storage/base.py +279 -5
  39. ccproxy/auth/storage/generic.py +134 -0
  40. ccproxy/cli/__init__.py +1 -2
  41. ccproxy/cli/_settings_help.py +351 -0
  42. ccproxy/cli/commands/auth.py +1519 -793
  43. ccproxy/cli/commands/config/commands.py +209 -276
  44. ccproxy/cli/commands/plugins.py +669 -0
  45. ccproxy/cli/commands/serve.py +75 -810
  46. ccproxy/cli/commands/status.py +254 -0
  47. ccproxy/cli/decorators.py +83 -0
  48. ccproxy/cli/helpers.py +22 -60
  49. ccproxy/cli/main.py +359 -10
  50. ccproxy/cli/options/claude_options.py +0 -25
  51. ccproxy/config/__init__.py +7 -11
  52. ccproxy/config/core.py +227 -0
  53. ccproxy/config/env_generator.py +232 -0
  54. ccproxy/config/runtime.py +67 -0
  55. ccproxy/config/security.py +36 -3
  56. ccproxy/config/settings.py +382 -441
  57. ccproxy/config/toml_generator.py +299 -0
  58. ccproxy/config/utils.py +452 -0
  59. ccproxy/core/__init__.py +7 -271
  60. ccproxy/{_version.py → core/_version.py} +16 -3
  61. ccproxy/core/async_task_manager.py +516 -0
  62. ccproxy/core/async_utils.py +47 -14
  63. ccproxy/core/auth/__init__.py +6 -0
  64. ccproxy/core/constants.py +16 -50
  65. ccproxy/core/errors.py +53 -0
  66. ccproxy/core/id_utils.py +20 -0
  67. ccproxy/core/interfaces.py +16 -123
  68. ccproxy/core/logging.py +473 -18
  69. ccproxy/core/plugins/__init__.py +77 -0
  70. ccproxy/core/plugins/cli_discovery.py +211 -0
  71. ccproxy/core/plugins/declaration.py +455 -0
  72. ccproxy/core/plugins/discovery.py +604 -0
  73. ccproxy/core/plugins/factories.py +967 -0
  74. ccproxy/core/plugins/hooks/__init__.py +30 -0
  75. ccproxy/core/plugins/hooks/base.py +58 -0
  76. ccproxy/core/plugins/hooks/events.py +46 -0
  77. ccproxy/core/plugins/hooks/implementations/__init__.py +16 -0
  78. ccproxy/core/plugins/hooks/implementations/formatters/__init__.py +11 -0
  79. ccproxy/core/plugins/hooks/implementations/formatters/json.py +552 -0
  80. ccproxy/core/plugins/hooks/implementations/formatters/raw.py +370 -0
  81. ccproxy/core/plugins/hooks/implementations/http_tracer.py +431 -0
  82. ccproxy/core/plugins/hooks/layers.py +44 -0
  83. ccproxy/core/plugins/hooks/manager.py +186 -0
  84. ccproxy/core/plugins/hooks/registry.py +139 -0
  85. ccproxy/core/plugins/hooks/thread_manager.py +203 -0
  86. ccproxy/core/plugins/hooks/types.py +22 -0
  87. ccproxy/core/plugins/interfaces.py +416 -0
  88. ccproxy/core/plugins/loader.py +166 -0
  89. ccproxy/core/plugins/middleware.py +233 -0
  90. ccproxy/core/plugins/models.py +59 -0
  91. ccproxy/core/plugins/protocol.py +180 -0
  92. ccproxy/core/plugins/runtime.py +519 -0
  93. ccproxy/{observability/context.py → core/request_context.py} +137 -94
  94. ccproxy/core/status_report.py +211 -0
  95. ccproxy/core/transformers.py +13 -8
  96. ccproxy/data/claude_headers_fallback.json +558 -0
  97. ccproxy/data/codex_headers_fallback.json +121 -0
  98. ccproxy/http/__init__.py +30 -0
  99. ccproxy/http/base.py +95 -0
  100. ccproxy/http/client.py +323 -0
  101. ccproxy/http/hooks.py +642 -0
  102. ccproxy/http/pool.py +279 -0
  103. ccproxy/llms/formatters/__init__.py +7 -0
  104. ccproxy/llms/formatters/anthropic_to_openai/__init__.py +55 -0
  105. ccproxy/llms/formatters/anthropic_to_openai/errors.py +65 -0
  106. ccproxy/llms/formatters/anthropic_to_openai/requests.py +356 -0
  107. ccproxy/llms/formatters/anthropic_to_openai/responses.py +153 -0
  108. ccproxy/llms/formatters/anthropic_to_openai/streams.py +1546 -0
  109. ccproxy/llms/formatters/base.py +140 -0
  110. ccproxy/llms/formatters/base_model.py +33 -0
  111. ccproxy/llms/formatters/common/__init__.py +51 -0
  112. ccproxy/llms/formatters/common/identifiers.py +48 -0
  113. ccproxy/llms/formatters/common/streams.py +254 -0
  114. ccproxy/llms/formatters/common/thinking.py +74 -0
  115. ccproxy/llms/formatters/common/usage.py +135 -0
  116. ccproxy/llms/formatters/constants.py +55 -0
  117. ccproxy/llms/formatters/context.py +116 -0
  118. ccproxy/llms/formatters/mapping.py +33 -0
  119. ccproxy/llms/formatters/openai_to_anthropic/__init__.py +55 -0
  120. ccproxy/llms/formatters/openai_to_anthropic/_helpers.py +141 -0
  121. ccproxy/llms/formatters/openai_to_anthropic/errors.py +53 -0
  122. ccproxy/llms/formatters/openai_to_anthropic/requests.py +674 -0
  123. ccproxy/llms/formatters/openai_to_anthropic/responses.py +285 -0
  124. ccproxy/llms/formatters/openai_to_anthropic/streams.py +530 -0
  125. ccproxy/llms/formatters/openai_to_openai/__init__.py +53 -0
  126. ccproxy/llms/formatters/openai_to_openai/_helpers.py +325 -0
  127. ccproxy/llms/formatters/openai_to_openai/errors.py +6 -0
  128. ccproxy/llms/formatters/openai_to_openai/requests.py +388 -0
  129. ccproxy/llms/formatters/openai_to_openai/responses.py +594 -0
  130. ccproxy/llms/formatters/openai_to_openai/streams.py +1832 -0
  131. ccproxy/llms/formatters/utils.py +306 -0
  132. ccproxy/llms/models/__init__.py +9 -0
  133. ccproxy/llms/models/anthropic.py +619 -0
  134. ccproxy/llms/models/openai.py +844 -0
  135. ccproxy/llms/streaming/__init__.py +26 -0
  136. ccproxy/llms/streaming/accumulators.py +1074 -0
  137. ccproxy/llms/streaming/formatters.py +251 -0
  138. ccproxy/{adapters/openai/streaming.py → llms/streaming/processors.py} +193 -240
  139. ccproxy/models/__init__.py +8 -159
  140. ccproxy/models/detection.py +92 -193
  141. ccproxy/models/provider.py +75 -0
  142. ccproxy/plugins/access_log/README.md +32 -0
  143. ccproxy/plugins/access_log/__init__.py +20 -0
  144. ccproxy/plugins/access_log/config.py +33 -0
  145. ccproxy/plugins/access_log/formatter.py +126 -0
  146. ccproxy/plugins/access_log/hook.py +763 -0
  147. ccproxy/plugins/access_log/logger.py +254 -0
  148. ccproxy/plugins/access_log/plugin.py +137 -0
  149. ccproxy/plugins/access_log/writer.py +109 -0
  150. ccproxy/plugins/analytics/README.md +24 -0
  151. ccproxy/plugins/analytics/__init__.py +1 -0
  152. ccproxy/plugins/analytics/config.py +5 -0
  153. ccproxy/plugins/analytics/ingest.py +85 -0
  154. ccproxy/plugins/analytics/models.py +97 -0
  155. ccproxy/plugins/analytics/plugin.py +121 -0
  156. ccproxy/plugins/analytics/routes.py +163 -0
  157. ccproxy/plugins/analytics/service.py +284 -0
  158. ccproxy/plugins/claude_api/README.md +29 -0
  159. ccproxy/plugins/claude_api/__init__.py +10 -0
  160. ccproxy/plugins/claude_api/adapter.py +829 -0
  161. ccproxy/plugins/claude_api/config.py +52 -0
  162. ccproxy/plugins/claude_api/detection_service.py +461 -0
  163. ccproxy/plugins/claude_api/health.py +175 -0
  164. ccproxy/plugins/claude_api/hooks.py +284 -0
  165. ccproxy/plugins/claude_api/models.py +256 -0
  166. ccproxy/plugins/claude_api/plugin.py +298 -0
  167. ccproxy/plugins/claude_api/routes.py +118 -0
  168. ccproxy/plugins/claude_api/streaming_metrics.py +68 -0
  169. ccproxy/plugins/claude_api/tasks.py +84 -0
  170. ccproxy/plugins/claude_sdk/README.md +35 -0
  171. ccproxy/plugins/claude_sdk/__init__.py +80 -0
  172. ccproxy/plugins/claude_sdk/adapter.py +749 -0
  173. ccproxy/plugins/claude_sdk/auth.py +57 -0
  174. ccproxy/{claude_sdk → plugins/claude_sdk}/client.py +63 -39
  175. ccproxy/plugins/claude_sdk/config.py +210 -0
  176. ccproxy/{claude_sdk → plugins/claude_sdk}/converter.py +6 -6
  177. ccproxy/plugins/claude_sdk/detection_service.py +163 -0
  178. ccproxy/{services/claude_sdk_service.py → plugins/claude_sdk/handler.py} +123 -304
  179. ccproxy/plugins/claude_sdk/health.py +113 -0
  180. ccproxy/plugins/claude_sdk/hooks.py +115 -0
  181. ccproxy/{claude_sdk → plugins/claude_sdk}/manager.py +42 -32
  182. ccproxy/{claude_sdk → plugins/claude_sdk}/message_queue.py +8 -8
  183. ccproxy/{models/claude_sdk.py → plugins/claude_sdk/models.py} +64 -16
  184. ccproxy/plugins/claude_sdk/options.py +154 -0
  185. ccproxy/{claude_sdk → plugins/claude_sdk}/parser.py +23 -5
  186. ccproxy/plugins/claude_sdk/plugin.py +269 -0
  187. ccproxy/plugins/claude_sdk/routes.py +104 -0
  188. ccproxy/{claude_sdk → plugins/claude_sdk}/session_client.py +124 -12
  189. ccproxy/plugins/claude_sdk/session_pool.py +700 -0
  190. ccproxy/{claude_sdk → plugins/claude_sdk}/stream_handle.py +48 -43
  191. ccproxy/{claude_sdk → plugins/claude_sdk}/stream_worker.py +22 -18
  192. ccproxy/{claude_sdk → plugins/claude_sdk}/streaming.py +50 -16
  193. ccproxy/plugins/claude_sdk/tasks.py +97 -0
  194. ccproxy/plugins/claude_shared/README.md +18 -0
  195. ccproxy/plugins/claude_shared/__init__.py +12 -0
  196. ccproxy/plugins/claude_shared/model_defaults.py +171 -0
  197. ccproxy/plugins/codex/README.md +35 -0
  198. ccproxy/plugins/codex/__init__.py +6 -0
  199. ccproxy/plugins/codex/adapter.py +635 -0
  200. ccproxy/{config/codex.py → plugins/codex/config.py} +78 -12
  201. ccproxy/plugins/codex/detection_service.py +544 -0
  202. ccproxy/plugins/codex/health.py +162 -0
  203. ccproxy/plugins/codex/hooks.py +263 -0
  204. ccproxy/plugins/codex/model_defaults.py +39 -0
  205. ccproxy/plugins/codex/models.py +263 -0
  206. ccproxy/plugins/codex/plugin.py +275 -0
  207. ccproxy/plugins/codex/routes.py +129 -0
  208. ccproxy/plugins/codex/streaming_metrics.py +324 -0
  209. ccproxy/plugins/codex/tasks.py +106 -0
  210. ccproxy/plugins/codex/utils/__init__.py +1 -0
  211. ccproxy/plugins/codex/utils/sse_parser.py +106 -0
  212. ccproxy/plugins/command_replay/README.md +34 -0
  213. ccproxy/plugins/command_replay/__init__.py +17 -0
  214. ccproxy/plugins/command_replay/config.py +133 -0
  215. ccproxy/plugins/command_replay/formatter.py +432 -0
  216. ccproxy/plugins/command_replay/hook.py +294 -0
  217. ccproxy/plugins/command_replay/plugin.py +161 -0
  218. ccproxy/plugins/copilot/README.md +39 -0
  219. ccproxy/plugins/copilot/__init__.py +11 -0
  220. ccproxy/plugins/copilot/adapter.py +465 -0
  221. ccproxy/plugins/copilot/config.py +155 -0
  222. ccproxy/plugins/copilot/data/copilot_fallback.json +41 -0
  223. ccproxy/plugins/copilot/detection_service.py +255 -0
  224. ccproxy/plugins/copilot/manager.py +275 -0
  225. ccproxy/plugins/copilot/model_defaults.py +284 -0
  226. ccproxy/plugins/copilot/models.py +148 -0
  227. ccproxy/plugins/copilot/oauth/__init__.py +16 -0
  228. ccproxy/plugins/copilot/oauth/client.py +494 -0
  229. ccproxy/plugins/copilot/oauth/models.py +385 -0
  230. ccproxy/plugins/copilot/oauth/provider.py +602 -0
  231. ccproxy/plugins/copilot/oauth/storage.py +170 -0
  232. ccproxy/plugins/copilot/plugin.py +360 -0
  233. ccproxy/plugins/copilot/routes.py +294 -0
  234. ccproxy/plugins/credential_balancer/README.md +124 -0
  235. ccproxy/plugins/credential_balancer/__init__.py +6 -0
  236. ccproxy/plugins/credential_balancer/config.py +270 -0
  237. ccproxy/plugins/credential_balancer/factory.py +415 -0
  238. ccproxy/plugins/credential_balancer/hook.py +51 -0
  239. ccproxy/plugins/credential_balancer/manager.py +587 -0
  240. ccproxy/plugins/credential_balancer/plugin.py +146 -0
  241. ccproxy/plugins/dashboard/README.md +25 -0
  242. ccproxy/plugins/dashboard/__init__.py +1 -0
  243. ccproxy/plugins/dashboard/config.py +8 -0
  244. ccproxy/plugins/dashboard/plugin.py +71 -0
  245. ccproxy/plugins/dashboard/routes.py +67 -0
  246. ccproxy/plugins/docker/README.md +32 -0
  247. ccproxy/{docker → plugins/docker}/__init__.py +3 -0
  248. ccproxy/{docker → plugins/docker}/adapter.py +108 -10
  249. ccproxy/plugins/docker/config.py +82 -0
  250. ccproxy/{docker → plugins/docker}/docker_path.py +4 -3
  251. ccproxy/{docker → plugins/docker}/middleware.py +2 -2
  252. ccproxy/plugins/docker/plugin.py +198 -0
  253. ccproxy/{docker → plugins/docker}/stream_process.py +3 -3
  254. ccproxy/plugins/duckdb_storage/README.md +26 -0
  255. ccproxy/plugins/duckdb_storage/__init__.py +1 -0
  256. ccproxy/plugins/duckdb_storage/config.py +22 -0
  257. ccproxy/plugins/duckdb_storage/plugin.py +128 -0
  258. ccproxy/plugins/duckdb_storage/routes.py +51 -0
  259. ccproxy/plugins/duckdb_storage/storage.py +633 -0
  260. ccproxy/plugins/max_tokens/README.md +38 -0
  261. ccproxy/plugins/max_tokens/__init__.py +12 -0
  262. ccproxy/plugins/max_tokens/adapter.py +235 -0
  263. ccproxy/plugins/max_tokens/config.py +86 -0
  264. ccproxy/plugins/max_tokens/models.py +53 -0
  265. ccproxy/plugins/max_tokens/plugin.py +200 -0
  266. ccproxy/plugins/max_tokens/service.py +271 -0
  267. ccproxy/plugins/max_tokens/token_limits.json +54 -0
  268. ccproxy/plugins/metrics/README.md +35 -0
  269. ccproxy/plugins/metrics/__init__.py +10 -0
  270. ccproxy/{observability/metrics.py → plugins/metrics/collector.py} +20 -153
  271. ccproxy/plugins/metrics/config.py +85 -0
  272. ccproxy/plugins/metrics/grafana/dashboards/ccproxy-dashboard.json +1720 -0
  273. ccproxy/plugins/metrics/hook.py +403 -0
  274. ccproxy/plugins/metrics/plugin.py +268 -0
  275. ccproxy/{observability → plugins/metrics}/pushgateway.py +57 -59
  276. ccproxy/plugins/metrics/routes.py +107 -0
  277. ccproxy/plugins/metrics/tasks.py +117 -0
  278. ccproxy/plugins/oauth_claude/README.md +35 -0
  279. ccproxy/plugins/oauth_claude/__init__.py +14 -0
  280. ccproxy/plugins/oauth_claude/client.py +270 -0
  281. ccproxy/plugins/oauth_claude/config.py +84 -0
  282. ccproxy/plugins/oauth_claude/manager.py +482 -0
  283. ccproxy/plugins/oauth_claude/models.py +266 -0
  284. ccproxy/plugins/oauth_claude/plugin.py +149 -0
  285. ccproxy/plugins/oauth_claude/provider.py +571 -0
  286. ccproxy/plugins/oauth_claude/storage.py +212 -0
  287. ccproxy/plugins/oauth_codex/README.md +38 -0
  288. ccproxy/plugins/oauth_codex/__init__.py +14 -0
  289. ccproxy/plugins/oauth_codex/client.py +224 -0
  290. ccproxy/plugins/oauth_codex/config.py +95 -0
  291. ccproxy/plugins/oauth_codex/manager.py +256 -0
  292. ccproxy/plugins/oauth_codex/models.py +239 -0
  293. ccproxy/plugins/oauth_codex/plugin.py +146 -0
  294. ccproxy/plugins/oauth_codex/provider.py +574 -0
  295. ccproxy/plugins/oauth_codex/storage.py +92 -0
  296. ccproxy/plugins/permissions/README.md +28 -0
  297. ccproxy/plugins/permissions/__init__.py +22 -0
  298. ccproxy/plugins/permissions/config.py +28 -0
  299. ccproxy/{cli/commands/permission_handler.py → plugins/permissions/handlers/cli.py} +49 -25
  300. ccproxy/plugins/permissions/handlers/protocol.py +33 -0
  301. ccproxy/plugins/permissions/handlers/terminal.py +675 -0
  302. ccproxy/{api/routes → plugins/permissions}/mcp.py +34 -7
  303. ccproxy/{models/permissions.py → plugins/permissions/models.py} +65 -1
  304. ccproxy/plugins/permissions/plugin.py +153 -0
  305. ccproxy/{api/routes/permissions.py → plugins/permissions/routes.py} +20 -16
  306. ccproxy/{api/services/permission_service.py → plugins/permissions/service.py} +65 -11
  307. ccproxy/{api → plugins/permissions}/ui/permission_handler_protocol.py +1 -1
  308. ccproxy/{api → plugins/permissions}/ui/terminal_permission_handler.py +66 -10
  309. ccproxy/plugins/pricing/README.md +34 -0
  310. ccproxy/plugins/pricing/__init__.py +6 -0
  311. ccproxy/{pricing → plugins/pricing}/cache.py +7 -6
  312. ccproxy/{config/pricing.py → plugins/pricing/config.py} +32 -6
  313. ccproxy/plugins/pricing/exceptions.py +35 -0
  314. ccproxy/plugins/pricing/loader.py +440 -0
  315. ccproxy/{pricing → plugins/pricing}/models.py +13 -23
  316. ccproxy/plugins/pricing/plugin.py +169 -0
  317. ccproxy/plugins/pricing/service.py +191 -0
  318. ccproxy/plugins/pricing/tasks.py +300 -0
  319. ccproxy/{pricing → plugins/pricing}/updater.py +86 -72
  320. ccproxy/plugins/pricing/utils.py +99 -0
  321. ccproxy/plugins/request_tracer/README.md +40 -0
  322. ccproxy/plugins/request_tracer/__init__.py +7 -0
  323. ccproxy/plugins/request_tracer/config.py +120 -0
  324. ccproxy/plugins/request_tracer/hook.py +415 -0
  325. ccproxy/plugins/request_tracer/plugin.py +255 -0
  326. ccproxy/scheduler/__init__.py +2 -14
  327. ccproxy/scheduler/core.py +26 -41
  328. ccproxy/scheduler/manager.py +63 -107
  329. ccproxy/scheduler/registry.py +6 -32
  330. ccproxy/scheduler/tasks.py +346 -314
  331. ccproxy/services/__init__.py +0 -1
  332. ccproxy/services/adapters/__init__.py +11 -0
  333. ccproxy/services/adapters/base.py +123 -0
  334. ccproxy/services/adapters/chain_composer.py +88 -0
  335. ccproxy/services/adapters/chain_validation.py +44 -0
  336. ccproxy/services/adapters/chat_accumulator.py +200 -0
  337. ccproxy/services/adapters/delta_utils.py +142 -0
  338. ccproxy/services/adapters/format_adapter.py +136 -0
  339. ccproxy/services/adapters/format_context.py +11 -0
  340. ccproxy/services/adapters/format_registry.py +158 -0
  341. ccproxy/services/adapters/http_adapter.py +1045 -0
  342. ccproxy/services/adapters/mock_adapter.py +118 -0
  343. ccproxy/services/adapters/protocols.py +35 -0
  344. ccproxy/services/adapters/simple_converters.py +571 -0
  345. ccproxy/services/auth_registry.py +180 -0
  346. ccproxy/services/cache/__init__.py +6 -0
  347. ccproxy/services/cache/response_cache.py +261 -0
  348. ccproxy/services/cli_detection.py +437 -0
  349. ccproxy/services/config/__init__.py +6 -0
  350. ccproxy/services/config/proxy_configuration.py +111 -0
  351. ccproxy/services/container.py +256 -0
  352. ccproxy/services/factories.py +380 -0
  353. ccproxy/services/handler_config.py +76 -0
  354. ccproxy/services/interfaces.py +298 -0
  355. ccproxy/services/mocking/__init__.py +6 -0
  356. ccproxy/services/mocking/mock_handler.py +291 -0
  357. ccproxy/services/tracing/__init__.py +7 -0
  358. ccproxy/services/tracing/interfaces.py +61 -0
  359. ccproxy/services/tracing/null_tracer.py +57 -0
  360. ccproxy/streaming/__init__.py +23 -0
  361. ccproxy/streaming/buffer.py +1056 -0
  362. ccproxy/streaming/deferred.py +897 -0
  363. ccproxy/streaming/handler.py +117 -0
  364. ccproxy/streaming/interfaces.py +77 -0
  365. ccproxy/streaming/simple_adapter.py +39 -0
  366. ccproxy/streaming/sse.py +109 -0
  367. ccproxy/streaming/sse_parser.py +127 -0
  368. ccproxy/templates/__init__.py +6 -0
  369. ccproxy/templates/plugin_scaffold.py +695 -0
  370. ccproxy/testing/endpoints/__init__.py +33 -0
  371. ccproxy/testing/endpoints/cli.py +215 -0
  372. ccproxy/testing/endpoints/config.py +874 -0
  373. ccproxy/testing/endpoints/console.py +57 -0
  374. ccproxy/testing/endpoints/models.py +100 -0
  375. ccproxy/testing/endpoints/runner.py +1903 -0
  376. ccproxy/testing/endpoints/tools.py +308 -0
  377. ccproxy/testing/mock_responses.py +70 -1
  378. ccproxy/testing/response_handlers.py +20 -0
  379. ccproxy/utils/__init__.py +0 -6
  380. ccproxy/utils/binary_resolver.py +476 -0
  381. ccproxy/utils/caching.py +327 -0
  382. ccproxy/utils/cli_logging.py +101 -0
  383. ccproxy/utils/command_line.py +251 -0
  384. ccproxy/utils/headers.py +228 -0
  385. ccproxy/utils/model_mapper.py +120 -0
  386. ccproxy/utils/startup_helpers.py +95 -342
  387. ccproxy/utils/version_checker.py +279 -6
  388. ccproxy_api-0.2.0.dist-info/METADATA +212 -0
  389. ccproxy_api-0.2.0.dist-info/RECORD +417 -0
  390. {ccproxy_api-0.1.6.dist-info → ccproxy_api-0.2.0.dist-info}/WHEEL +1 -1
  391. ccproxy_api-0.2.0.dist-info/entry_points.txt +24 -0
  392. ccproxy/__init__.py +0 -4
  393. ccproxy/adapters/__init__.py +0 -11
  394. ccproxy/adapters/base.py +0 -80
  395. ccproxy/adapters/codex/__init__.py +0 -11
  396. ccproxy/adapters/openai/__init__.py +0 -42
  397. ccproxy/adapters/openai/adapter.py +0 -953
  398. ccproxy/adapters/openai/models.py +0 -412
  399. ccproxy/adapters/openai/response_adapter.py +0 -355
  400. ccproxy/adapters/openai/response_models.py +0 -178
  401. ccproxy/api/middleware/headers.py +0 -49
  402. ccproxy/api/middleware/logging.py +0 -180
  403. ccproxy/api/middleware/request_content_logging.py +0 -297
  404. ccproxy/api/middleware/server_header.py +0 -58
  405. ccproxy/api/responses.py +0 -89
  406. ccproxy/api/routes/claude.py +0 -371
  407. ccproxy/api/routes/codex.py +0 -1231
  408. ccproxy/api/routes/metrics.py +0 -1029
  409. ccproxy/api/routes/proxy.py +0 -211
  410. ccproxy/api/services/__init__.py +0 -6
  411. ccproxy/auth/conditional.py +0 -84
  412. ccproxy/auth/credentials_adapter.py +0 -93
  413. ccproxy/auth/models.py +0 -118
  414. ccproxy/auth/oauth/models.py +0 -48
  415. ccproxy/auth/openai/__init__.py +0 -13
  416. ccproxy/auth/openai/credentials.py +0 -166
  417. ccproxy/auth/openai/oauth_client.py +0 -334
  418. ccproxy/auth/openai/storage.py +0 -184
  419. ccproxy/auth/storage/json_file.py +0 -158
  420. ccproxy/auth/storage/keyring.py +0 -189
  421. ccproxy/claude_sdk/__init__.py +0 -18
  422. ccproxy/claude_sdk/options.py +0 -194
  423. ccproxy/claude_sdk/session_pool.py +0 -550
  424. ccproxy/cli/docker/__init__.py +0 -34
  425. ccproxy/cli/docker/adapter_factory.py +0 -157
  426. ccproxy/cli/docker/params.py +0 -274
  427. ccproxy/config/auth.py +0 -153
  428. ccproxy/config/claude.py +0 -348
  429. ccproxy/config/cors.py +0 -79
  430. ccproxy/config/discovery.py +0 -95
  431. ccproxy/config/docker_settings.py +0 -264
  432. ccproxy/config/observability.py +0 -158
  433. ccproxy/config/reverse_proxy.py +0 -31
  434. ccproxy/config/scheduler.py +0 -108
  435. ccproxy/config/server.py +0 -86
  436. ccproxy/config/validators.py +0 -231
  437. ccproxy/core/codex_transformers.py +0 -389
  438. ccproxy/core/http.py +0 -328
  439. ccproxy/core/http_transformers.py +0 -812
  440. ccproxy/core/proxy.py +0 -143
  441. ccproxy/core/validators.py +0 -288
  442. ccproxy/models/errors.py +0 -42
  443. ccproxy/models/messages.py +0 -269
  444. ccproxy/models/requests.py +0 -107
  445. ccproxy/models/responses.py +0 -270
  446. ccproxy/models/types.py +0 -102
  447. ccproxy/observability/__init__.py +0 -51
  448. ccproxy/observability/access_logger.py +0 -457
  449. ccproxy/observability/sse_events.py +0 -303
  450. ccproxy/observability/stats_printer.py +0 -753
  451. ccproxy/observability/storage/__init__.py +0 -1
  452. ccproxy/observability/storage/duckdb_simple.py +0 -677
  453. ccproxy/observability/storage/models.py +0 -70
  454. ccproxy/observability/streaming_response.py +0 -107
  455. ccproxy/pricing/__init__.py +0 -19
  456. ccproxy/pricing/loader.py +0 -251
  457. ccproxy/services/claude_detection_service.py +0 -269
  458. ccproxy/services/codex_detection_service.py +0 -263
  459. ccproxy/services/credentials/__init__.py +0 -55
  460. ccproxy/services/credentials/config.py +0 -105
  461. ccproxy/services/credentials/manager.py +0 -561
  462. ccproxy/services/credentials/oauth_client.py +0 -481
  463. ccproxy/services/proxy_service.py +0 -1827
  464. ccproxy/static/.keep +0 -0
  465. ccproxy/utils/cost_calculator.py +0 -210
  466. ccproxy/utils/disconnection_monitor.py +0 -83
  467. ccproxy/utils/model_mapping.py +0 -199
  468. ccproxy/utils/models_provider.py +0 -150
  469. ccproxy/utils/simple_request_logger.py +0 -284
  470. ccproxy/utils/streaming_metrics.py +0 -199
  471. ccproxy_api-0.1.6.dist-info/METADATA +0 -615
  472. ccproxy_api-0.1.6.dist-info/RECORD +0 -189
  473. ccproxy_api-0.1.6.dist-info/entry_points.txt +0 -4
  474. /ccproxy/{api/middleware/auth.py → auth/models/__init__.py} +0 -0
  475. /ccproxy/{claude_sdk → plugins/claude_sdk}/exceptions.py +0 -0
  476. /ccproxy/{docker → plugins/docker}/models.py +0 -0
  477. /ccproxy/{docker → plugins/docker}/protocol.py +0 -0
  478. /ccproxy/{docker → plugins/docker}/validators.py +0 -0
  479. /ccproxy/{auth/oauth/storage.py → plugins/permissions/handlers/__init__.py} +0 -0
  480. /ccproxy/{api → plugins/permissions}/ui/__init__.py +0 -0
  481. {ccproxy_api-0.1.6.dist-info → ccproxy_api-0.2.0.dist-info}/licenses/LICENSE +0 -0
@@ -6,5 +6,4 @@
6
6
  __all__ = [
7
7
  "ClaudeSDKService",
8
8
  "MetricsService",
9
- "ProxyService",
10
9
  ]
@@ -0,0 +1,11 @@
1
+ """Adapter subpackage exports."""
2
+
3
+ from .format_adapter import DictFormatAdapter, FormatAdapterProtocol
4
+ from .format_registry import FormatRegistry
5
+
6
+
7
+ __all__ = [
8
+ "FormatAdapterProtocol",
9
+ "DictFormatAdapter",
10
+ "FormatRegistry",
11
+ ]
@@ -0,0 +1,123 @@
1
+ """Base adapter for provider plugins."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import Any
5
+
6
+ from fastapi import Request
7
+ from starlette.responses import Response, StreamingResponse
8
+
9
+ from ccproxy.streaming import DeferredStreaming
10
+
11
+
12
+ class BaseAdapter(ABC):
13
+ """Base adapter for provider-specific request handling."""
14
+
15
+ def __init__(self, config: Any, **kwargs: Any) -> None:
16
+ """Initialize the base adapter.
17
+
18
+ Args:
19
+ config: Plugin configuration
20
+ **kwargs: Additional keyword arguments for subclasses
21
+ """
22
+ self.config = config
23
+ self.tool_accumulator_class = kwargs.pop("tool_accumulator_class", None)
24
+
25
+ @abstractmethod
26
+ async def handle_request(
27
+ self, request: Request
28
+ ) -> Response | StreamingResponse | DeferredStreaming:
29
+ """Handle a provider-specific request.
30
+
31
+ Args:
32
+ request: FastAPI request object with endpoint and method in request.state.context
33
+
34
+ Returns:
35
+ Response, StreamingResponse, or DeferredStreaming object
36
+ """
37
+ ...
38
+
39
+ @abstractmethod
40
+ async def handle_streaming(
41
+ self, request: Request, endpoint: str, **kwargs: Any
42
+ ) -> StreamingResponse | DeferredStreaming:
43
+ """Handle a streaming request.
44
+
45
+ Args:
46
+ request: FastAPI request object
47
+ endpoint: Target endpoint path
48
+ **kwargs: Additional provider-specific arguments
49
+
50
+ Returns:
51
+ StreamingResponse or DeferredStreaming object
52
+ """
53
+ ...
54
+
55
+ async def validate_request(
56
+ self, request: Request, endpoint: str
57
+ ) -> dict[str, Any] | None:
58
+ """Validate request before processing.
59
+
60
+ Args:
61
+ request: FastAPI request object
62
+ endpoint: Target endpoint path
63
+
64
+ Returns:
65
+ Validation result or None if valid
66
+ """
67
+ return None
68
+
69
+ async def transform_request(self, request_data: dict[str, Any]) -> dict[str, Any]:
70
+ """Transform request data if needed.
71
+
72
+ Args:
73
+ request_data: Original request data
74
+
75
+ Returns:
76
+ Transformed request data
77
+ """
78
+ return request_data
79
+
80
+ async def transform_response(self, response_data: dict[str, Any]) -> dict[str, Any]:
81
+ """Transform response data if needed.
82
+
83
+ Args:
84
+ response_data: Original response data
85
+
86
+ Returns:
87
+ Transformed response data
88
+ """
89
+ return response_data
90
+
91
+ def _ensure_tool_accumulator(self, request_context: Any) -> None:
92
+ """Attach tool accumulator metadata to the request context if available."""
93
+
94
+ if not self.tool_accumulator_class or not request_context:
95
+ return
96
+
97
+ if getattr(request_context, "_tool_accumulator_class", None) is None:
98
+ request_context._tool_accumulator_class = self.tool_accumulator_class
99
+
100
+ @staticmethod
101
+ def _record_tool_definitions(request_context: Any, payload: Any) -> None:
102
+ """Persist tool definitions on the request context for downstream consumers."""
103
+
104
+ if not request_context or not isinstance(payload, dict):
105
+ return
106
+
107
+ metadata = getattr(request_context, "metadata", None)
108
+ if not isinstance(metadata, dict):
109
+ return
110
+
111
+ tools = payload.get("tools")
112
+ if tools and "_tool_definitions" not in metadata:
113
+ metadata["_tool_definitions"] = tools
114
+
115
+ @abstractmethod
116
+ async def cleanup(self) -> None:
117
+ """Cleanup adapter resources.
118
+
119
+ This method should be overridden by concrete adapters to clean up
120
+ any resources like HTTP clients, sessions, or background tasks.
121
+ Called during application shutdown.
122
+ """
123
+ ...
@@ -0,0 +1,88 @@
1
+ from __future__ import annotations
2
+
3
+ import contextlib
4
+ from collections.abc import AsyncIterator
5
+ from typing import Any, Literal
6
+
7
+ from .format_adapter import DictFormatAdapter, FormatAdapterProtocol
8
+ from .format_registry import FormatRegistry
9
+
10
+
11
+ class ComposedAdapter(DictFormatAdapter):
12
+ """A DictFormatAdapter composed from multiple pairwise adapters."""
13
+
14
+ pass
15
+
16
+
17
+ def _pairs_from_chain(
18
+ chain: list[str], stage: Literal["request", "response", "error", "stream"]
19
+ ) -> list[tuple[str, str]]:
20
+ if len(chain) < 2:
21
+ return []
22
+ # For responses and streaming, convert from provider format (tail) back to client format (head)
23
+ if stage in ("response", "error", "stream"):
24
+ pairs = [(chain[i + 1], chain[i]) for i in range(len(chain) - 1)]
25
+ pairs.reverse()
26
+ return pairs
27
+ # Requests go forward (client -> provider)
28
+ return [(chain[i], chain[i + 1]) for i in range(len(chain) - 1)]
29
+
30
+
31
+ def compose_from_chain(
32
+ *,
33
+ registry: FormatRegistry,
34
+ chain: list[str],
35
+ name: str | None = None,
36
+ ) -> FormatAdapterProtocol:
37
+ """Compose a FormatAdapter from a format_chain using the registry.
38
+
39
+ The composed adapter sequentially applies the per‑pair adapters for request,
40
+ response, error, and stream stages.
41
+ """
42
+
43
+ async def _compose_stage(
44
+ data: dict[str, Any], stage: Literal["request", "response", "error"]
45
+ ) -> dict[str, Any]:
46
+ current = data
47
+ for src, dst in _pairs_from_chain(chain, stage):
48
+ adapter = registry.get(src, dst)
49
+ if stage == "request":
50
+ current = await adapter.convert_request(current)
51
+ elif stage == "response":
52
+ current = await adapter.convert_response(current)
53
+ else:
54
+ # Default error passthrough if adapter lacks explicit error handling
55
+ with contextlib.suppress(NotImplementedError):
56
+ current = await adapter.convert_error(current)
57
+ return current
58
+
59
+ async def _request(data: dict[str, Any]) -> dict[str, Any]:
60
+ return await _compose_stage(data, "request")
61
+
62
+ async def _response(data: dict[str, Any]) -> dict[str, Any]:
63
+ return await _compose_stage(data, "response")
64
+
65
+ async def _error(data: dict[str, Any]) -> dict[str, Any]:
66
+ return await _compose_stage(data, "error")
67
+
68
+ async def _stream(
69
+ stream: AsyncIterator[dict[str, Any]],
70
+ ) -> AsyncIterator[dict[str, Any]]:
71
+ # Pipe the stream through each pairwise adapter's convert_stream
72
+ current_stream = stream
73
+ for src, dst in _pairs_from_chain(chain, "stream"):
74
+ adapter = registry.get(src, dst)
75
+ current_stream = adapter.convert_stream(current_stream)
76
+ async for item in current_stream:
77
+ yield item
78
+
79
+ return ComposedAdapter(
80
+ request=_request,
81
+ response=_response,
82
+ error=_error,
83
+ stream=_stream,
84
+ name=name or f"ComposedAdapter({' -> '.join(chain)})",
85
+ )
86
+
87
+
88
+ __all__ = ["compose_from_chain", "ComposedAdapter"]
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterable
4
+
5
+ from .format_registry import FormatRegistry
6
+
7
+
8
+ def validate_chains(
9
+ *, registry: FormatRegistry, chains: Iterable[list[str]]
10
+ ) -> list[str]:
11
+ """Validate that all adjacent pairs in chains exist in the registry.
12
+
13
+ Returns a list of human‑readable error strings for missing pairs.
14
+ """
15
+ errors: list[str] = []
16
+ pairs_needed: set[tuple[str, str]] = set()
17
+ for chain in chains:
18
+ if len(chain) >= 2:
19
+ for i in range(len(chain) - 1):
20
+ pairs_needed.add((chain[i], chain[i + 1]))
21
+ for src, dst in sorted(pairs_needed):
22
+ if registry.get_if_exists(src, dst) is None:
23
+ errors.append(f"Missing format adapter: {src} -> {dst}")
24
+ return errors
25
+
26
+
27
+ def validate_stream_pairs(
28
+ *, registry: FormatRegistry, chains: Iterable[list[str]]
29
+ ) -> list[str]:
30
+ """Validate reverse-direction pairs for streaming (provider→client)."""
31
+ missing: list[str] = []
32
+ for chain in chains:
33
+ if len(chain) < 2:
34
+ continue
35
+ reverse_pairs = list(
36
+ reversed([(chain[i + 1], chain[i]) for i in range(len(chain) - 1)])
37
+ )
38
+ for src, dst in reverse_pairs:
39
+ if registry.get_if_exists(src, dst) is None:
40
+ missing.append(f"Missing streaming adapter: {src} -> {dst}")
41
+ return missing
42
+
43
+
44
+ __all__ = ["validate_chains", "validate_stream_pairs"]
@@ -0,0 +1,200 @@
1
+ """ChatCompletion accumulator for OpenAI streaming format."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ from typing import Any
7
+
8
+ from .delta_utils import accumulate_delta
9
+
10
+
11
+ class ChatCompletionAccumulator:
12
+ """Accumulator for OpenAI ChatCompletion streaming format.
13
+
14
+ Handles partial tool calls and other streaming data by accumulating
15
+ chunks until complete objects are ready for validation.
16
+
17
+ Follows the OpenAI SDK ChatCompletionStreamManager pattern.
18
+ """
19
+
20
+ def __init__(self) -> None:
21
+ self._accumulated: dict[str, Any] = {}
22
+ self._done_tool_calls: set[int] = set()
23
+ self._current_tool_call_index: int | None = None
24
+
25
+ def accumulate_chunk(self, chunk: dict[str, Any]) -> dict[str, Any] | None:
26
+ """Accumulate a streaming chunk and return complete object if ready.
27
+
28
+ Args:
29
+ chunk: The incoming stream chunk data
30
+
31
+ Returns:
32
+ None if accumulation is ongoing, or the complete object when ready
33
+ for validation
34
+ """
35
+ # For chunks without tool calls, return immediately UNLESS we have accumulated state
36
+ # (in which case this might be a finish_reason chunk)
37
+ if not self._has_tool_calls(chunk) and not self._accumulated:
38
+ return chunk
39
+
40
+ # For the first chunk, copy the base structure
41
+ if not self._accumulated:
42
+ self._accumulated = copy.deepcopy(chunk)
43
+ else:
44
+ # For subsequent chunks, preserve base fields and only accumulate deltas
45
+ base_fields = {"id", "object", "created", "model"}
46
+ chunk_copy = copy.deepcopy(chunk)
47
+
48
+ # Remove base fields from chunk_copy to avoid concatenation
49
+ for field in base_fields:
50
+ if field in chunk_copy:
51
+ del chunk_copy[field]
52
+
53
+ # Use accumulate_delta for the remaining fields (choices, etc.)
54
+ self._accumulated = accumulate_delta(self._accumulated, chunk_copy)
55
+
56
+ # Track tool call progress if present
57
+ if self._has_tool_calls(chunk):
58
+ self._track_tool_call_progress(chunk)
59
+
60
+ # Don't validate if we have incomplete tool calls
61
+ if self._has_incomplete_tool_calls():
62
+ return None # Continue accumulating
63
+
64
+ # Return a copy for validation if chunk seems complete
65
+ if self._should_emit_chunk(chunk):
66
+ return copy.deepcopy(self._accumulated)
67
+
68
+ # Continue accumulating
69
+ return None
70
+
71
+ def reset(self) -> None:
72
+ """Reset accumulator state for next message."""
73
+ self._accumulated.clear()
74
+ self._done_tool_calls.clear()
75
+ self._current_tool_call_index = None
76
+
77
+ def _has_tool_calls(self, chunk: dict[str, Any]) -> bool:
78
+ """Check if chunk contains tool call data."""
79
+ if not isinstance(chunk, dict) or "choices" not in chunk:
80
+ return False
81
+
82
+ for choice in chunk.get("choices", []):
83
+ if not isinstance(choice, dict):
84
+ continue
85
+ delta = choice.get("delta", {})
86
+ if isinstance(delta, dict) and "tool_calls" in delta:
87
+ return True
88
+
89
+ return False
90
+
91
+ def _track_tool_call_progress(self, chunk: dict[str, Any]) -> None:
92
+ """Track progress of tool calls in this chunk."""
93
+ for choice in chunk.get("choices", []):
94
+ if not isinstance(choice, dict):
95
+ continue
96
+
97
+ delta = choice.get("delta", {})
98
+ if not isinstance(delta, dict):
99
+ continue
100
+
101
+ tool_calls = delta.get("tool_calls", [])
102
+ if not isinstance(tool_calls, list):
103
+ continue
104
+
105
+ for tool_call in tool_calls:
106
+ if not isinstance(tool_call, dict):
107
+ continue
108
+
109
+ # Track current tool call index
110
+ if "index" in tool_call:
111
+ self._current_tool_call_index = tool_call["index"]
112
+
113
+ # Mark tool call as done if it has complete structure
114
+ if self._is_tool_call_complete(tool_call):
115
+ index = tool_call.get("index", self._current_tool_call_index)
116
+ if index is not None:
117
+ self._done_tool_calls.add(index)
118
+
119
+ def _is_tool_call_complete(self, tool_call: dict[str, Any]) -> bool:
120
+ """Check if a tool call has all required fields."""
121
+ if not tool_call.get("id"):
122
+ return False
123
+
124
+ function = tool_call.get("function", {})
125
+ if not isinstance(function, dict):
126
+ return False
127
+
128
+ if not function.get("name"):
129
+ return False
130
+
131
+ # Arguments can be empty string, but should be present
132
+ return "arguments" in function
133
+
134
+ def _has_incomplete_tool_calls(self) -> bool:
135
+ """Check if accumulated state has incomplete tool calls."""
136
+ if not self._accumulated.get("choices"):
137
+ return False
138
+
139
+ for choice in self._accumulated["choices"]:
140
+ if not isinstance(choice, dict):
141
+ continue
142
+
143
+ delta = choice.get("delta", {})
144
+ if not isinstance(delta, dict):
145
+ continue
146
+
147
+ tool_calls = delta.get("tool_calls", [])
148
+ if not isinstance(tool_calls, list):
149
+ continue
150
+
151
+ for tool_call in tool_calls:
152
+ if not isinstance(tool_call, dict):
153
+ continue
154
+
155
+ # Check if this tool call is incomplete
156
+ if not self._is_tool_call_complete(tool_call):
157
+ return True
158
+
159
+ return False
160
+
161
+ def _should_emit_chunk(self, chunk: dict[str, Any]) -> bool:
162
+ """Determine if we should emit the accumulated chunk for validation.
163
+
164
+ We emit when:
165
+ 1. No tool calls are present (regular content)
166
+ 2. All tool calls in the accumulated state are complete AND we see a finish_reason
167
+ """
168
+ # If no tool calls in accumulated state, emit immediately
169
+ if not self._has_any_tool_calls_in_accumulated():
170
+ return True
171
+
172
+ # For tool calls, only emit when both conditions are met:
173
+ # 1. All tool calls are complete
174
+ # 2. We see a finish_reason (indicates end of tool call sequence)
175
+ has_finish_reason = False
176
+ for choice in chunk.get("choices", []):
177
+ if isinstance(choice, dict) and choice.get("finish_reason"):
178
+ has_finish_reason = True
179
+ break
180
+
181
+ return bool(has_finish_reason and not self._has_incomplete_tool_calls())
182
+
183
+ def _has_any_tool_calls_in_accumulated(self) -> bool:
184
+ """Check if accumulated state has any tool calls."""
185
+ if not self._accumulated.get("choices"):
186
+ return False
187
+
188
+ for choice in self._accumulated["choices"]:
189
+ if not isinstance(choice, dict):
190
+ continue
191
+
192
+ delta = choice.get("delta", {})
193
+ if not isinstance(delta, dict):
194
+ continue
195
+
196
+ tool_calls = delta.get("tool_calls", [])
197
+ if isinstance(tool_calls, list) and tool_calls:
198
+ return True
199
+
200
+ return False
@@ -0,0 +1,142 @@
1
+ """Delta accumulation utilities following OpenAI SDK patterns."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+
8
+ def accumulate_delta(
9
+ accumulated: dict[str, Any], delta: dict[str, Any]
10
+ ) -> dict[str, Any]:
11
+ """Recursively merge delta into accumulated following OpenAI's rules.
12
+
13
+ This function implements the same accumulation logic as OpenAI's SDK:
14
+ - Concatenate strings
15
+ - Add numbers (int/float)
16
+ - Recursively merge dictionaries
17
+ - Extend primitive lists
18
+ - Merge object lists by 'index' key
19
+ - Preserve 'index' and 'type' keys without modification
20
+
21
+ Args:
22
+ accumulated: The accumulated state to merge into
23
+ delta: The delta to merge
24
+
25
+ Returns:
26
+ The merged result (may modify accumulated in-place)
27
+
28
+ Raises:
29
+ TypeError: For unsupported data types
30
+ ValueError: For invalid list structures
31
+ """
32
+ # Handle None/empty cases
33
+ if not delta:
34
+ return accumulated
35
+ if not accumulated:
36
+ return dict(delta)
37
+
38
+ # Work on a copy to avoid mutating input
39
+ result = dict(accumulated)
40
+
41
+ for key, delta_value in delta.items():
42
+ if key not in result:
43
+ # New key, just set it
44
+ result[key] = delta_value
45
+ continue
46
+
47
+ current_value = result[key]
48
+
49
+ # Handle different data type combinations
50
+ if isinstance(current_value, str) and isinstance(delta_value, str):
51
+ # Concatenate strings
52
+ result[key] = current_value + delta_value
53
+
54
+ elif isinstance(current_value, int | float) and isinstance(
55
+ delta_value, int | float
56
+ ):
57
+ # Add numbers
58
+ result[key] = current_value + delta_value
59
+
60
+ elif isinstance(current_value, dict) and isinstance(delta_value, dict):
61
+ # Recursively merge dictionaries
62
+ result[key] = accumulate_delta(current_value, delta_value)
63
+
64
+ elif isinstance(current_value, list) and isinstance(delta_value, list):
65
+ # Handle list merging
66
+ result[key] = _accumulate_list(current_value, delta_value)
67
+
68
+ else:
69
+ # For any other case, delta value overwrites
70
+ result[key] = delta_value
71
+
72
+ return result
73
+
74
+
75
+ def _accumulate_list(current: list[Any], delta: list[Any]) -> list[Any]:
76
+ """Accumulate list values following OpenAI's patterns.
77
+
78
+ - For primitive lists: extend
79
+ - For object lists: merge by 'index' key
80
+
81
+ Args:
82
+ current: Current list value
83
+ delta: Delta list value
84
+
85
+ Returns:
86
+ Merged list
87
+
88
+ Raises:
89
+ ValueError: If object list entries are missing required 'index' key
90
+ """
91
+ if not delta:
92
+ return current
93
+ if not current:
94
+ return list(delta)
95
+
96
+ # Check if this is an object list (contains dicts with 'index')
97
+ has_indexed_objects = any(
98
+ isinstance(item, dict) and "index" in item for item in (current + delta)
99
+ )
100
+
101
+ if not has_indexed_objects:
102
+ # Primitive list - just extend
103
+ return current + delta
104
+
105
+ # Object list - merge by index
106
+ result = list(current)
107
+
108
+ for delta_item in delta:
109
+ if not isinstance(delta_item, dict):
110
+ # Mixed list types - append non-dict items
111
+ result.append(delta_item)
112
+ continue
113
+
114
+ if "index" not in delta_item:
115
+ raise ValueError("Dictionary in list delta must have 'index' key")
116
+
117
+ delta_index = delta_item["index"]
118
+
119
+ # Find existing item with same index
120
+ existing_item = None
121
+ existing_pos = None
122
+ for i, item in enumerate(result):
123
+ if isinstance(item, dict) and item.get("index") == delta_index:
124
+ existing_item = item
125
+ existing_pos = i
126
+ break
127
+
128
+ if existing_item is not None and existing_pos is not None:
129
+ # Merge with existing item, preserving special keys
130
+ merged = accumulate_delta(existing_item, delta_item)
131
+
132
+ # Preserve 'index' and 'type' from original if not in delta
133
+ for special_key in ["index", "type"]:
134
+ if special_key not in delta_item and special_key in existing_item:
135
+ merged[special_key] = existing_item[special_key]
136
+
137
+ result[existing_pos] = merged
138
+ else:
139
+ # New item - append to list
140
+ result.append(delta_item)
141
+
142
+ return result