ccproxy-api 0.1.7__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.
- ccproxy/api/__init__.py +1 -15
- ccproxy/api/app.py +434 -219
- ccproxy/api/bootstrap.py +30 -0
- ccproxy/api/decorators.py +85 -0
- ccproxy/api/dependencies.py +144 -168
- ccproxy/api/format_validation.py +54 -0
- ccproxy/api/middleware/cors.py +6 -3
- ccproxy/api/middleware/errors.py +388 -524
- ccproxy/api/middleware/hooks.py +563 -0
- ccproxy/api/middleware/normalize_headers.py +59 -0
- ccproxy/api/middleware/request_id.py +35 -16
- ccproxy/api/middleware/streaming_hooks.py +292 -0
- ccproxy/api/routes/__init__.py +5 -14
- ccproxy/api/routes/health.py +39 -672
- ccproxy/api/routes/plugins.py +277 -0
- ccproxy/auth/__init__.py +2 -19
- ccproxy/auth/bearer.py +25 -15
- ccproxy/auth/dependencies.py +123 -157
- ccproxy/auth/exceptions.py +0 -12
- ccproxy/auth/manager.py +35 -49
- ccproxy/auth/managers/__init__.py +10 -0
- ccproxy/auth/managers/base.py +523 -0
- ccproxy/auth/managers/base_enhanced.py +63 -0
- ccproxy/auth/managers/token_snapshot.py +77 -0
- ccproxy/auth/models/base.py +65 -0
- ccproxy/auth/models/credentials.py +40 -0
- ccproxy/auth/oauth/__init__.py +4 -18
- ccproxy/auth/oauth/base.py +533 -0
- ccproxy/auth/oauth/cli_errors.py +37 -0
- ccproxy/auth/oauth/flows.py +430 -0
- ccproxy/auth/oauth/protocol.py +366 -0
- ccproxy/auth/oauth/registry.py +408 -0
- ccproxy/auth/oauth/router.py +396 -0
- ccproxy/auth/oauth/routes.py +186 -113
- ccproxy/auth/oauth/session.py +151 -0
- ccproxy/auth/oauth/templates.py +342 -0
- ccproxy/auth/storage/__init__.py +2 -5
- ccproxy/auth/storage/base.py +279 -5
- ccproxy/auth/storage/generic.py +134 -0
- ccproxy/cli/__init__.py +1 -2
- ccproxy/cli/_settings_help.py +351 -0
- ccproxy/cli/commands/auth.py +1519 -793
- ccproxy/cli/commands/config/commands.py +209 -276
- ccproxy/cli/commands/plugins.py +669 -0
- ccproxy/cli/commands/serve.py +75 -810
- ccproxy/cli/commands/status.py +254 -0
- ccproxy/cli/decorators.py +83 -0
- ccproxy/cli/helpers.py +22 -60
- ccproxy/cli/main.py +359 -10
- ccproxy/cli/options/claude_options.py +0 -25
- ccproxy/config/__init__.py +7 -11
- ccproxy/config/core.py +227 -0
- ccproxy/config/env_generator.py +232 -0
- ccproxy/config/runtime.py +67 -0
- ccproxy/config/security.py +36 -3
- ccproxy/config/settings.py +382 -441
- ccproxy/config/toml_generator.py +299 -0
- ccproxy/config/utils.py +452 -0
- ccproxy/core/__init__.py +7 -271
- ccproxy/{_version.py → core/_version.py} +16 -3
- ccproxy/core/async_task_manager.py +516 -0
- ccproxy/core/async_utils.py +47 -14
- ccproxy/core/auth/__init__.py +6 -0
- ccproxy/core/constants.py +16 -50
- ccproxy/core/errors.py +53 -0
- ccproxy/core/id_utils.py +20 -0
- ccproxy/core/interfaces.py +16 -123
- ccproxy/core/logging.py +473 -18
- ccproxy/core/plugins/__init__.py +77 -0
- ccproxy/core/plugins/cli_discovery.py +211 -0
- ccproxy/core/plugins/declaration.py +455 -0
- ccproxy/core/plugins/discovery.py +604 -0
- ccproxy/core/plugins/factories.py +967 -0
- ccproxy/core/plugins/hooks/__init__.py +30 -0
- ccproxy/core/plugins/hooks/base.py +58 -0
- ccproxy/core/plugins/hooks/events.py +46 -0
- ccproxy/core/plugins/hooks/implementations/__init__.py +16 -0
- ccproxy/core/plugins/hooks/implementations/formatters/__init__.py +11 -0
- ccproxy/core/plugins/hooks/implementations/formatters/json.py +552 -0
- ccproxy/core/plugins/hooks/implementations/formatters/raw.py +370 -0
- ccproxy/core/plugins/hooks/implementations/http_tracer.py +431 -0
- ccproxy/core/plugins/hooks/layers.py +44 -0
- ccproxy/core/plugins/hooks/manager.py +186 -0
- ccproxy/core/plugins/hooks/registry.py +139 -0
- ccproxy/core/plugins/hooks/thread_manager.py +203 -0
- ccproxy/core/plugins/hooks/types.py +22 -0
- ccproxy/core/plugins/interfaces.py +416 -0
- ccproxy/core/plugins/loader.py +166 -0
- ccproxy/core/plugins/middleware.py +233 -0
- ccproxy/core/plugins/models.py +59 -0
- ccproxy/core/plugins/protocol.py +180 -0
- ccproxy/core/plugins/runtime.py +519 -0
- ccproxy/{observability/context.py → core/request_context.py} +137 -94
- ccproxy/core/status_report.py +211 -0
- ccproxy/core/transformers.py +13 -8
- ccproxy/data/claude_headers_fallback.json +540 -19
- ccproxy/data/codex_headers_fallback.json +114 -7
- ccproxy/http/__init__.py +30 -0
- ccproxy/http/base.py +95 -0
- ccproxy/http/client.py +323 -0
- ccproxy/http/hooks.py +642 -0
- ccproxy/http/pool.py +279 -0
- ccproxy/llms/formatters/__init__.py +7 -0
- ccproxy/llms/formatters/anthropic_to_openai/__init__.py +55 -0
- ccproxy/llms/formatters/anthropic_to_openai/errors.py +65 -0
- ccproxy/llms/formatters/anthropic_to_openai/requests.py +356 -0
- ccproxy/llms/formatters/anthropic_to_openai/responses.py +153 -0
- ccproxy/llms/formatters/anthropic_to_openai/streams.py +1546 -0
- ccproxy/llms/formatters/base.py +140 -0
- ccproxy/llms/formatters/base_model.py +33 -0
- ccproxy/llms/formatters/common/__init__.py +51 -0
- ccproxy/llms/formatters/common/identifiers.py +48 -0
- ccproxy/llms/formatters/common/streams.py +254 -0
- ccproxy/llms/formatters/common/thinking.py +74 -0
- ccproxy/llms/formatters/common/usage.py +135 -0
- ccproxy/llms/formatters/constants.py +55 -0
- ccproxy/llms/formatters/context.py +116 -0
- ccproxy/llms/formatters/mapping.py +33 -0
- ccproxy/llms/formatters/openai_to_anthropic/__init__.py +55 -0
- ccproxy/llms/formatters/openai_to_anthropic/_helpers.py +141 -0
- ccproxy/llms/formatters/openai_to_anthropic/errors.py +53 -0
- ccproxy/llms/formatters/openai_to_anthropic/requests.py +674 -0
- ccproxy/llms/formatters/openai_to_anthropic/responses.py +285 -0
- ccproxy/llms/formatters/openai_to_anthropic/streams.py +530 -0
- ccproxy/llms/formatters/openai_to_openai/__init__.py +53 -0
- ccproxy/llms/formatters/openai_to_openai/_helpers.py +325 -0
- ccproxy/llms/formatters/openai_to_openai/errors.py +6 -0
- ccproxy/llms/formatters/openai_to_openai/requests.py +388 -0
- ccproxy/llms/formatters/openai_to_openai/responses.py +594 -0
- ccproxy/llms/formatters/openai_to_openai/streams.py +1832 -0
- ccproxy/llms/formatters/utils.py +306 -0
- ccproxy/llms/models/__init__.py +9 -0
- ccproxy/llms/models/anthropic.py +619 -0
- ccproxy/llms/models/openai.py +844 -0
- ccproxy/llms/streaming/__init__.py +26 -0
- ccproxy/llms/streaming/accumulators.py +1074 -0
- ccproxy/llms/streaming/formatters.py +251 -0
- ccproxy/{adapters/openai/streaming.py → llms/streaming/processors.py} +193 -240
- ccproxy/models/__init__.py +8 -159
- ccproxy/models/detection.py +92 -193
- ccproxy/models/provider.py +75 -0
- ccproxy/plugins/access_log/README.md +32 -0
- ccproxy/plugins/access_log/__init__.py +20 -0
- ccproxy/plugins/access_log/config.py +33 -0
- ccproxy/plugins/access_log/formatter.py +126 -0
- ccproxy/plugins/access_log/hook.py +763 -0
- ccproxy/plugins/access_log/logger.py +254 -0
- ccproxy/plugins/access_log/plugin.py +137 -0
- ccproxy/plugins/access_log/writer.py +109 -0
- ccproxy/plugins/analytics/README.md +24 -0
- ccproxy/plugins/analytics/__init__.py +1 -0
- ccproxy/plugins/analytics/config.py +5 -0
- ccproxy/plugins/analytics/ingest.py +85 -0
- ccproxy/plugins/analytics/models.py +97 -0
- ccproxy/plugins/analytics/plugin.py +121 -0
- ccproxy/plugins/analytics/routes.py +163 -0
- ccproxy/plugins/analytics/service.py +284 -0
- ccproxy/plugins/claude_api/README.md +29 -0
- ccproxy/plugins/claude_api/__init__.py +10 -0
- ccproxy/plugins/claude_api/adapter.py +829 -0
- ccproxy/plugins/claude_api/config.py +52 -0
- ccproxy/plugins/claude_api/detection_service.py +461 -0
- ccproxy/plugins/claude_api/health.py +175 -0
- ccproxy/plugins/claude_api/hooks.py +284 -0
- ccproxy/plugins/claude_api/models.py +256 -0
- ccproxy/plugins/claude_api/plugin.py +298 -0
- ccproxy/plugins/claude_api/routes.py +118 -0
- ccproxy/plugins/claude_api/streaming_metrics.py +68 -0
- ccproxy/plugins/claude_api/tasks.py +84 -0
- ccproxy/plugins/claude_sdk/README.md +35 -0
- ccproxy/plugins/claude_sdk/__init__.py +80 -0
- ccproxy/plugins/claude_sdk/adapter.py +749 -0
- ccproxy/plugins/claude_sdk/auth.py +57 -0
- ccproxy/{claude_sdk → plugins/claude_sdk}/client.py +63 -39
- ccproxy/plugins/claude_sdk/config.py +210 -0
- ccproxy/{claude_sdk → plugins/claude_sdk}/converter.py +6 -6
- ccproxy/plugins/claude_sdk/detection_service.py +163 -0
- ccproxy/{services/claude_sdk_service.py → plugins/claude_sdk/handler.py} +123 -304
- ccproxy/plugins/claude_sdk/health.py +113 -0
- ccproxy/plugins/claude_sdk/hooks.py +115 -0
- ccproxy/{claude_sdk → plugins/claude_sdk}/manager.py +42 -32
- ccproxy/{claude_sdk → plugins/claude_sdk}/message_queue.py +8 -8
- ccproxy/{models/claude_sdk.py → plugins/claude_sdk/models.py} +64 -16
- ccproxy/plugins/claude_sdk/options.py +154 -0
- ccproxy/{claude_sdk → plugins/claude_sdk}/parser.py +23 -5
- ccproxy/plugins/claude_sdk/plugin.py +269 -0
- ccproxy/plugins/claude_sdk/routes.py +104 -0
- ccproxy/{claude_sdk → plugins/claude_sdk}/session_client.py +124 -12
- ccproxy/plugins/claude_sdk/session_pool.py +700 -0
- ccproxy/{claude_sdk → plugins/claude_sdk}/stream_handle.py +48 -43
- ccproxy/{claude_sdk → plugins/claude_sdk}/stream_worker.py +22 -18
- ccproxy/{claude_sdk → plugins/claude_sdk}/streaming.py +50 -16
- ccproxy/plugins/claude_sdk/tasks.py +97 -0
- ccproxy/plugins/claude_shared/README.md +18 -0
- ccproxy/plugins/claude_shared/__init__.py +12 -0
- ccproxy/plugins/claude_shared/model_defaults.py +171 -0
- ccproxy/plugins/codex/README.md +35 -0
- ccproxy/plugins/codex/__init__.py +6 -0
- ccproxy/plugins/codex/adapter.py +635 -0
- ccproxy/{config/codex.py → plugins/codex/config.py} +78 -12
- ccproxy/plugins/codex/detection_service.py +544 -0
- ccproxy/plugins/codex/health.py +162 -0
- ccproxy/plugins/codex/hooks.py +263 -0
- ccproxy/plugins/codex/model_defaults.py +39 -0
- ccproxy/plugins/codex/models.py +263 -0
- ccproxy/plugins/codex/plugin.py +275 -0
- ccproxy/plugins/codex/routes.py +129 -0
- ccproxy/plugins/codex/streaming_metrics.py +324 -0
- ccproxy/plugins/codex/tasks.py +106 -0
- ccproxy/plugins/codex/utils/__init__.py +1 -0
- ccproxy/plugins/codex/utils/sse_parser.py +106 -0
- ccproxy/plugins/command_replay/README.md +34 -0
- ccproxy/plugins/command_replay/__init__.py +17 -0
- ccproxy/plugins/command_replay/config.py +133 -0
- ccproxy/plugins/command_replay/formatter.py +432 -0
- ccproxy/plugins/command_replay/hook.py +294 -0
- ccproxy/plugins/command_replay/plugin.py +161 -0
- ccproxy/plugins/copilot/README.md +39 -0
- ccproxy/plugins/copilot/__init__.py +11 -0
- ccproxy/plugins/copilot/adapter.py +465 -0
- ccproxy/plugins/copilot/config.py +155 -0
- ccproxy/plugins/copilot/data/copilot_fallback.json +41 -0
- ccproxy/plugins/copilot/detection_service.py +255 -0
- ccproxy/plugins/copilot/manager.py +275 -0
- ccproxy/plugins/copilot/model_defaults.py +284 -0
- ccproxy/plugins/copilot/models.py +148 -0
- ccproxy/plugins/copilot/oauth/__init__.py +16 -0
- ccproxy/plugins/copilot/oauth/client.py +494 -0
- ccproxy/plugins/copilot/oauth/models.py +385 -0
- ccproxy/plugins/copilot/oauth/provider.py +602 -0
- ccproxy/plugins/copilot/oauth/storage.py +170 -0
- ccproxy/plugins/copilot/plugin.py +360 -0
- ccproxy/plugins/copilot/routes.py +294 -0
- ccproxy/plugins/credential_balancer/README.md +124 -0
- ccproxy/plugins/credential_balancer/__init__.py +6 -0
- ccproxy/plugins/credential_balancer/config.py +270 -0
- ccproxy/plugins/credential_balancer/factory.py +415 -0
- ccproxy/plugins/credential_balancer/hook.py +51 -0
- ccproxy/plugins/credential_balancer/manager.py +587 -0
- ccproxy/plugins/credential_balancer/plugin.py +146 -0
- ccproxy/plugins/dashboard/README.md +25 -0
- ccproxy/plugins/dashboard/__init__.py +1 -0
- ccproxy/plugins/dashboard/config.py +8 -0
- ccproxy/plugins/dashboard/plugin.py +71 -0
- ccproxy/plugins/dashboard/routes.py +67 -0
- ccproxy/plugins/docker/README.md +32 -0
- ccproxy/{docker → plugins/docker}/__init__.py +3 -0
- ccproxy/{docker → plugins/docker}/adapter.py +108 -10
- ccproxy/plugins/docker/config.py +82 -0
- ccproxy/{docker → plugins/docker}/docker_path.py +4 -3
- ccproxy/{docker → plugins/docker}/middleware.py +2 -2
- ccproxy/plugins/docker/plugin.py +198 -0
- ccproxy/{docker → plugins/docker}/stream_process.py +3 -3
- ccproxy/plugins/duckdb_storage/README.md +26 -0
- ccproxy/plugins/duckdb_storage/__init__.py +1 -0
- ccproxy/plugins/duckdb_storage/config.py +22 -0
- ccproxy/plugins/duckdb_storage/plugin.py +128 -0
- ccproxy/plugins/duckdb_storage/routes.py +51 -0
- ccproxy/plugins/duckdb_storage/storage.py +633 -0
- ccproxy/plugins/max_tokens/README.md +38 -0
- ccproxy/plugins/max_tokens/__init__.py +12 -0
- ccproxy/plugins/max_tokens/adapter.py +235 -0
- ccproxy/plugins/max_tokens/config.py +86 -0
- ccproxy/plugins/max_tokens/models.py +53 -0
- ccproxy/plugins/max_tokens/plugin.py +200 -0
- ccproxy/plugins/max_tokens/service.py +271 -0
- ccproxy/plugins/max_tokens/token_limits.json +54 -0
- ccproxy/plugins/metrics/README.md +35 -0
- ccproxy/plugins/metrics/__init__.py +10 -0
- ccproxy/{observability/metrics.py → plugins/metrics/collector.py} +20 -153
- ccproxy/plugins/metrics/config.py +85 -0
- ccproxy/plugins/metrics/grafana/dashboards/ccproxy-dashboard.json +1720 -0
- ccproxy/plugins/metrics/hook.py +403 -0
- ccproxy/plugins/metrics/plugin.py +268 -0
- ccproxy/{observability → plugins/metrics}/pushgateway.py +57 -59
- ccproxy/plugins/metrics/routes.py +107 -0
- ccproxy/plugins/metrics/tasks.py +117 -0
- ccproxy/plugins/oauth_claude/README.md +35 -0
- ccproxy/plugins/oauth_claude/__init__.py +14 -0
- ccproxy/plugins/oauth_claude/client.py +270 -0
- ccproxy/plugins/oauth_claude/config.py +84 -0
- ccproxy/plugins/oauth_claude/manager.py +482 -0
- ccproxy/plugins/oauth_claude/models.py +266 -0
- ccproxy/plugins/oauth_claude/plugin.py +149 -0
- ccproxy/plugins/oauth_claude/provider.py +571 -0
- ccproxy/plugins/oauth_claude/storage.py +212 -0
- ccproxy/plugins/oauth_codex/README.md +38 -0
- ccproxy/plugins/oauth_codex/__init__.py +14 -0
- ccproxy/plugins/oauth_codex/client.py +224 -0
- ccproxy/plugins/oauth_codex/config.py +95 -0
- ccproxy/plugins/oauth_codex/manager.py +256 -0
- ccproxy/plugins/oauth_codex/models.py +239 -0
- ccproxy/plugins/oauth_codex/plugin.py +146 -0
- ccproxy/plugins/oauth_codex/provider.py +574 -0
- ccproxy/plugins/oauth_codex/storage.py +92 -0
- ccproxy/plugins/permissions/README.md +28 -0
- ccproxy/plugins/permissions/__init__.py +22 -0
- ccproxy/plugins/permissions/config.py +28 -0
- ccproxy/{cli/commands/permission_handler.py → plugins/permissions/handlers/cli.py} +49 -25
- ccproxy/plugins/permissions/handlers/protocol.py +33 -0
- ccproxy/plugins/permissions/handlers/terminal.py +675 -0
- ccproxy/{api/routes → plugins/permissions}/mcp.py +34 -7
- ccproxy/{models/permissions.py → plugins/permissions/models.py} +65 -1
- ccproxy/plugins/permissions/plugin.py +153 -0
- ccproxy/{api/routes/permissions.py → plugins/permissions/routes.py} +20 -16
- ccproxy/{api/services/permission_service.py → plugins/permissions/service.py} +65 -11
- ccproxy/{api → plugins/permissions}/ui/permission_handler_protocol.py +1 -1
- ccproxy/{api → plugins/permissions}/ui/terminal_permission_handler.py +66 -10
- ccproxy/plugins/pricing/README.md +34 -0
- ccproxy/plugins/pricing/__init__.py +6 -0
- ccproxy/{pricing → plugins/pricing}/cache.py +7 -6
- ccproxy/{config/pricing.py → plugins/pricing/config.py} +32 -6
- ccproxy/plugins/pricing/exceptions.py +35 -0
- ccproxy/plugins/pricing/loader.py +440 -0
- ccproxy/{pricing → plugins/pricing}/models.py +13 -23
- ccproxy/plugins/pricing/plugin.py +169 -0
- ccproxy/plugins/pricing/service.py +191 -0
- ccproxy/plugins/pricing/tasks.py +300 -0
- ccproxy/{pricing → plugins/pricing}/updater.py +86 -72
- ccproxy/plugins/pricing/utils.py +99 -0
- ccproxy/plugins/request_tracer/README.md +40 -0
- ccproxy/plugins/request_tracer/__init__.py +7 -0
- ccproxy/plugins/request_tracer/config.py +120 -0
- ccproxy/plugins/request_tracer/hook.py +415 -0
- ccproxy/plugins/request_tracer/plugin.py +255 -0
- ccproxy/scheduler/__init__.py +2 -14
- ccproxy/scheduler/core.py +26 -41
- ccproxy/scheduler/manager.py +61 -105
- ccproxy/scheduler/registry.py +6 -32
- ccproxy/scheduler/tasks.py +268 -276
- ccproxy/services/__init__.py +0 -1
- ccproxy/services/adapters/__init__.py +11 -0
- ccproxy/services/adapters/base.py +123 -0
- ccproxy/services/adapters/chain_composer.py +88 -0
- ccproxy/services/adapters/chain_validation.py +44 -0
- ccproxy/services/adapters/chat_accumulator.py +200 -0
- ccproxy/services/adapters/delta_utils.py +142 -0
- ccproxy/services/adapters/format_adapter.py +136 -0
- ccproxy/services/adapters/format_context.py +11 -0
- ccproxy/services/adapters/format_registry.py +158 -0
- ccproxy/services/adapters/http_adapter.py +1045 -0
- ccproxy/services/adapters/mock_adapter.py +118 -0
- ccproxy/services/adapters/protocols.py +35 -0
- ccproxy/services/adapters/simple_converters.py +571 -0
- ccproxy/services/auth_registry.py +180 -0
- ccproxy/services/cache/__init__.py +6 -0
- ccproxy/services/cache/response_cache.py +261 -0
- ccproxy/services/cli_detection.py +437 -0
- ccproxy/services/config/__init__.py +6 -0
- ccproxy/services/config/proxy_configuration.py +111 -0
- ccproxy/services/container.py +256 -0
- ccproxy/services/factories.py +380 -0
- ccproxy/services/handler_config.py +76 -0
- ccproxy/services/interfaces.py +298 -0
- ccproxy/services/mocking/__init__.py +6 -0
- ccproxy/services/mocking/mock_handler.py +291 -0
- ccproxy/services/tracing/__init__.py +7 -0
- ccproxy/services/tracing/interfaces.py +61 -0
- ccproxy/services/tracing/null_tracer.py +57 -0
- ccproxy/streaming/__init__.py +23 -0
- ccproxy/streaming/buffer.py +1056 -0
- ccproxy/streaming/deferred.py +897 -0
- ccproxy/streaming/handler.py +117 -0
- ccproxy/streaming/interfaces.py +77 -0
- ccproxy/streaming/simple_adapter.py +39 -0
- ccproxy/streaming/sse.py +109 -0
- ccproxy/streaming/sse_parser.py +127 -0
- ccproxy/templates/__init__.py +6 -0
- ccproxy/templates/plugin_scaffold.py +695 -0
- ccproxy/testing/endpoints/__init__.py +33 -0
- ccproxy/testing/endpoints/cli.py +215 -0
- ccproxy/testing/endpoints/config.py +874 -0
- ccproxy/testing/endpoints/console.py +57 -0
- ccproxy/testing/endpoints/models.py +100 -0
- ccproxy/testing/endpoints/runner.py +1903 -0
- ccproxy/testing/endpoints/tools.py +308 -0
- ccproxy/testing/mock_responses.py +70 -1
- ccproxy/testing/response_handlers.py +20 -0
- ccproxy/utils/__init__.py +0 -6
- ccproxy/utils/binary_resolver.py +476 -0
- ccproxy/utils/caching.py +327 -0
- ccproxy/utils/cli_logging.py +101 -0
- ccproxy/utils/command_line.py +251 -0
- ccproxy/utils/headers.py +228 -0
- ccproxy/utils/model_mapper.py +120 -0
- ccproxy/utils/startup_helpers.py +68 -446
- ccproxy/utils/version_checker.py +273 -6
- ccproxy_api-0.2.0.dist-info/METADATA +212 -0
- ccproxy_api-0.2.0.dist-info/RECORD +417 -0
- {ccproxy_api-0.1.7.dist-info → ccproxy_api-0.2.0.dist-info}/WHEEL +1 -1
- ccproxy_api-0.2.0.dist-info/entry_points.txt +24 -0
- ccproxy/__init__.py +0 -4
- ccproxy/adapters/__init__.py +0 -11
- ccproxy/adapters/base.py +0 -80
- ccproxy/adapters/codex/__init__.py +0 -11
- ccproxy/adapters/openai/__init__.py +0 -42
- ccproxy/adapters/openai/adapter.py +0 -953
- ccproxy/adapters/openai/models.py +0 -412
- ccproxy/adapters/openai/response_adapter.py +0 -355
- ccproxy/adapters/openai/response_models.py +0 -178
- ccproxy/api/middleware/headers.py +0 -49
- ccproxy/api/middleware/logging.py +0 -180
- ccproxy/api/middleware/request_content_logging.py +0 -297
- ccproxy/api/middleware/server_header.py +0 -58
- ccproxy/api/responses.py +0 -89
- ccproxy/api/routes/claude.py +0 -371
- ccproxy/api/routes/codex.py +0 -1251
- ccproxy/api/routes/metrics.py +0 -1029
- ccproxy/api/routes/proxy.py +0 -211
- ccproxy/api/services/__init__.py +0 -6
- ccproxy/auth/conditional.py +0 -84
- ccproxy/auth/credentials_adapter.py +0 -93
- ccproxy/auth/models.py +0 -118
- ccproxy/auth/oauth/models.py +0 -48
- ccproxy/auth/openai/__init__.py +0 -13
- ccproxy/auth/openai/credentials.py +0 -166
- ccproxy/auth/openai/oauth_client.py +0 -334
- ccproxy/auth/openai/storage.py +0 -184
- ccproxy/auth/storage/json_file.py +0 -158
- ccproxy/auth/storage/keyring.py +0 -189
- ccproxy/claude_sdk/__init__.py +0 -18
- ccproxy/claude_sdk/options.py +0 -194
- ccproxy/claude_sdk/session_pool.py +0 -550
- ccproxy/cli/docker/__init__.py +0 -34
- ccproxy/cli/docker/adapter_factory.py +0 -157
- ccproxy/cli/docker/params.py +0 -274
- ccproxy/config/auth.py +0 -153
- ccproxy/config/claude.py +0 -348
- ccproxy/config/cors.py +0 -79
- ccproxy/config/discovery.py +0 -95
- ccproxy/config/docker_settings.py +0 -264
- ccproxy/config/observability.py +0 -158
- ccproxy/config/reverse_proxy.py +0 -31
- ccproxy/config/scheduler.py +0 -108
- ccproxy/config/server.py +0 -86
- ccproxy/config/validators.py +0 -231
- ccproxy/core/codex_transformers.py +0 -389
- ccproxy/core/http.py +0 -328
- ccproxy/core/http_transformers.py +0 -812
- ccproxy/core/proxy.py +0 -143
- ccproxy/core/validators.py +0 -288
- ccproxy/models/errors.py +0 -42
- ccproxy/models/messages.py +0 -269
- ccproxy/models/requests.py +0 -107
- ccproxy/models/responses.py +0 -270
- ccproxy/models/types.py +0 -102
- ccproxy/observability/__init__.py +0 -51
- ccproxy/observability/access_logger.py +0 -457
- ccproxy/observability/sse_events.py +0 -303
- ccproxy/observability/stats_printer.py +0 -753
- ccproxy/observability/storage/__init__.py +0 -1
- ccproxy/observability/storage/duckdb_simple.py +0 -677
- ccproxy/observability/storage/models.py +0 -70
- ccproxy/observability/streaming_response.py +0 -107
- ccproxy/pricing/__init__.py +0 -19
- ccproxy/pricing/loader.py +0 -251
- ccproxy/services/claude_detection_service.py +0 -243
- ccproxy/services/codex_detection_service.py +0 -252
- ccproxy/services/credentials/__init__.py +0 -55
- ccproxy/services/credentials/config.py +0 -105
- ccproxy/services/credentials/manager.py +0 -561
- ccproxy/services/credentials/oauth_client.py +0 -481
- ccproxy/services/proxy_service.py +0 -1827
- ccproxy/static/.keep +0 -0
- ccproxy/utils/cost_calculator.py +0 -210
- ccproxy/utils/disconnection_monitor.py +0 -83
- ccproxy/utils/model_mapping.py +0 -199
- ccproxy/utils/models_provider.py +0 -150
- ccproxy/utils/simple_request_logger.py +0 -284
- ccproxy/utils/streaming_metrics.py +0 -199
- ccproxy_api-0.1.7.dist-info/METADATA +0 -615
- ccproxy_api-0.1.7.dist-info/RECORD +0 -191
- ccproxy_api-0.1.7.dist-info/entry_points.txt +0 -4
- /ccproxy/{api/middleware/auth.py → auth/models/__init__.py} +0 -0
- /ccproxy/{claude_sdk → plugins/claude_sdk}/exceptions.py +0 -0
- /ccproxy/{docker → plugins/docker}/models.py +0 -0
- /ccproxy/{docker → plugins/docker}/protocol.py +0 -0
- /ccproxy/{docker → plugins/docker}/validators.py +0 -0
- /ccproxy/{auth/oauth/storage.py → plugins/permissions/handlers/__init__.py} +0 -0
- /ccproxy/{api → plugins/permissions}/ui/__init__.py +0 -0
- {ccproxy_api-0.1.7.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
|
+
]
|