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
@@ -0,0 +1,1903 @@
1
+ """Async endpoint test runner implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ import asyncio
7
+ import copy
8
+ import json
9
+ import re
10
+ from collections.abc import Iterable, Sequence
11
+ from typing import Any, Literal, overload
12
+
13
+ import httpx
14
+ import structlog
15
+
16
+ from ccproxy.llms.models.openai import ResponseMessage, ResponseObject
17
+ from ccproxy.llms.streaming.accumulators import StreamAccumulator
18
+
19
+ from .config import (
20
+ ENDPOINT_TESTS,
21
+ FORMAT_TOOLS,
22
+ PROVIDER_TOOL_ACCUMULATORS,
23
+ REQUEST_DATA,
24
+ )
25
+ from .console import (
26
+ colored_error,
27
+ colored_header,
28
+ colored_info,
29
+ colored_progress,
30
+ colored_success,
31
+ colored_warning,
32
+ )
33
+ from .models import (
34
+ EndpointRequestResult,
35
+ EndpointTest,
36
+ EndpointTestResult,
37
+ EndpointTestRunSummary,
38
+ )
39
+ from .tools import handle_tool_call
40
+
41
+
42
+ logger = structlog.get_logger(__name__)
43
+
44
+
45
+ def extract_thinking_blocks(content: str) -> list[tuple[str, str]]:
46
+ """Extract thinking blocks from content."""
47
+ thinking_pattern = r'<thinking signature="([^"]*)">(.*?)</thinking>'
48
+ matches = re.findall(thinking_pattern, content, re.DOTALL)
49
+ return matches
50
+
51
+
52
+ def extract_visible_content(content: str) -> str:
53
+ """Extract only the visible content (not thinking blocks)."""
54
+ thinking_pattern = r'<thinking signature="[^"]*">.*?</thinking>'
55
+ return re.sub(thinking_pattern, "", content, flags=re.DOTALL).strip()
56
+
57
+
58
+ def get_request_payload(test: EndpointTest) -> dict[str, Any]:
59
+ """Get formatted request payload for a test, excluding validation classes."""
60
+ template = REQUEST_DATA[test.request].copy()
61
+
62
+ validation_keys = {
63
+ "model_class",
64
+ "chunk_model_class",
65
+ "accumulator_class",
66
+ "api_format",
67
+ }
68
+ template = {k: v for k, v in template.items() if k not in validation_keys}
69
+
70
+ def format_value(value: Any) -> Any:
71
+ if isinstance(value, str):
72
+ return value.format(model=test.model)
73
+ if isinstance(value, dict):
74
+ return {k: format_value(v) for k, v in value.items()}
75
+ if isinstance(value, list):
76
+ return [format_value(item) for item in value]
77
+ return value
78
+
79
+ formatted_template = format_value(template)
80
+ # Type assertion for mypy - we know the format_value function preserves the dict type
81
+ return formatted_template # type: ignore[no-any-return]
82
+
83
+
84
+ class TestEndpoint:
85
+ """Test endpoint utility for CCProxy API testing."""
86
+
87
+ def __init__(
88
+ self,
89
+ base_url: str = "http://127.0.0.1:8000",
90
+ trace: bool = False,
91
+ *,
92
+ cors_origin: str | None = None,
93
+ default_headers: dict[str, str] | None = None,
94
+ client: httpx.AsyncClient | None = None,
95
+ ):
96
+ self.base_url = base_url
97
+ self.trace = trace
98
+ self.cors_origin = cors_origin
99
+ self.base_headers: dict[str, str] = {"Accept-Encoding": "identity"}
100
+
101
+ if default_headers:
102
+ self.base_headers.update(default_headers)
103
+
104
+ if self.cors_origin:
105
+ self.base_headers["Origin"] = self.cors_origin
106
+
107
+ if client is None:
108
+ self.client = httpx.AsyncClient(
109
+ timeout=30.0,
110
+ headers=self.base_headers.copy(),
111
+ )
112
+ else:
113
+ self.client = client
114
+ # Ensure client carries required defaults without overwriting explicit values
115
+ for key, value in self.base_headers.items():
116
+ if key not in self.client.headers:
117
+ self.client.headers[key] = value
118
+
119
+ async def __aenter__(self) -> TestEndpoint:
120
+ return self
121
+
122
+ async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: # noqa: D401
123
+ await self.client.aclose()
124
+
125
+ def _build_headers(self, extra: dict[str, Any] | None = None) -> dict[str, str]:
126
+ """Compose request headers for requests made by the tester."""
127
+
128
+ headers = self.base_headers.copy()
129
+ if extra:
130
+ headers.update(extra)
131
+ return headers
132
+
133
+ def extract_and_display_request_id(
134
+ self,
135
+ headers: dict[str, Any],
136
+ context: dict[str, Any] | None = None,
137
+ ) -> str | None:
138
+ """Extract request ID from response headers and display it."""
139
+ request_id_headers = [
140
+ "x-request-id",
141
+ "request-id",
142
+ "x-amzn-requestid",
143
+ "x-correlation-id",
144
+ "x-trace-id",
145
+ "traceparent",
146
+ ]
147
+
148
+ request_id = None
149
+ context_data = context or {}
150
+ for header_name in request_id_headers:
151
+ for key in [header_name, header_name.lower()]:
152
+ if key in headers:
153
+ request_id = headers[key]
154
+ break
155
+ if request_id:
156
+ break
157
+
158
+ if request_id:
159
+ print(colored_info(f"-> Request ID: {request_id}"))
160
+ logger.info(
161
+ "Request ID extracted",
162
+ request_id=request_id,
163
+ **context_data,
164
+ )
165
+ else:
166
+ logger.debug(
167
+ "No request ID found in headers",
168
+ available_headers=list(headers.keys()),
169
+ **context_data,
170
+ )
171
+
172
+ return request_id
173
+
174
+ @overload
175
+ async def post_json(
176
+ self,
177
+ url: str,
178
+ payload: dict[str, Any],
179
+ *,
180
+ context: dict[str, Any] | None = None,
181
+ headers: dict[str, str] | None = None,
182
+ capture_result: Literal[False] = False,
183
+ ) -> dict[str, Any]: ...
184
+
185
+ @overload
186
+ async def post_json(
187
+ self,
188
+ url: str,
189
+ payload: dict[str, Any],
190
+ *,
191
+ context: dict[str, Any] | None = None,
192
+ headers: dict[str, str] | None = None,
193
+ capture_result: Literal[True],
194
+ ) -> tuple[dict[str, Any], EndpointRequestResult]: ...
195
+
196
+ async def post_json(
197
+ self,
198
+ url: str,
199
+ payload: dict[str, Any],
200
+ *,
201
+ context: dict[str, Any] | None = None,
202
+ headers: dict[str, str] | None = None,
203
+ capture_result: bool = False,
204
+ ) -> dict[str, Any] | tuple[dict[str, Any], EndpointRequestResult]:
205
+ """Post JSON request and return parsed response."""
206
+ request_headers = self._build_headers({"Content-Type": "application/json"})
207
+ if headers:
208
+ request_headers.update(headers)
209
+
210
+ context_data = context or {}
211
+
212
+ print(colored_info(f"-> Making JSON request to {url}"))
213
+ logger.info(
214
+ "Making JSON request",
215
+ url=url,
216
+ payload_model=payload.get("model"),
217
+ payload_stream=payload.get("stream"),
218
+ **context_data,
219
+ )
220
+
221
+ response = await self.client.post(url, json=payload, headers=request_headers)
222
+
223
+ logger.info(
224
+ "Received JSON response",
225
+ status_code=response.status_code,
226
+ headers=dict(response.headers),
227
+ **context_data,
228
+ )
229
+
230
+ self.extract_and_display_request_id(
231
+ dict(response.headers), context=context_data
232
+ )
233
+
234
+ status_code = response.status_code
235
+ response_headers = dict(response.headers)
236
+
237
+ parsed_body: dict[str, Any]
238
+ if status_code != 200:
239
+ print(colored_error(f"[ERROR] Request failed: HTTP {status_code}"))
240
+ logger.error(
241
+ "Request failed",
242
+ status_code=status_code,
243
+ response_text=response.text,
244
+ **context_data,
245
+ )
246
+ parsed_body = {"error": f"HTTP {status_code}: {response.text}"}
247
+ else:
248
+ try:
249
+ json_response = response.json()
250
+ except json.JSONDecodeError as exc: # noqa: TRY003
251
+ logger.error(
252
+ "Failed to parse JSON response",
253
+ error=str(exc),
254
+ **context_data,
255
+ )
256
+ parsed_body = {"error": f"JSON decode error: {exc}"}
257
+ else:
258
+ parsed_body = json_response
259
+
260
+ request_result_details: dict[str, Any] = {
261
+ "headers": response_headers,
262
+ }
263
+ if isinstance(parsed_body, dict):
264
+ request_result_details["response"] = parsed_body
265
+ error_detail = parsed_body.get("error")
266
+ if error_detail:
267
+ request_result_details["error_detail"] = error_detail
268
+ else:
269
+ request_result_details["response"] = parsed_body
270
+
271
+ request_result = EndpointRequestResult(
272
+ phase=context_data.get("phase", "initial"),
273
+ method="POST",
274
+ status_code=status_code,
275
+ stream=False,
276
+ details=request_result_details,
277
+ )
278
+
279
+ if capture_result:
280
+ return parsed_body, request_result
281
+
282
+ return parsed_body
283
+
284
+ async def post_stream(
285
+ self,
286
+ url: str,
287
+ payload: dict[str, Any],
288
+ *,
289
+ context: dict[str, Any] | None = None,
290
+ headers: dict[str, str] | None = None,
291
+ ) -> tuple[list[str], list[EndpointRequestResult]]:
292
+ """Post streaming request and return list of SSE events."""
293
+ request_headers = self._build_headers(
294
+ {"Accept": "text/event-stream", "Content-Type": "application/json"}
295
+ )
296
+ if headers:
297
+ request_headers.update(headers)
298
+
299
+ context_data = context or {}
300
+
301
+ print(colored_info(f"-> Making streaming request to {url}"))
302
+ logger.info(
303
+ "Making streaming request",
304
+ url=url,
305
+ payload_model=payload.get("model"),
306
+ payload_stream=payload.get("stream"),
307
+ **context_data,
308
+ )
309
+
310
+ events: list[str] = []
311
+ raw_chunks: list[str] = []
312
+ request_results: list[EndpointRequestResult] = []
313
+ fallback_request_result: EndpointRequestResult | None = None
314
+ fallback_used = False
315
+ stream_status_code: int | None = None
316
+ primary_event_count = 0
317
+
318
+ try:
319
+ async with self.client.stream(
320
+ "POST", url, json=payload, headers=request_headers
321
+ ) as resp:
322
+ logger.info(
323
+ "Streaming response received",
324
+ status_code=resp.status_code,
325
+ headers=dict(resp.headers),
326
+ **context_data,
327
+ )
328
+
329
+ self.extract_and_display_request_id(
330
+ dict(resp.headers), context=context_data
331
+ )
332
+
333
+ stream_status_code = resp.status_code
334
+
335
+ if resp.status_code != 200:
336
+ error_text = await resp.aread()
337
+ error_message = error_text.decode()
338
+ print(
339
+ colored_error(
340
+ f"[ERROR] Streaming request failed: HTTP {resp.status_code}"
341
+ )
342
+ )
343
+ logger.error(
344
+ "Streaming request failed",
345
+ status_code=resp.status_code,
346
+ response_text=error_message,
347
+ **context_data,
348
+ )
349
+ error_payload = json.dumps(
350
+ {
351
+ "error": {
352
+ "status": resp.status_code,
353
+ "message": error_message,
354
+ }
355
+ },
356
+ ensure_ascii=False,
357
+ )
358
+ events = [f"data: {error_payload}", "data: [DONE]"]
359
+ request_results.append(
360
+ EndpointRequestResult(
361
+ phase=context_data.get("phase", "initial"),
362
+ method="POST",
363
+ status_code=stream_status_code,
364
+ stream=True,
365
+ details={
366
+ "event_count": len(events),
367
+ "error_detail": error_message,
368
+ },
369
+ )
370
+ )
371
+ return events, request_results
372
+
373
+ buffer = ""
374
+ async for chunk in resp.aiter_text():
375
+ if not chunk:
376
+ continue
377
+
378
+ # normalized_segments = self._normalize_stream_chunk(chunk)
379
+
380
+ for segment in chunk: # normalized_segments:
381
+ if not segment:
382
+ continue
383
+
384
+ raw_chunks.append(segment)
385
+ buffer += segment
386
+
387
+ while "\n\n" in buffer:
388
+ raw_event, buffer = buffer.split("\n\n", 1)
389
+ if raw_event.strip():
390
+ events.append(raw_event.strip())
391
+
392
+ if buffer.strip():
393
+ events.append(buffer.strip())
394
+
395
+ except Exception as exc: # noqa: BLE001
396
+ logger.error(
397
+ "Streaming request exception",
398
+ error=str(exc),
399
+ **context_data,
400
+ )
401
+ error_payload = json.dumps(
402
+ {"error": {"message": str(exc)}}, ensure_ascii=False
403
+ )
404
+ events.append(f"data: {error_payload}")
405
+ events.append("data: [DONE]")
406
+ request_results.append(
407
+ EndpointRequestResult(
408
+ phase=context_data.get("phase", "initial"),
409
+ method="POST",
410
+ status_code=stream_status_code,
411
+ stream=True,
412
+ details={
413
+ "event_count": len(events),
414
+ "error_detail": str(exc),
415
+ },
416
+ )
417
+ )
418
+ return events, request_results
419
+
420
+ raw_text = "".join(raw_chunks).strip()
421
+ primary_event_count = len(events)
422
+ only_done = events and all(
423
+ evt.strip().lower() == "data: [done]" for evt in events
424
+ )
425
+
426
+ if not events or only_done:
427
+ logger.debug(
428
+ "stream_response_empty",
429
+ event_count=len(events),
430
+ raw_length=len(raw_text),
431
+ **context_data,
432
+ )
433
+
434
+ fallback_events: list[str] | None = None
435
+
436
+ if raw_text and raw_text.lower() != "data: [done]":
437
+ if raw_text.startswith("data:"):
438
+ fallback_events = [raw_text, "data: [DONE]"]
439
+ else:
440
+ fallback_events = [f"data: {raw_text}", "data: [DONE]"]
441
+ else:
442
+ (
443
+ fallback_events,
444
+ fallback_request_result,
445
+ ) = await self._fallback_stream_to_json(
446
+ url=url,
447
+ payload=payload,
448
+ context=context_data,
449
+ )
450
+
451
+ if fallback_events:
452
+ logger.info(
453
+ "stream_fallback_applied",
454
+ fallback_event_count=len(fallback_events),
455
+ **context_data,
456
+ )
457
+ events = fallback_events
458
+ fallback_used = True
459
+
460
+ events = [evt.rstrip("'\"") if isinstance(evt, str) else evt for evt in events]
461
+
462
+ request_details: dict[str, Any] = {
463
+ "event_count": len(events),
464
+ }
465
+ if fallback_used:
466
+ request_details["fallback_applied"] = True
467
+ if primary_event_count and primary_event_count != len(events):
468
+ request_details["primary_event_count"] = primary_event_count
469
+ if raw_text:
470
+ request_details["raw_preview"] = raw_text[:120]
471
+
472
+ request_results.append(
473
+ EndpointRequestResult(
474
+ phase=context_data.get("phase", "initial"),
475
+ method="POST",
476
+ status_code=stream_status_code,
477
+ stream=True,
478
+ details=request_details,
479
+ )
480
+ )
481
+
482
+ if fallback_request_result is not None:
483
+ request_results.append(fallback_request_result)
484
+
485
+ logger.info(
486
+ "Streaming completed",
487
+ event_count=len(events),
488
+ **context_data,
489
+ )
490
+ return events, request_results
491
+
492
+ async def options_preflight(
493
+ self,
494
+ url: str,
495
+ *,
496
+ request_method: str = "POST",
497
+ request_headers: Sequence[str] | None = None,
498
+ headers: dict[str, str] | None = None,
499
+ context: dict[str, Any] | None = None,
500
+ ) -> tuple[int, dict[str, Any]]:
501
+ """Send a CORS preflight OPTIONS request and return status and headers."""
502
+
503
+ preflight_headers = self._build_headers({})
504
+ preflight_headers["Access-Control-Request-Method"] = request_method
505
+ if request_headers:
506
+ preflight_headers["Access-Control-Request-Headers"] = ", ".join(
507
+ request_headers
508
+ )
509
+ if headers:
510
+ preflight_headers.update(headers)
511
+
512
+ context_data = context or {}
513
+
514
+ print(colored_info(f"-> Making CORS preflight request to {url}"))
515
+ logger.info(
516
+ "Making CORS preflight request",
517
+ url=url,
518
+ request_method=request_method,
519
+ request_headers=request_headers,
520
+ **context_data,
521
+ )
522
+
523
+ response = await self.client.options(url, headers=preflight_headers)
524
+ status_code = response.status_code
525
+ response_headers = dict(response.headers)
526
+
527
+ logger.info(
528
+ "Preflight response received",
529
+ status_code=status_code,
530
+ headers=response_headers,
531
+ **context_data,
532
+ )
533
+
534
+ self.extract_and_display_request_id(response_headers, context=context_data)
535
+ print(colored_info(f"-> Preflight response status: HTTP {status_code}"))
536
+ return status_code, response_headers
537
+
538
+ def _normalize_stream_chunk(self, chunk: str) -> list[str]:
539
+ """Decode chunks that arrive as Python bytes reprs (e.g. b'...')."""
540
+
541
+ if not chunk:
542
+ return []
543
+
544
+ pattern = re.compile(r"b(['\"])(.*?)(?<!\\)\1", re.DOTALL)
545
+ matches = list(pattern.finditer(chunk))
546
+
547
+ if not matches:
548
+ return [chunk]
549
+
550
+ segments: list[str] = []
551
+ last_end = 0
552
+ for match in matches:
553
+ literal = match.group(0)
554
+ try:
555
+ value = ast.literal_eval(literal)
556
+ if isinstance(value, bytes):
557
+ segments.append(value.decode("utf-8", "replace"))
558
+ else:
559
+ segments.append(str(value))
560
+ except Exception:
561
+ segments.append(match.group(2).replace("\\n", "\n"))
562
+ last_end = match.end()
563
+
564
+ remainder = chunk[last_end:]
565
+ if remainder.strip():
566
+ segments.append(remainder)
567
+
568
+ return segments or [chunk]
569
+
570
+ async def _fallback_stream_to_json(
571
+ self,
572
+ *,
573
+ url: str,
574
+ payload: dict[str, Any],
575
+ context: dict[str, Any],
576
+ ) -> tuple[list[str], EndpointRequestResult | None]:
577
+ """Retry streaming request as JSON when no SSE events are emitted."""
578
+
579
+ if not isinstance(payload, dict):
580
+ return [], None
581
+
582
+ fallback_payload = copy.deepcopy(payload)
583
+ fallback_payload["stream"] = False
584
+
585
+ fallback_context = {**context, "phase": context.get("phase", "fallback")}
586
+ fallback_context["fallback"] = "stream_to_json"
587
+
588
+ try:
589
+ response, request_result = await self.post_json(
590
+ url,
591
+ fallback_payload,
592
+ context=fallback_context,
593
+ capture_result=True,
594
+ )
595
+ except Exception as exc: # noqa: BLE001
596
+ logger.error(
597
+ "stream_fallback_request_failed",
598
+ error=str(exc),
599
+ **fallback_context,
600
+ )
601
+ return [], None
602
+
603
+ if isinstance(response, dict | list):
604
+ body = json.dumps(response, ensure_ascii=False)
605
+ else:
606
+ body = str(response)
607
+
608
+ return [f"data: {body}", "data: [DONE]"], request_result
609
+
610
+ def validate_response(
611
+ self, response: dict[str, Any], model_class: Any, *, is_streaming: bool = False
612
+ ) -> bool:
613
+ """Validate response using the provided model_class."""
614
+ try:
615
+ payload = response
616
+ if model_class is ResponseMessage:
617
+ payload = self._extract_openai_responses_message(response)
618
+ elif model_class is ResponseObject and isinstance(payload.get("text"), str):
619
+ try:
620
+ payload = payload.copy()
621
+ payload["text"] = json.loads(payload["text"])
622
+ except json.JSONDecodeError:
623
+ logger.debug(
624
+ "Failed to decode response.text as JSON",
625
+ text_value=payload.get("text"),
626
+ )
627
+ model_class.model_validate(payload)
628
+ print(colored_success(f"[OK] {model_class.__name__} validation passed"))
629
+ logger.info(f"{model_class.__name__} validation passed")
630
+ return True
631
+ except Exception as exc: # noqa: BLE001
632
+ print(
633
+ colored_error(
634
+ f"[ERROR] {model_class.__name__} validation failed: {exc}"
635
+ )
636
+ )
637
+ logger.error(f"{model_class.__name__} validation failed", error=str(exc))
638
+ return False
639
+
640
+ def _extract_openai_responses_message(
641
+ self, response: dict[str, Any]
642
+ ) -> dict[str, Any]:
643
+ """Coerce various response shapes into an OpenAIResponseMessage dict."""
644
+
645
+ try:
646
+ if isinstance(response, dict) and "choices" in response:
647
+ choices = response.get("choices") or []
648
+ if choices and isinstance(choices[0], dict):
649
+ message = choices[0].get("message")
650
+ if isinstance(message, dict):
651
+ return message
652
+ except Exception: # pragma: no cover - defensive fallback
653
+ pass
654
+
655
+ try:
656
+ output = response.get("output") if isinstance(response, dict) else None
657
+ if isinstance(output, list):
658
+ for item in output:
659
+ if isinstance(item, dict) and item.get("type") == "message":
660
+ content_blocks = item.get("content") or []
661
+ text_parts: list[str] = []
662
+ for block in content_blocks:
663
+ if (
664
+ isinstance(block, dict)
665
+ and block.get("type") in ("text", "output_text")
666
+ and block.get("text")
667
+ ):
668
+ text_parts.append(block["text"])
669
+ content_text = "".join(text_parts) if text_parts else None
670
+ return {"role": "assistant", "content": content_text}
671
+ except Exception: # pragma: no cover - defensive fallback
672
+ pass
673
+
674
+ return {"role": "assistant", "content": None}
675
+
676
+ def validate_sse_event(self, event: str) -> bool:
677
+ """Validate SSE event structure (basic check)."""
678
+ return event.startswith("data: ")
679
+
680
+ def _is_partial_tool_call_chunk(self, chunk: dict[str, Any]) -> bool:
681
+ """Check if chunk contains partial tool call data that shouldn't be validated."""
682
+ if not isinstance(chunk, dict) or "choices" not in chunk:
683
+ return False
684
+
685
+ for choice in chunk.get("choices", []):
686
+ if not isinstance(choice, dict):
687
+ continue
688
+
689
+ delta = choice.get("delta", {})
690
+ if not isinstance(delta, dict):
691
+ continue
692
+
693
+ tool_calls = delta.get("tool_calls", [])
694
+ if not tool_calls:
695
+ continue
696
+
697
+ for tool_call in tool_calls:
698
+ if not isinstance(tool_call, dict):
699
+ continue
700
+
701
+ function = tool_call.get("function", {})
702
+ if isinstance(function, dict):
703
+ if "arguments" in function and (
704
+ "name" not in function or not tool_call.get("id")
705
+ ):
706
+ return True
707
+
708
+ return False
709
+
710
+ def _has_tool_calls_in_chunk(self, chunk: dict[str, Any]) -> bool:
711
+ """Check if chunk contains any tool call data."""
712
+ if not isinstance(chunk, dict) or "choices" not in chunk:
713
+ return False
714
+
715
+ for choice in chunk.get("choices", []):
716
+ if not isinstance(choice, dict):
717
+ continue
718
+
719
+ delta = choice.get("delta", {})
720
+ if isinstance(delta, dict) and "tool_calls" in delta:
721
+ return True
722
+
723
+ return False
724
+
725
+ def _accumulate_tool_calls(
726
+ self, chunk: dict[str, Any], accumulator: dict[str, dict[str, Any]]
727
+ ) -> None:
728
+ """Accumulate tool call fragments across streaming chunks."""
729
+ if not isinstance(chunk, dict) or "choices" not in chunk:
730
+ return
731
+
732
+ for choice in chunk.get("choices", []):
733
+ if not isinstance(choice, dict):
734
+ continue
735
+
736
+ delta = choice.get("delta", {})
737
+ if not isinstance(delta, dict) or "tool_calls" not in delta:
738
+ continue
739
+
740
+ for tool_call in delta["tool_calls"]:
741
+ if not isinstance(tool_call, dict):
742
+ continue
743
+
744
+ index = tool_call.get("index", 0)
745
+ call_key = f"call_{index}"
746
+
747
+ accumulator.setdefault(
748
+ call_key,
749
+ {
750
+ "id": None,
751
+ "type": None,
752
+ "function": {"name": None, "arguments": ""},
753
+ },
754
+ )
755
+
756
+ if "id" in tool_call:
757
+ accumulator[call_key]["id"] = tool_call["id"]
758
+
759
+ if "type" in tool_call:
760
+ accumulator[call_key]["type"] = tool_call["type"]
761
+
762
+ function = tool_call.get("function", {})
763
+ if isinstance(function, dict):
764
+ if "name" in function:
765
+ accumulator[call_key]["function"]["name"] = function["name"]
766
+
767
+ if "arguments" in function:
768
+ accumulator[call_key]["function"]["arguments"] += function[
769
+ "arguments"
770
+ ]
771
+
772
+ def _get_complete_tool_calls(
773
+ self, accumulator: dict[str, dict[str, Any]]
774
+ ) -> list[dict[str, Any]]:
775
+ """Extract complete tool calls from accumulator."""
776
+ complete_calls = []
777
+
778
+ for call_data in accumulator.values():
779
+ if (
780
+ call_data.get("id")
781
+ and call_data.get("type")
782
+ and call_data["function"].get("name")
783
+ and call_data["function"].get("arguments")
784
+ ):
785
+ complete_calls.append(
786
+ {
787
+ "id": call_data["id"],
788
+ "type": call_data["type"],
789
+ "function": {
790
+ "name": call_data["function"]["name"],
791
+ "arguments": call_data["function"]["arguments"],
792
+ },
793
+ }
794
+ )
795
+
796
+ return complete_calls
797
+
798
+ def _execute_accumulated_tool_calls(
799
+ self,
800
+ tool_calls: list[dict[str, Any]],
801
+ tool_definitions: list[dict[str, Any]] | None = None,
802
+ context: dict[str, Any] | None = None,
803
+ ) -> list[dict[str, Any]]:
804
+ """Execute accumulated tool calls and return results."""
805
+ if not tool_calls:
806
+ return []
807
+
808
+ print(
809
+ colored_info(
810
+ f"-> {len(tool_calls)} tool call(s) accumulated from streaming"
811
+ )
812
+ )
813
+ context_data = context or {}
814
+ logger.info(
815
+ "Executing accumulated tool calls",
816
+ tool_count=len(tool_calls),
817
+ tool_names=[
818
+ (tool.get("function") or {}).get("name")
819
+ if isinstance(tool, dict)
820
+ else None
821
+ for tool in tool_calls
822
+ ],
823
+ **context_data,
824
+ )
825
+
826
+ tool_results = []
827
+
828
+ for tool_call in tool_calls:
829
+ try:
830
+ tool_name = None
831
+ tool_arguments: Any = None
832
+
833
+ if "function" in tool_call:
834
+ func = tool_call.get("function", {})
835
+ tool_name = func.get("name")
836
+ tool_arguments = func.get("arguments")
837
+ elif "name" in tool_call:
838
+ tool_name = tool_call.get("name")
839
+ tool_arguments = tool_call.get("arguments")
840
+
841
+ if tool_arguments and isinstance(tool_arguments, str):
842
+ tool_arguments = json.loads(tool_arguments)
843
+
844
+ if tool_definitions:
845
+ available_names = [
846
+ tool.get("name")
847
+ if "name" in tool
848
+ else tool.get("function", {}).get("name")
849
+ for tool in tool_definitions
850
+ ]
851
+ logger.debug(
852
+ "Available tool definitions", tool_names=available_names
853
+ )
854
+
855
+ logger.info(
856
+ "Executing tool call",
857
+ tool_name=tool_name,
858
+ **context_data,
859
+ )
860
+ # Ensure tool_name is a string before calling handle_tool_call
861
+ safe_tool_name = str(tool_name) if tool_name is not None else ""
862
+ tool_result = handle_tool_call(safe_tool_name, tool_arguments or {})
863
+ tool_results.append(
864
+ {
865
+ "tool_call": tool_call,
866
+ "result": tool_result,
867
+ "tool_name": tool_name,
868
+ "tool_input": tool_arguments,
869
+ }
870
+ )
871
+ print(
872
+ colored_success(
873
+ f"-> Tool result: {json.dumps(tool_result, indent=2)}"
874
+ )
875
+ )
876
+ except Exception as exc: # noqa: BLE001
877
+ logger.error(
878
+ "Tool execution failed",
879
+ error=str(exc),
880
+ tool_call=tool_call,
881
+ **context_data,
882
+ )
883
+ tool_results.append(
884
+ {
885
+ "tool_call": tool_call,
886
+ "result": {"error": str(exc)},
887
+ "tool_name": tool_call.get("name"),
888
+ "tool_input": tool_call.get("function", {}),
889
+ }
890
+ )
891
+ if tool_results:
892
+ logger.info(
893
+ "Tool calls executed",
894
+ tool_count=len(tool_results),
895
+ **context_data,
896
+ )
897
+
898
+ return tool_results
899
+
900
+ def handle_tool_calls_in_response(
901
+ self,
902
+ response: dict[str, Any],
903
+ *,
904
+ context: dict[str, Any] | None = None,
905
+ ) -> tuple[dict[str, Any], list[dict[str, Any]]]:
906
+ """Handle tool calls in a response and return modified response and tool results."""
907
+ tool_results: list[dict[str, Any]] = []
908
+ context_data = context or {}
909
+
910
+ if "choices" in response:
911
+ for choice in response.get("choices", []):
912
+ message = choice.get("message", {})
913
+ if message.get("tool_calls"):
914
+ print(colored_info("-> Tool calls detected in response"))
915
+ logger.info(
916
+ "Tool calls detected in response",
917
+ tool_call_count=len(message["tool_calls"]),
918
+ **context_data,
919
+ )
920
+ for tool_call in message["tool_calls"]:
921
+ tool_name = tool_call["function"]["name"]
922
+ tool_input = json.loads(tool_call["function"]["arguments"])
923
+ print(colored_info(f"-> Calling tool: {tool_name}"))
924
+ print(
925
+ colored_info(
926
+ f"-> Tool input: {json.dumps(tool_input, indent=2)}"
927
+ )
928
+ )
929
+
930
+ logger.info(
931
+ "Executing tool call",
932
+ tool_name=tool_name,
933
+ **context_data,
934
+ )
935
+ # Ensure tool_name is a string before calling handle_tool_call
936
+ safe_tool_name = str(tool_name) if tool_name is not None else ""
937
+ tool_result = handle_tool_call(safe_tool_name, tool_input)
938
+ print(
939
+ colored_success(
940
+ f"-> Tool result: {json.dumps(tool_result, indent=2)}"
941
+ )
942
+ )
943
+ logger.info(
944
+ "Tool call completed",
945
+ tool_name=tool_name,
946
+ **context_data,
947
+ )
948
+
949
+ tool_results.append(
950
+ {
951
+ "tool_call": tool_call,
952
+ "result": tool_result,
953
+ "tool_name": tool_name,
954
+ "tool_input": tool_input,
955
+ }
956
+ )
957
+
958
+ if "output" in response:
959
+ for item in response.get("output", []):
960
+ if (
961
+ isinstance(item, dict)
962
+ and item.get("type") == "function_call"
963
+ and item.get("name")
964
+ ):
965
+ tool_name = item.get("name")
966
+ tool_arguments = item.get("arguments", "")
967
+
968
+ print(colored_info("-> Tool calls detected in response"))
969
+ print(colored_info(f"-> Calling tool: {tool_name}"))
970
+
971
+ try:
972
+ tool_input = (
973
+ json.loads(tool_arguments)
974
+ if isinstance(tool_arguments, str)
975
+ else tool_arguments
976
+ )
977
+ print(
978
+ colored_info(
979
+ f"-> Tool input: {json.dumps(tool_input, indent=2)}"
980
+ )
981
+ )
982
+
983
+ logger.info(
984
+ "Executing tool call",
985
+ tool_name=tool_name,
986
+ **context_data,
987
+ )
988
+ # Ensure tool_name is a string before calling handle_tool_call
989
+ safe_tool_name = str(tool_name) if tool_name is not None else ""
990
+ tool_result = handle_tool_call(safe_tool_name, tool_input)
991
+ print(
992
+ colored_success(
993
+ f"-> Tool result: {json.dumps(tool_result, indent=2)}"
994
+ )
995
+ )
996
+ logger.info(
997
+ "Tool call completed",
998
+ tool_name=tool_name,
999
+ **context_data,
1000
+ )
1001
+
1002
+ tool_results.append(
1003
+ {
1004
+ "tool_call": {
1005
+ "name": tool_name,
1006
+ "arguments": tool_arguments,
1007
+ },
1008
+ "result": tool_result,
1009
+ "tool_name": tool_name,
1010
+ "tool_input": tool_input,
1011
+ }
1012
+ )
1013
+ except json.JSONDecodeError as exc:
1014
+ print(
1015
+ colored_error(f"-> Failed to parse tool arguments: {exc}")
1016
+ )
1017
+ print(colored_error(f"-> Raw arguments: {tool_arguments}"))
1018
+ tool_results.append(
1019
+ {
1020
+ "tool_call": {
1021
+ "name": tool_name,
1022
+ "arguments": tool_arguments,
1023
+ },
1024
+ "result": {
1025
+ "error": f"Failed to parse arguments: {exc}"
1026
+ },
1027
+ "tool_name": tool_name,
1028
+ "tool_input": None,
1029
+ }
1030
+ )
1031
+
1032
+ if "content" in response:
1033
+ for content_block in response.get("content", []):
1034
+ if (
1035
+ isinstance(content_block, dict)
1036
+ and content_block.get("type") == "tool_use"
1037
+ ):
1038
+ print(colored_info("-> Tool calls detected in response"))
1039
+ tool_name = content_block.get("name")
1040
+ tool_input = content_block.get("input", {})
1041
+ print(colored_info(f"-> Calling tool: {tool_name}"))
1042
+ print(
1043
+ colored_info(
1044
+ f"-> Tool input: {json.dumps(tool_input, indent=2)}"
1045
+ )
1046
+ )
1047
+
1048
+ logger.info(
1049
+ "Executing tool call",
1050
+ tool_name=tool_name,
1051
+ **context_data,
1052
+ )
1053
+ # Ensure tool_name is a string before calling handle_tool_call
1054
+ safe_tool_name = str(tool_name) if tool_name is not None else ""
1055
+ tool_result = handle_tool_call(safe_tool_name, tool_input)
1056
+ print(
1057
+ colored_success(
1058
+ f"-> Tool result: {json.dumps(tool_result, indent=2)}"
1059
+ )
1060
+ )
1061
+ logger.info(
1062
+ "Tool call completed",
1063
+ tool_name=tool_name,
1064
+ **context_data,
1065
+ )
1066
+
1067
+ tool_results.append(
1068
+ {
1069
+ "tool_call": content_block,
1070
+ "result": tool_result,
1071
+ "tool_name": tool_name,
1072
+ "tool_input": tool_input,
1073
+ }
1074
+ )
1075
+
1076
+ if tool_results:
1077
+ logger.info(
1078
+ "Tool call handling completed",
1079
+ tool_count=len(tool_results),
1080
+ **context_data,
1081
+ )
1082
+
1083
+ return response, tool_results
1084
+
1085
+ def display_thinking_blocks(self, content: str) -> None:
1086
+ """Display thinking blocks from response content."""
1087
+ thinking_blocks = extract_thinking_blocks(content)
1088
+ if thinking_blocks:
1089
+ print(colored_info("-> Thinking blocks detected"))
1090
+ for i, (signature, thinking_content) in enumerate(thinking_blocks, 1):
1091
+ print(colored_warning(f"[THINKING BLOCK {i}]"))
1092
+ print(colored_warning(f"Signature: {signature}"))
1093
+ print(colored_warning("=" * 60))
1094
+ print(thinking_content.strip())
1095
+ print(colored_warning("=" * 60))
1096
+
1097
+ def display_response_content(self, response: dict[str, Any]) -> None:
1098
+ """Display response content with thinking block handling."""
1099
+ content = ""
1100
+
1101
+ if "choices" in response:
1102
+ for choice in response.get("choices", []):
1103
+ message = choice.get("message", {})
1104
+ if message.get("content"):
1105
+ content = message["content"]
1106
+ break
1107
+ elif "content" in response:
1108
+ text_parts = []
1109
+ for content_block in response.get("content", []):
1110
+ if (
1111
+ isinstance(content_block, dict)
1112
+ and content_block.get("type") == "text"
1113
+ ):
1114
+ text_parts.append(content_block.get("text", ""))
1115
+ content = "".join(text_parts)
1116
+ elif "output" in response:
1117
+ text_parts = []
1118
+ for item in response.get("output", []):
1119
+ if not isinstance(item, dict):
1120
+ continue
1121
+ if item.get("type") == "message":
1122
+ for part in item.get("content", []):
1123
+ if isinstance(part, dict) and part.get("type") in {
1124
+ "output_text",
1125
+ "text",
1126
+ }:
1127
+ text_parts.append(part.get("text", ""))
1128
+ elif item.get("type") == "reasoning" and item.get("summary"):
1129
+ for part in item.get("summary", []):
1130
+ if isinstance(part, dict) and part.get("text"):
1131
+ text_parts.append(part.get("text"))
1132
+ content = "\n".join(text_parts)
1133
+ elif isinstance(response.get("text"), str):
1134
+ content = response.get("text", "")
1135
+
1136
+ if content:
1137
+ self.display_thinking_blocks(content)
1138
+ visible_content = extract_visible_content(content)
1139
+ if visible_content:
1140
+ print(colored_info("-> Response content:"))
1141
+ print(visible_content)
1142
+
1143
+ def _consume_stream_events(
1144
+ self,
1145
+ stream_events: Iterable[str],
1146
+ chunk_model_class: Any | None,
1147
+ accumulator_class: type[StreamAccumulator] | None,
1148
+ *,
1149
+ context: dict[str, Any] | None = None,
1150
+ ) -> tuple[str, str | None, StreamAccumulator | None, int]:
1151
+ """Consume SSE chunks, returning accumulated text, metadata, and count."""
1152
+
1153
+ last_event_name: str | None = None
1154
+ full_content = ""
1155
+ finish_reason: str | None = None
1156
+ accumulator = accumulator_class() if accumulator_class else None
1157
+ processed_events = 0
1158
+ context_data = context or {}
1159
+
1160
+ for event_chunk in stream_events:
1161
+ print(event_chunk)
1162
+
1163
+ for raw_event in event_chunk.strip().split("\n"):
1164
+ event = raw_event.strip()
1165
+ if not event:
1166
+ continue
1167
+
1168
+ if event.startswith("event: "):
1169
+ last_event_name = event[len("event: ") :].strip()
1170
+ continue
1171
+
1172
+ if not self.validate_sse_event(event) or event.endswith("[DONE]"):
1173
+ continue
1174
+
1175
+ try:
1176
+ data = json.loads(event[6:])
1177
+ except json.JSONDecodeError:
1178
+ logger.warning(
1179
+ "Invalid JSON in streaming event",
1180
+ event_type=event,
1181
+ **context_data,
1182
+ )
1183
+ continue
1184
+
1185
+ if accumulator:
1186
+ accumulator.accumulate(last_event_name or "", data)
1187
+
1188
+ processed_events += 1
1189
+
1190
+ if isinstance(data, dict):
1191
+ if "choices" in data:
1192
+ for choice in data.get("choices", []):
1193
+ delta = choice.get("delta", {})
1194
+ content = delta.get("content")
1195
+ if content:
1196
+ full_content += content
1197
+
1198
+ finish_reason_value = choice.get("finish_reason")
1199
+ if finish_reason_value:
1200
+ finish_reason = finish_reason_value
1201
+
1202
+ if chunk_model_class and not self._is_partial_tool_call_chunk(data):
1203
+ self.validate_stream_chunk(data, chunk_model_class)
1204
+
1205
+ return full_content, finish_reason, accumulator, processed_events
1206
+
1207
+ def _get_format_type_for_test(self, test: EndpointTest) -> str:
1208
+ """Determine the API format type for a test based on request data configuration."""
1209
+ if test.request in REQUEST_DATA:
1210
+ request_data = REQUEST_DATA[test.request]
1211
+ if "api_format" in request_data:
1212
+ api_format = request_data["api_format"]
1213
+ return str(api_format)
1214
+
1215
+ raise ValueError(
1216
+ f"Missing api_format for request type: {test.request}. Please add to REQUEST_DATA."
1217
+ )
1218
+
1219
+ async def run_endpoint_test(
1220
+ self, test: EndpointTest, index: int
1221
+ ) -> EndpointTestResult:
1222
+ """Run a single endpoint test and return its result."""
1223
+ request_log: list[EndpointRequestResult] = []
1224
+
1225
+ try:
1226
+ full_url = f"{self.base_url}{test.endpoint}"
1227
+ provider_key = test.name.split("_", 1)[0]
1228
+ payload = get_request_payload(test)
1229
+
1230
+ log_context = {
1231
+ "test_name": test.name,
1232
+ "endpoint": test.endpoint,
1233
+ "model": test.model,
1234
+ "stream": test.stream,
1235
+ }
1236
+
1237
+ template = REQUEST_DATA[test.request]
1238
+ model_class = template.get("model_class")
1239
+ chunk_model_class = template.get("chunk_model_class")
1240
+ accumulator_class = template.get(
1241
+ "accumulator_class"
1242
+ ) or PROVIDER_TOOL_ACCUMULATORS.get(provider_key)
1243
+
1244
+ has_tools = "tools" in payload
1245
+
1246
+ logger.info(
1247
+ "Running endpoint test",
1248
+ test_name=test.name,
1249
+ endpoint=test.endpoint,
1250
+ stream=test.stream,
1251
+ has_tools=has_tools,
1252
+ accumulator_class=getattr(accumulator_class, "__name__", None)
1253
+ if accumulator_class
1254
+ else None,
1255
+ model_class=getattr(model_class, "__name__", None)
1256
+ if model_class
1257
+ else None,
1258
+ )
1259
+
1260
+ if has_tools:
1261
+ print(colored_info("-> This test includes function tools"))
1262
+
1263
+ if test.stream:
1264
+ stream_events, stream_request_results = await self.post_stream(
1265
+ full_url,
1266
+ payload,
1267
+ context={**log_context, "phase": "initial"},
1268
+ )
1269
+ request_log.extend(stream_request_results)
1270
+
1271
+ (
1272
+ full_content,
1273
+ finish_reason,
1274
+ stream_accumulator,
1275
+ processed_events,
1276
+ ) = self._consume_stream_events(
1277
+ stream_events,
1278
+ chunk_model_class,
1279
+ accumulator_class,
1280
+ context={**log_context, "phase": "initial"},
1281
+ )
1282
+
1283
+ if (
1284
+ not full_content
1285
+ and stream_accumulator
1286
+ and getattr(stream_accumulator, "text_content", None)
1287
+ ):
1288
+ full_content = stream_accumulator.text_content
1289
+
1290
+ if processed_events == 0:
1291
+ message = f"{test.name}: streaming response ended without emitting any events"
1292
+ print(colored_warning(message))
1293
+ logger.warning(
1294
+ "Streaming response empty",
1295
+ event_count=processed_events,
1296
+ **log_context,
1297
+ )
1298
+ return EndpointTestResult(
1299
+ test=test,
1300
+ index=index,
1301
+ success=False,
1302
+ error=message,
1303
+ request_results=request_log,
1304
+ )
1305
+
1306
+ logger.info(
1307
+ "Stream events processed",
1308
+ event_count=processed_events,
1309
+ finish_reason=finish_reason,
1310
+ content_preview=(full_content[:120] if full_content else None),
1311
+ has_tools=has_tools,
1312
+ **log_context,
1313
+ )
1314
+
1315
+ if full_content:
1316
+ self.display_thinking_blocks(full_content)
1317
+ visible_content = extract_visible_content(full_content)
1318
+ if visible_content:
1319
+ print(colored_info("-> Accumulated response:"))
1320
+ print(visible_content)
1321
+
1322
+ if stream_accumulator and processed_events > 0:
1323
+ aggregated_snapshot = stream_accumulator.rebuild_response_object(
1324
+ {"choices": [], "content": [], "tool_calls": []}
1325
+ )
1326
+ if any(
1327
+ aggregated_snapshot.get(key)
1328
+ for key in ("choices", "content", "tool_calls", "output")
1329
+ ):
1330
+ print(colored_info("-> Aggregated response object (partial):"))
1331
+ print(json.dumps(aggregated_snapshot, indent=2))
1332
+ self.display_response_content(aggregated_snapshot)
1333
+ logger.debug(
1334
+ "Stream accumulator snapshot",
1335
+ snapshot_keys=[
1336
+ key
1337
+ for key, value in aggregated_snapshot.items()
1338
+ if value
1339
+ ],
1340
+ **log_context,
1341
+ )
1342
+
1343
+ tool_results: list[dict[str, Any]] = []
1344
+ if has_tools and stream_accumulator:
1345
+ complete_tool_calls = stream_accumulator.get_complete_tool_calls()
1346
+ if (
1347
+ finish_reason in ["tool_calls", "tool_use"]
1348
+ or complete_tool_calls
1349
+ ):
1350
+ tool_defs = (
1351
+ payload.get("tools") if isinstance(payload, dict) else None
1352
+ )
1353
+ tool_results = self._execute_accumulated_tool_calls(
1354
+ complete_tool_calls,
1355
+ tool_defs,
1356
+ context={**log_context, "phase": "tool_execution"},
1357
+ )
1358
+
1359
+ if tool_results:
1360
+ print(
1361
+ colored_info(
1362
+ "-> Sending tool results back to LLM for final response"
1363
+ )
1364
+ )
1365
+ logger.info(
1366
+ "Tool results ready for continuation",
1367
+ tool_count=len(tool_results),
1368
+ **log_context,
1369
+ )
1370
+
1371
+ format_type = self._get_format_type_for_test(test)
1372
+
1373
+ response = {
1374
+ "choices": [{"finish_reason": finish_reason}],
1375
+ "content": full_content,
1376
+ }
1377
+ response["tool_calls"] = complete_tool_calls
1378
+
1379
+ format_tools = FORMAT_TOOLS[format_type]
1380
+ continuation_payload = (
1381
+ format_tools.build_continuation_request(
1382
+ payload, response, tool_results
1383
+ )
1384
+ )
1385
+
1386
+ (
1387
+ continuation_events,
1388
+ continuation_request_results,
1389
+ ) = await self.post_stream(
1390
+ full_url,
1391
+ continuation_payload,
1392
+ context={**log_context, "phase": "continuation"},
1393
+ )
1394
+ request_log.extend(continuation_request_results)
1395
+ print(colored_info("Final response (with tool results):"))
1396
+ (
1397
+ continuation_content,
1398
+ _,
1399
+ continuation_accumulator,
1400
+ continuation_events_processed,
1401
+ ) = self._consume_stream_events(
1402
+ continuation_events,
1403
+ chunk_model_class,
1404
+ accumulator_class,
1405
+ context={**log_context, "phase": "continuation"},
1406
+ )
1407
+
1408
+ if continuation_events_processed == 0:
1409
+ message = f"{test.name}: continuation streaming response contained no events"
1410
+ print(colored_warning(message))
1411
+ logger.warning(
1412
+ "Continuation response empty",
1413
+ event_count=continuation_events_processed,
1414
+ **log_context,
1415
+ )
1416
+ return EndpointTestResult(
1417
+ test=test,
1418
+ index=index,
1419
+ success=False,
1420
+ error=message,
1421
+ request_results=request_log,
1422
+ )
1423
+
1424
+ logger.info(
1425
+ "Continuation stream processed",
1426
+ event_count=continuation_events_processed,
1427
+ content_preview=(
1428
+ continuation_content[:120]
1429
+ if continuation_content
1430
+ else None
1431
+ ),
1432
+ **log_context,
1433
+ )
1434
+
1435
+ if continuation_content:
1436
+ self.display_thinking_blocks(continuation_content)
1437
+ visible_content = extract_visible_content(
1438
+ continuation_content
1439
+ )
1440
+ if visible_content:
1441
+ print(colored_info("-> Accumulated response:"))
1442
+ print(visible_content)
1443
+
1444
+ if (
1445
+ continuation_accumulator
1446
+ and continuation_events_processed > 0
1447
+ ):
1448
+ aggregated_snapshot = (
1449
+ continuation_accumulator.rebuild_response_object(
1450
+ {"choices": [], "content": [], "tool_calls": []}
1451
+ )
1452
+ )
1453
+ if any(
1454
+ aggregated_snapshot.get(key)
1455
+ for key in ("choices", "content", "tool_calls")
1456
+ ):
1457
+ print(
1458
+ colored_info(
1459
+ "-> Aggregated response object (partial):"
1460
+ )
1461
+ )
1462
+ print(json.dumps(aggregated_snapshot, indent=2))
1463
+ self.display_response_content(aggregated_snapshot)
1464
+ logger.debug(
1465
+ "Continuation accumulator snapshot",
1466
+ snapshot_keys=[
1467
+ key
1468
+ for key, value in aggregated_snapshot.items()
1469
+ if value
1470
+ ],
1471
+ **log_context,
1472
+ )
1473
+
1474
+ else:
1475
+ response, initial_request_result = await self.post_json(
1476
+ full_url,
1477
+ payload,
1478
+ context={**log_context, "phase": "initial"},
1479
+ capture_result=True,
1480
+ )
1481
+ request_log.append(initial_request_result)
1482
+
1483
+ print(json.dumps(response, indent=2))
1484
+
1485
+ json_tool_results: list[dict[str, Any]] = []
1486
+ if has_tools:
1487
+ response, json_tool_results = self.handle_tool_calls_in_response(
1488
+ response, context={**log_context, "phase": "tool_detection"}
1489
+ )
1490
+
1491
+ if json_tool_results:
1492
+ print(
1493
+ colored_info(
1494
+ "-> Sending tool results back to LLM for final response"
1495
+ )
1496
+ )
1497
+ logger.info(
1498
+ "Tool results ready for continuation",
1499
+ tool_count=len(json_tool_results),
1500
+ **log_context,
1501
+ )
1502
+
1503
+ format_type = self._get_format_type_for_test(test)
1504
+ format_tools = FORMAT_TOOLS[format_type]
1505
+ continuation_payload = format_tools.build_continuation_request(
1506
+ payload, response, json_tool_results
1507
+ )
1508
+
1509
+ (
1510
+ continuation_response,
1511
+ continuation_request_result,
1512
+ ) = await self.post_json(
1513
+ full_url,
1514
+ continuation_payload,
1515
+ context={**log_context, "phase": "continuation"},
1516
+ capture_result=True,
1517
+ )
1518
+ request_log.append(continuation_request_result)
1519
+ print(colored_info("Final response (with tool results):"))
1520
+ print(json.dumps(continuation_response, indent=2))
1521
+ self.display_response_content(continuation_response)
1522
+ preview_data = json.dumps(
1523
+ continuation_response, ensure_ascii=False
1524
+ )
1525
+ logger.info(
1526
+ "Continuation response received",
1527
+ tool_count=len(json_tool_results),
1528
+ content_preview=preview_data[:120],
1529
+ **log_context,
1530
+ )
1531
+
1532
+ self.display_response_content(response)
1533
+
1534
+ if "error" not in response and model_class:
1535
+ self.validate_response(response, model_class, is_streaming=False)
1536
+
1537
+ print(colored_success(f"[OK] Test {test.name} completed successfully"))
1538
+ logger.info("Test completed successfully", **log_context)
1539
+ return EndpointTestResult(
1540
+ test=test,
1541
+ index=index,
1542
+ success=True,
1543
+ request_results=request_log,
1544
+ )
1545
+
1546
+ except Exception as exc: # noqa: BLE001
1547
+ print(colored_error(f"[FAIL] Test {test.name} failed: {exc}"))
1548
+ logger.error(
1549
+ "Test execution failed",
1550
+ **log_context,
1551
+ error=str(exc),
1552
+ exc_info=exc,
1553
+ )
1554
+ return EndpointTestResult(
1555
+ test=test,
1556
+ index=index,
1557
+ success=False,
1558
+ error=str(exc),
1559
+ exception=exc,
1560
+ request_results=request_log,
1561
+ )
1562
+
1563
+ def validate_stream_chunk(
1564
+ self, chunk: dict[str, Any], chunk_model_class: Any
1565
+ ) -> bool:
1566
+ """Validate a streaming chunk against the provided model class."""
1567
+
1568
+ # Some providers emit housekeeping chunks (e.g. pure filter results) that
1569
+ # do not include the standard fields expected by the OpenAI schema. Skip
1570
+ # validation for those so we only flag real contract violations.
1571
+ if not chunk.get("choices") and "model" not in chunk:
1572
+ logger.debug(
1573
+ "Skipping validation for non-standard chunk",
1574
+ chunk_keys=list(chunk.keys()),
1575
+ )
1576
+ return True
1577
+
1578
+ if chunk.get("type") == "message" and "choices" not in chunk:
1579
+ logger.debug(
1580
+ "Skipping validation for provider message chunk",
1581
+ chunk_type=chunk.get("type"),
1582
+ chunk_keys=list(chunk.keys()),
1583
+ )
1584
+ return True
1585
+
1586
+ try:
1587
+ chunk_model_class.model_validate(chunk)
1588
+ return True
1589
+ except Exception as exc: # noqa: BLE001
1590
+ if self._has_tool_calls_in_chunk(chunk):
1591
+ logger.debug(
1592
+ "Validation failed for tool call chunk (expected)", error=str(exc)
1593
+ )
1594
+ return True
1595
+
1596
+ print(
1597
+ colored_error(
1598
+ f"[ERROR] {chunk_model_class.__name__} chunk validation failed: {exc}"
1599
+ )
1600
+ )
1601
+ return False
1602
+
1603
+ async def run_all_tests(
1604
+ self, selected_indices: list[int] | None = None
1605
+ ) -> EndpointTestRunSummary:
1606
+ """Run endpoint tests, optionally filtered by selected indices."""
1607
+ print(colored_header("CCProxy Endpoint Tests"))
1608
+ print(colored_info(f"Test endpoints at {self.base_url}"))
1609
+ logger.info("Starting endpoint tests", base_url=self.base_url)
1610
+
1611
+ total_available = len(ENDPOINT_TESTS)
1612
+
1613
+ if selected_indices is not None:
1614
+ indices_to_run = [i for i in selected_indices if 0 <= i < total_available]
1615
+ logger.info(
1616
+ "Running selected tests",
1617
+ selected_count=len(indices_to_run),
1618
+ total_count=total_available,
1619
+ selected_indices=selected_indices,
1620
+ )
1621
+ else:
1622
+ indices_to_run = list(range(total_available))
1623
+ logger.info("Running all tests", test_count=total_available)
1624
+
1625
+ total_to_run = len(indices_to_run)
1626
+ print(
1627
+ colored_info(
1628
+ f"Selected tests: {total_to_run} of {total_available} available"
1629
+ )
1630
+ )
1631
+
1632
+ if total_to_run == 0:
1633
+ print(colored_warning("No tests selected; nothing to execute."))
1634
+ logger.warning("No tests selected for execution")
1635
+ return EndpointTestRunSummary(
1636
+ base_url=self.base_url,
1637
+ results=[],
1638
+ successful_count=0,
1639
+ failure_count=0,
1640
+ )
1641
+
1642
+ results: list[EndpointTestResult] = []
1643
+ successful_tests = 0
1644
+ failed_tests = 0
1645
+
1646
+ for position, index in enumerate(indices_to_run, 1):
1647
+ test = ENDPOINT_TESTS[index]
1648
+
1649
+ progress_message = (
1650
+ f"[{position}/{total_to_run}] Running test #{index + 1}: {test.name}"
1651
+ )
1652
+ if test.description and test.description != test.name:
1653
+ progress_message += f" - {test.description}"
1654
+
1655
+ print(colored_progress(progress_message))
1656
+ logger.info(
1657
+ "Dispatching endpoint test",
1658
+ test_name=test.name,
1659
+ endpoint=test.endpoint,
1660
+ ordinal=position,
1661
+ total=total_to_run,
1662
+ stream=test.stream,
1663
+ model=test.model,
1664
+ )
1665
+
1666
+ result = await self.run_endpoint_test(test, index)
1667
+ results.append(result)
1668
+
1669
+ if result.success:
1670
+ successful_tests += 1
1671
+ else:
1672
+ failed_tests += 1
1673
+
1674
+ error_messages = [result.error for result in results if result.error]
1675
+
1676
+ summary = EndpointTestRunSummary(
1677
+ base_url=self.base_url,
1678
+ results=results,
1679
+ successful_count=successful_tests,
1680
+ failure_count=failed_tests,
1681
+ errors=error_messages,
1682
+ )
1683
+
1684
+ if summary.failure_count == 0:
1685
+ print(
1686
+ colored_success(
1687
+ f"\nAll {summary.total} endpoint tests completed successfully."
1688
+ )
1689
+ )
1690
+ logger.info(
1691
+ "All endpoint tests completed successfully",
1692
+ total_tests=summary.total,
1693
+ successful=summary.successful_count,
1694
+ failed=summary.failure_count,
1695
+ error_count=len(summary.errors),
1696
+ )
1697
+ else:
1698
+ print(
1699
+ colored_warning(
1700
+ f"\nTest run completed: {summary.successful_count} passed, "
1701
+ f"{summary.failure_count} failed (out of {summary.total})."
1702
+ )
1703
+ )
1704
+ logger.warning(
1705
+ "Endpoint tests completed with failures",
1706
+ total_tests=summary.total,
1707
+ successful=summary.successful_count,
1708
+ failed=summary.failure_count,
1709
+ errors=summary.errors,
1710
+ error_count=len(summary.errors),
1711
+ )
1712
+
1713
+ if summary.failed_results:
1714
+ print(colored_error("Failed tests:"))
1715
+ for failed in summary.failed_results:
1716
+ error_detail = failed.error or "no error message provided"
1717
+ print(
1718
+ colored_error(
1719
+ f" - {failed.test.name} (#{failed.index + 1}): {error_detail}"
1720
+ )
1721
+ )
1722
+
1723
+ additional_errors = [err for err in summary.errors if err]
1724
+ if additional_errors and len(additional_errors) > summary.failure_count:
1725
+ print(colored_error("Additional errors:"))
1726
+ for err in additional_errors:
1727
+ print(colored_error(f" - {err}"))
1728
+
1729
+ return summary
1730
+
1731
+
1732
+ def resolve_selected_indices(
1733
+ selection: str | Sequence[int] | None,
1734
+ ) -> list[int] | None:
1735
+ """Normalize test selection input into 0-based indices."""
1736
+
1737
+ if selection is None:
1738
+ return None
1739
+
1740
+ total_tests = len(ENDPOINT_TESTS)
1741
+
1742
+ if isinstance(selection, str):
1743
+ indices = parse_test_selection(selection, total_tests)
1744
+ else:
1745
+ try:
1746
+ seen: set[int] = set()
1747
+ indices = []
1748
+ for raw in selection:
1749
+ index = int(raw)
1750
+ if index in seen:
1751
+ continue
1752
+ seen.add(index)
1753
+ indices.append(index)
1754
+ except TypeError as exc:
1755
+ raise TypeError(
1756
+ "tests must be a selection string or a sequence of integers"
1757
+ ) from exc
1758
+
1759
+ indices.sort()
1760
+
1761
+ for index in indices:
1762
+ if index < 0 or index >= total_tests:
1763
+ raise ValueError(
1764
+ f"Test index {index} is out of range (0-{total_tests - 1})"
1765
+ )
1766
+
1767
+ return indices
1768
+
1769
+
1770
+ def find_tests_by_pattern(pattern: str) -> list[int]:
1771
+ """Find test indices by pattern (regex, exact match, or partial match)."""
1772
+ pattern_lower = pattern.lower()
1773
+ matches: list[int] = []
1774
+
1775
+ for i, test in enumerate(ENDPOINT_TESTS):
1776
+ if test.name.lower() == pattern_lower:
1777
+ return [i]
1778
+
1779
+ try:
1780
+ regex = re.compile(pattern_lower, re.IGNORECASE)
1781
+ for i, test in enumerate(ENDPOINT_TESTS):
1782
+ if regex.search(test.name.lower()):
1783
+ matches.append(i)
1784
+ if matches:
1785
+ return matches
1786
+ except re.error:
1787
+ pass
1788
+
1789
+ for i, test in enumerate(ENDPOINT_TESTS):
1790
+ if pattern_lower in test.name.lower():
1791
+ matches.append(i)
1792
+
1793
+ return matches
1794
+
1795
+
1796
+ def parse_test_selection(selection: str, total_tests: int) -> list[int]:
1797
+ """Parse test selection string into list of test indices (0-based)."""
1798
+ indices: set[int] = set()
1799
+
1800
+ for part in selection.split(","):
1801
+ part = part.strip()
1802
+
1803
+ if ".." in part:
1804
+ if part.startswith(".."):
1805
+ try:
1806
+ end = int(part[2:])
1807
+ indices.update(range(0, end))
1808
+ except ValueError as exc:
1809
+ raise ValueError(
1810
+ f"Invalid range format: '{part}' - ranges must use numbers"
1811
+ ) from exc
1812
+ elif part.endswith(".."):
1813
+ try:
1814
+ start = int(part[:-2]) - 1
1815
+ indices.update(range(start, total_tests))
1816
+ except ValueError as exc:
1817
+ raise ValueError(
1818
+ f"Invalid range format: '{part}' - ranges must use numbers"
1819
+ ) from exc
1820
+ else:
1821
+ try:
1822
+ start_str, end_str = part.split("..", 1)
1823
+ start = int(start_str) - 1
1824
+ end = int(end_str)
1825
+ indices.update(range(start, end))
1826
+ except ValueError as exc:
1827
+ raise ValueError(
1828
+ f"Invalid range format: '{part}' - ranges must use numbers"
1829
+ ) from exc
1830
+ else:
1831
+ try:
1832
+ index = int(part) - 1
1833
+ if 0 <= index < total_tests:
1834
+ indices.add(index)
1835
+ else:
1836
+ raise ValueError(
1837
+ f"Test index {part} is out of range (1-{total_tests})"
1838
+ )
1839
+ except ValueError:
1840
+ matched_indices = find_tests_by_pattern(part)
1841
+ if matched_indices:
1842
+ indices.update(matched_indices)
1843
+ else:
1844
+ suggestions = []
1845
+ part_lower = part.lower()
1846
+ for test in ENDPOINT_TESTS:
1847
+ if any(
1848
+ word in test.name.lower() for word in part_lower.split("_")
1849
+ ):
1850
+ suggestions.append(test.name)
1851
+
1852
+ error_msg = f"No tests match pattern '{part}'"
1853
+ if suggestions:
1854
+ error_msg += (
1855
+ f". Did you mean one of: {', '.join(suggestions[:3])}"
1856
+ )
1857
+ raise ValueError(error_msg)
1858
+
1859
+ return sorted(indices)
1860
+
1861
+
1862
+ async def run_endpoint_tests_async(
1863
+ base_url: str = "http://127.0.0.1:8000",
1864
+ tests: str | Sequence[int] | None = None,
1865
+ ) -> EndpointTestRunSummary:
1866
+ """Execute endpoint tests asynchronously and return the summary."""
1867
+
1868
+ selected_indices = resolve_selected_indices(tests)
1869
+ if selected_indices is not None and not selected_indices:
1870
+ raise ValueError("No valid tests selected")
1871
+
1872
+ async with TestEndpoint(base_url=base_url) as tester:
1873
+ return await tester.run_all_tests(selected_indices)
1874
+
1875
+
1876
+ def run_endpoint_tests(
1877
+ base_url: str = "http://127.0.0.1:8000",
1878
+ tests: str | Sequence[int] | None = None,
1879
+ ) -> EndpointTestRunSummary:
1880
+ """Convenience wrapper to run endpoint tests from synchronous code."""
1881
+
1882
+ try:
1883
+ loop = asyncio.get_running_loop()
1884
+ except RuntimeError:
1885
+ loop = None
1886
+
1887
+ if loop and loop.is_running():
1888
+ raise RuntimeError(
1889
+ "run_endpoint_tests() cannot be called while an event loop is running; "
1890
+ "use await run_endpoint_tests_async(...) instead"
1891
+ )
1892
+
1893
+ return asyncio.run(run_endpoint_tests_async(base_url=base_url, tests=tests))
1894
+
1895
+
1896
+ __all__ = [
1897
+ "TestEndpoint",
1898
+ "run_endpoint_tests",
1899
+ "run_endpoint_tests_async",
1900
+ "resolve_selected_indices",
1901
+ "parse_test_selection",
1902
+ "find_tests_by_pattern",
1903
+ ]