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