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