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,563 @@
|
|
|
1
|
+
"""Hooks middleware for request lifecycle management."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Any, cast
|
|
6
|
+
|
|
7
|
+
from fastapi import Request, Response
|
|
8
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
9
|
+
from starlette.responses import StreamingResponse
|
|
10
|
+
|
|
11
|
+
from ccproxy.api.middleware.streaming_hooks import StreamingResponseWithHooks
|
|
12
|
+
from ccproxy.core.logging import TraceBoundLogger, get_logger
|
|
13
|
+
from ccproxy.core.plugins.hooks import HookEvent, HookManager
|
|
14
|
+
from ccproxy.core.plugins.hooks.base import HookContext
|
|
15
|
+
from ccproxy.utils.headers import (
|
|
16
|
+
extract_request_headers,
|
|
17
|
+
extract_response_headers,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
logger: TraceBoundLogger = get_logger()
|
|
22
|
+
|
|
23
|
+
MAX_BODY_LOG_CHARS = 2048
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _stringify_raw_body(body: bytes | None) -> tuple[str | None, int, bool]:
|
|
27
|
+
"""Convert raw body bytes into a logging-friendly preview."""
|
|
28
|
+
|
|
29
|
+
if not body:
|
|
30
|
+
return None, 0, False
|
|
31
|
+
|
|
32
|
+
text = body.decode("utf-8", errors="replace")
|
|
33
|
+
length = len(text)
|
|
34
|
+
truncated = length > MAX_BODY_LOG_CHARS
|
|
35
|
+
preview = f"{text[:MAX_BODY_LOG_CHARS]}...[truncated]" if truncated else text
|
|
36
|
+
return preview, length, truncated
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class HooksMiddleware(BaseHTTPMiddleware):
|
|
40
|
+
"""Middleware that emits hook lifecycle events for requests.
|
|
41
|
+
|
|
42
|
+
This middleware wraps the entire request-response cycle and emits:
|
|
43
|
+
- REQUEST_STARTED before processing request
|
|
44
|
+
- REQUEST_COMPLETED on successful response
|
|
45
|
+
- REQUEST_FAILED on error
|
|
46
|
+
|
|
47
|
+
It maintains RequestContext compatibility and provides centralized
|
|
48
|
+
hook emission for both regular and streaming responses.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(self, app: Any, hook_manager: HookManager | None = None) -> None:
|
|
52
|
+
"""Initialize the hooks middleware.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
app: ASGI application
|
|
56
|
+
hook_manager: Hook manager for emitting events
|
|
57
|
+
"""
|
|
58
|
+
super().__init__(app)
|
|
59
|
+
self.hook_manager = hook_manager
|
|
60
|
+
|
|
61
|
+
async def dispatch(self, request: Request, call_next: Any) -> Response:
|
|
62
|
+
"""Dispatch the request with hook emission.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
request: The incoming request
|
|
66
|
+
call_next: The next middleware/handler in the chain
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
The response from downstream handlers
|
|
70
|
+
"""
|
|
71
|
+
# Get hook manager from app state if not set during init
|
|
72
|
+
hook_manager = self.hook_manager
|
|
73
|
+
if not hook_manager and hasattr(request.app.state, "hook_manager"):
|
|
74
|
+
hook_manager = request.app.state.hook_manager
|
|
75
|
+
|
|
76
|
+
# Skip hook emission if no hook manager available
|
|
77
|
+
if not hook_manager:
|
|
78
|
+
return cast(Response, await call_next(request))
|
|
79
|
+
|
|
80
|
+
# Extract request_id from ASGI scope extensions
|
|
81
|
+
request_id = getattr(request.state, "request_id", None)
|
|
82
|
+
if not request_id:
|
|
83
|
+
# Fallback to headers or generate one
|
|
84
|
+
request_id = request.headers.get(
|
|
85
|
+
"X-Request-ID", f"req-{int(time.time() * 1000)}"
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# Get or create RequestContext
|
|
89
|
+
from ccproxy.core.request_context import RequestContext
|
|
90
|
+
|
|
91
|
+
request_context = RequestContext.get_current()
|
|
92
|
+
if not request_context:
|
|
93
|
+
# Create minimal context if none exists
|
|
94
|
+
start_time_perf = time.perf_counter()
|
|
95
|
+
request_context = RequestContext(
|
|
96
|
+
request_id=request_id,
|
|
97
|
+
start_time=start_time_perf,
|
|
98
|
+
logger=logger,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# Wall-clock time for human-readable timestamps
|
|
102
|
+
start_time = time.time()
|
|
103
|
+
|
|
104
|
+
# Create hook context for the request
|
|
105
|
+
logger.debug("headers_on_request_start", headers=dict(request.headers))
|
|
106
|
+
hook_context = HookContext(
|
|
107
|
+
event=HookEvent.REQUEST_STARTED, # Will be overridden in emit calls
|
|
108
|
+
timestamp=datetime.fromtimestamp(start_time),
|
|
109
|
+
data={
|
|
110
|
+
"request_id": request_id,
|
|
111
|
+
"method": request.method,
|
|
112
|
+
"url": str(request.url),
|
|
113
|
+
# Extract headers using utility function
|
|
114
|
+
"headers": extract_request_headers(request),
|
|
115
|
+
},
|
|
116
|
+
metadata=getattr(request_context, "metadata", {}),
|
|
117
|
+
request=request,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
# Emit REQUEST_STARTED before processing
|
|
122
|
+
await hook_manager.emit_with_context(hook_context)
|
|
123
|
+
|
|
124
|
+
# Capture and emit HTTP_REQUEST hook with body
|
|
125
|
+
(
|
|
126
|
+
body_preview,
|
|
127
|
+
body_size,
|
|
128
|
+
body_truncated,
|
|
129
|
+
body_is_json,
|
|
130
|
+
) = await self._emit_http_request_hook(hook_manager, request, hook_context)
|
|
131
|
+
|
|
132
|
+
accept_header = request.headers.get("accept", "").lower()
|
|
133
|
+
if "text/event-stream" not in accept_header:
|
|
134
|
+
logger.info(
|
|
135
|
+
"request_started",
|
|
136
|
+
request_id=request_id,
|
|
137
|
+
method=request.method,
|
|
138
|
+
url=str(request.url),
|
|
139
|
+
has_body=body_preview is not None,
|
|
140
|
+
body_size=body_size,
|
|
141
|
+
body_truncated=body_truncated,
|
|
142
|
+
is_json=body_is_json,
|
|
143
|
+
origin="client",
|
|
144
|
+
streaming=False,
|
|
145
|
+
category="http",
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
# Process the request
|
|
149
|
+
response = cast(Response, await call_next(request))
|
|
150
|
+
|
|
151
|
+
# Update hook context with response information
|
|
152
|
+
end_time = time.time()
|
|
153
|
+
response_hook_context = HookContext(
|
|
154
|
+
event=HookEvent.REQUEST_COMPLETED, # Will be overridden in emit calls
|
|
155
|
+
timestamp=datetime.fromtimestamp(start_time),
|
|
156
|
+
data={
|
|
157
|
+
"request_id": request_id,
|
|
158
|
+
"method": request.method,
|
|
159
|
+
"url": str(request.url),
|
|
160
|
+
"headers": extract_request_headers(request),
|
|
161
|
+
"response_status": getattr(response, "status_code", 200),
|
|
162
|
+
# Response headers preserved via extract_response_headers
|
|
163
|
+
"response_headers": extract_response_headers(response),
|
|
164
|
+
"duration": end_time - start_time,
|
|
165
|
+
},
|
|
166
|
+
metadata=getattr(request_context, "metadata", {}),
|
|
167
|
+
request=request,
|
|
168
|
+
response=response,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
# Handle streaming responses specially
|
|
172
|
+
# Check if it's a streaming response (including middleware wrapped streaming responses)
|
|
173
|
+
is_streaming = (
|
|
174
|
+
isinstance(response, StreamingResponse)
|
|
175
|
+
or type(response).__name__ == "_StreamingResponse"
|
|
176
|
+
)
|
|
177
|
+
logger.debug(
|
|
178
|
+
"hooks_middleware_checking_response_type",
|
|
179
|
+
response_type=type(response).__name__,
|
|
180
|
+
response_class=str(type(response)),
|
|
181
|
+
is_streaming=is_streaming,
|
|
182
|
+
request_id=request_id,
|
|
183
|
+
)
|
|
184
|
+
if is_streaming:
|
|
185
|
+
# For streaming responses, wrap with hook emission on completion
|
|
186
|
+
# Don't emit REQUEST_COMPLETED here - it will be emitted when streaming actually completes
|
|
187
|
+
|
|
188
|
+
logger.debug(
|
|
189
|
+
"hooks_middleware_wrapping_streaming_response",
|
|
190
|
+
request_id=request_id,
|
|
191
|
+
method=request.method,
|
|
192
|
+
url=str(request.url),
|
|
193
|
+
status_code=getattr(response, "status_code", 200),
|
|
194
|
+
duration=end_time - start_time,
|
|
195
|
+
response_type="streaming",
|
|
196
|
+
category="hooks",
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
# Wrap the streaming response to emit hooks on completion
|
|
200
|
+
request_data = {
|
|
201
|
+
"method": request.method,
|
|
202
|
+
"url": str(request.url),
|
|
203
|
+
"headers": extract_request_headers(request),
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
# Include RequestContext metadata if available
|
|
207
|
+
request_metadata: dict[str, Any] = {}
|
|
208
|
+
if request_context:
|
|
209
|
+
request_metadata = getattr(request_context, "metadata", {})
|
|
210
|
+
|
|
211
|
+
response_stream = cast(StreamingResponse, response)
|
|
212
|
+
is_sse = self._is_sse_response(response_stream)
|
|
213
|
+
|
|
214
|
+
if is_sse:
|
|
215
|
+
logger.info(
|
|
216
|
+
"sse_connection_started",
|
|
217
|
+
request_id=request_id,
|
|
218
|
+
method=request.method,
|
|
219
|
+
url=str(request.url),
|
|
220
|
+
origin="client",
|
|
221
|
+
streaming=True,
|
|
222
|
+
has_body=body_preview is not None,
|
|
223
|
+
body_size=body_size,
|
|
224
|
+
body_truncated=body_truncated,
|
|
225
|
+
is_json=body_is_json,
|
|
226
|
+
category="http",
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
# Coerce body iterator to AsyncGenerator[bytes]
|
|
230
|
+
async def _coerce_bytes() -> Any:
|
|
231
|
+
async for chunk in response_stream.body_iterator:
|
|
232
|
+
if isinstance(chunk, bytes):
|
|
233
|
+
yield chunk
|
|
234
|
+
elif isinstance(chunk, memoryview):
|
|
235
|
+
yield bytes(chunk)
|
|
236
|
+
else:
|
|
237
|
+
yield str(chunk).encode("utf-8", errors="replace")
|
|
238
|
+
|
|
239
|
+
wrapped_response = StreamingResponseWithHooks(
|
|
240
|
+
content=_coerce_bytes(),
|
|
241
|
+
hook_manager=hook_manager,
|
|
242
|
+
request_id=request_id,
|
|
243
|
+
request_data=request_data,
|
|
244
|
+
request_metadata=request_metadata,
|
|
245
|
+
start_time=start_time,
|
|
246
|
+
status_code=response_stream.status_code,
|
|
247
|
+
origin="client",
|
|
248
|
+
is_sse=is_sse,
|
|
249
|
+
headers=dict(response_stream.headers),
|
|
250
|
+
media_type=response_stream.media_type,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
return wrapped_response
|
|
254
|
+
else:
|
|
255
|
+
# For regular responses, emit HTTP_RESPONSE and REQUEST_COMPLETED
|
|
256
|
+
await self._emit_http_response_hook(
|
|
257
|
+
hook_manager, request, response, hook_context
|
|
258
|
+
)
|
|
259
|
+
await hook_manager.emit_with_context(response_hook_context)
|
|
260
|
+
|
|
261
|
+
duration_ms = round((end_time - start_time) * 1000, 3)
|
|
262
|
+
logger.info(
|
|
263
|
+
"request_completed",
|
|
264
|
+
request_id=request_id,
|
|
265
|
+
method=request.method,
|
|
266
|
+
url=str(request.url),
|
|
267
|
+
status_code=getattr(response, "status_code", 200),
|
|
268
|
+
duration_ms=duration_ms,
|
|
269
|
+
origin="client",
|
|
270
|
+
streaming=False,
|
|
271
|
+
success=True,
|
|
272
|
+
category="http",
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
logger.debug(
|
|
276
|
+
"hooks_middleware_request_completed",
|
|
277
|
+
request_id=request_id,
|
|
278
|
+
method=request.method,
|
|
279
|
+
url=str(request.url),
|
|
280
|
+
status_code=getattr(response, "status_code", 200),
|
|
281
|
+
duration=end_time - start_time,
|
|
282
|
+
response_type="regular",
|
|
283
|
+
category="hooks",
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
return response
|
|
287
|
+
|
|
288
|
+
except Exception as e:
|
|
289
|
+
# Update hook context with error information
|
|
290
|
+
end_time = time.time()
|
|
291
|
+
error_hook_context = HookContext(
|
|
292
|
+
event=HookEvent.REQUEST_FAILED, # Will be overridden in emit calls
|
|
293
|
+
timestamp=datetime.fromtimestamp(start_time),
|
|
294
|
+
data={
|
|
295
|
+
"request_id": request_id,
|
|
296
|
+
"method": request.method,
|
|
297
|
+
"url": str(request.url),
|
|
298
|
+
"headers": extract_request_headers(request),
|
|
299
|
+
"duration": end_time - start_time,
|
|
300
|
+
},
|
|
301
|
+
metadata=getattr(request_context, "metadata", {}),
|
|
302
|
+
request=request,
|
|
303
|
+
error=e,
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
# Emit REQUEST_FAILED on error
|
|
307
|
+
try:
|
|
308
|
+
await hook_manager.emit_with_context(error_hook_context)
|
|
309
|
+
except Exception as hook_error:
|
|
310
|
+
logger.error(
|
|
311
|
+
"hooks_middleware_hook_emission_failed",
|
|
312
|
+
request_id=request_id,
|
|
313
|
+
original_error=str(e),
|
|
314
|
+
hook_error=str(hook_error),
|
|
315
|
+
category="hooks",
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
logger.debug(
|
|
319
|
+
"hooks_middleware_request_failed",
|
|
320
|
+
request_id=request_id,
|
|
321
|
+
method=request.method,
|
|
322
|
+
url=str(request.url),
|
|
323
|
+
error=str(e),
|
|
324
|
+
duration=end_time - start_time,
|
|
325
|
+
category="hooks",
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
duration_ms = round((end_time - start_time) * 1000, 3)
|
|
329
|
+
status_code = getattr(e, "status_code", None)
|
|
330
|
+
logger.info(
|
|
331
|
+
"request_completed",
|
|
332
|
+
request_id=request_id,
|
|
333
|
+
method=request.method,
|
|
334
|
+
url=str(request.url),
|
|
335
|
+
status_code=status_code,
|
|
336
|
+
duration_ms=duration_ms,
|
|
337
|
+
origin="client",
|
|
338
|
+
streaming=False,
|
|
339
|
+
success=False,
|
|
340
|
+
error_type=type(e).__name__,
|
|
341
|
+
category="http",
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
# Re-raise the original exception
|
|
345
|
+
raise
|
|
346
|
+
|
|
347
|
+
async def _emit_http_request_hook(
|
|
348
|
+
self, hook_manager: HookManager, request: Request, base_context: HookContext
|
|
349
|
+
) -> tuple[str | None, int, bool, bool]:
|
|
350
|
+
"""Emit HTTP_REQUEST hook with request body capture.
|
|
351
|
+
|
|
352
|
+
Args:
|
|
353
|
+
hook_manager: Hook manager for emitting events
|
|
354
|
+
request: FastAPI request object
|
|
355
|
+
base_context: Base hook context for request metadata
|
|
356
|
+
"""
|
|
357
|
+
try:
|
|
358
|
+
# Capture request body - this may be empty for GET requests
|
|
359
|
+
request_body = await self._capture_request_body(request)
|
|
360
|
+
|
|
361
|
+
# Build HTTP request context
|
|
362
|
+
http_request_context = {
|
|
363
|
+
"request_id": base_context.data.get("request_id"),
|
|
364
|
+
"method": request.method,
|
|
365
|
+
"url": str(request.url),
|
|
366
|
+
"headers": extract_request_headers(request),
|
|
367
|
+
"is_client_request": True, # Distinguish from provider requests
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
# Add body information if available - pass raw data to let formatters handle conversion
|
|
371
|
+
if request_body:
|
|
372
|
+
http_request_context["body"] = request_body
|
|
373
|
+
# Set content type for formatters to use
|
|
374
|
+
content_type = request.headers.get("content-type", "")
|
|
375
|
+
http_request_context["is_json"] = "application/json" in content_type
|
|
376
|
+
|
|
377
|
+
preview, length, truncated = _stringify_raw_body(request_body)
|
|
378
|
+
logger.debug(
|
|
379
|
+
"client_http_request",
|
|
380
|
+
request_id=base_context.data.get("request_id"),
|
|
381
|
+
method=request.method,
|
|
382
|
+
url=str(request.url),
|
|
383
|
+
body_preview=preview,
|
|
384
|
+
body_size=length,
|
|
385
|
+
body_truncated=truncated,
|
|
386
|
+
category="http",
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
# Emit HTTP_REQUEST hook
|
|
390
|
+
await hook_manager.emit(HookEvent.HTTP_REQUEST, http_request_context)
|
|
391
|
+
|
|
392
|
+
return (
|
|
393
|
+
preview,
|
|
394
|
+
length,
|
|
395
|
+
truncated,
|
|
396
|
+
bool(http_request_context.get("is_json", False)),
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
except Exception as e:
|
|
400
|
+
logger.debug(
|
|
401
|
+
"http_request_hook_emission_failed",
|
|
402
|
+
error=str(e),
|
|
403
|
+
request_id=base_context.data.get("request_id"),
|
|
404
|
+
method=request.method,
|
|
405
|
+
category="hooks",
|
|
406
|
+
)
|
|
407
|
+
return (None, 0, False, False)
|
|
408
|
+
|
|
409
|
+
async def _emit_http_response_hook(
|
|
410
|
+
self,
|
|
411
|
+
hook_manager: HookManager,
|
|
412
|
+
request: Request,
|
|
413
|
+
response: Response,
|
|
414
|
+
base_context: HookContext,
|
|
415
|
+
) -> None:
|
|
416
|
+
"""Emit HTTP_RESPONSE hook with response body capture.
|
|
417
|
+
|
|
418
|
+
Args:
|
|
419
|
+
hook_manager: Hook manager for emitting events
|
|
420
|
+
request: FastAPI request object
|
|
421
|
+
response: FastAPI response object
|
|
422
|
+
base_context: Base hook context for request metadata
|
|
423
|
+
"""
|
|
424
|
+
try:
|
|
425
|
+
# Build HTTP response context
|
|
426
|
+
http_response_context = {
|
|
427
|
+
"request_id": base_context.data.get("request_id"),
|
|
428
|
+
"method": request.method,
|
|
429
|
+
"url": str(request.url),
|
|
430
|
+
"headers": extract_request_headers(request),
|
|
431
|
+
"status_code": getattr(response, "status_code", 200),
|
|
432
|
+
"response_headers": dict(getattr(response, "headers", {})),
|
|
433
|
+
"is_client_response": True, # Distinguish from provider responses
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
# Capture response body for non-streaming responses
|
|
437
|
+
response_body = await self._capture_response_body(response)
|
|
438
|
+
if response_body is not None:
|
|
439
|
+
http_response_context["response_body"] = response_body
|
|
440
|
+
|
|
441
|
+
preview, length, truncated = _stringify_raw_body(response_body)
|
|
442
|
+
logger.debug(
|
|
443
|
+
"client_http_response",
|
|
444
|
+
request_id=base_context.data.get("request_id"),
|
|
445
|
+
method=request.method,
|
|
446
|
+
url=str(request.url),
|
|
447
|
+
status_code=getattr(response, "status_code", 200),
|
|
448
|
+
body_preview=preview,
|
|
449
|
+
body_size=length,
|
|
450
|
+
body_truncated=truncated,
|
|
451
|
+
category="http",
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
# Emit HTTP_RESPONSE hook
|
|
455
|
+
await hook_manager.emit(HookEvent.HTTP_RESPONSE, http_response_context)
|
|
456
|
+
|
|
457
|
+
except Exception as e:
|
|
458
|
+
logger.debug(
|
|
459
|
+
"http_response_hook_emission_failed",
|
|
460
|
+
error=str(e),
|
|
461
|
+
request_id=base_context.data.get("request_id"),
|
|
462
|
+
status_code=getattr(response, "status_code", 200),
|
|
463
|
+
category="hooks",
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
async def _capture_request_body(self, request: Request) -> bytes:
|
|
467
|
+
"""Capture request body, handling caching for multiple reads.
|
|
468
|
+
|
|
469
|
+
Args:
|
|
470
|
+
request: FastAPI request object
|
|
471
|
+
|
|
472
|
+
Returns:
|
|
473
|
+
Request body as bytes
|
|
474
|
+
"""
|
|
475
|
+
try:
|
|
476
|
+
# Check if body is already cached
|
|
477
|
+
if hasattr(request.state, "cached_body"):
|
|
478
|
+
return cast(bytes, request.state.cached_body)
|
|
479
|
+
|
|
480
|
+
# Read and cache body for future use
|
|
481
|
+
body = await request.body()
|
|
482
|
+
request.state.cached_body = body
|
|
483
|
+
return body
|
|
484
|
+
|
|
485
|
+
except Exception as e:
|
|
486
|
+
logger.debug(
|
|
487
|
+
"request_body_capture_failed",
|
|
488
|
+
error=str(e),
|
|
489
|
+
method=request.method,
|
|
490
|
+
url=str(request.url),
|
|
491
|
+
)
|
|
492
|
+
return b""
|
|
493
|
+
|
|
494
|
+
async def _capture_response_body(self, response: Response) -> bytes | None:
|
|
495
|
+
"""Capture response body for non-streaming responses.
|
|
496
|
+
|
|
497
|
+
Args:
|
|
498
|
+
response: FastAPI response object
|
|
499
|
+
|
|
500
|
+
Returns:
|
|
501
|
+
Response body as raw bytes or None if unavailable
|
|
502
|
+
"""
|
|
503
|
+
try:
|
|
504
|
+
# For regular Response objects, try to get body
|
|
505
|
+
if hasattr(response, "body") and response.body:
|
|
506
|
+
body_data = response.body
|
|
507
|
+
logger.debug(
|
|
508
|
+
"response_body_capture_debug",
|
|
509
|
+
body_type=type(body_data).__name__,
|
|
510
|
+
body_size=len(body_data)
|
|
511
|
+
if hasattr(body_data, "__len__")
|
|
512
|
+
else "no_len",
|
|
513
|
+
has_body_attr=hasattr(response, "body"),
|
|
514
|
+
body_truthy=bool(response.body),
|
|
515
|
+
)
|
|
516
|
+
# Ensure return type is bytes
|
|
517
|
+
if isinstance(body_data, memoryview):
|
|
518
|
+
return body_data.tobytes()
|
|
519
|
+
return body_data
|
|
520
|
+
|
|
521
|
+
logger.debug(
|
|
522
|
+
"response_body_capture_none",
|
|
523
|
+
has_body_attr=hasattr(response, "body"),
|
|
524
|
+
body_truthy=bool(getattr(response, "body", None)),
|
|
525
|
+
response_type=type(response).__name__,
|
|
526
|
+
)
|
|
527
|
+
return None
|
|
528
|
+
|
|
529
|
+
except Exception as e:
|
|
530
|
+
logger.debug(
|
|
531
|
+
"response_body_capture_failed",
|
|
532
|
+
error=str(e),
|
|
533
|
+
status_code=getattr(response, "status_code", 200),
|
|
534
|
+
)
|
|
535
|
+
return None
|
|
536
|
+
|
|
537
|
+
@staticmethod
|
|
538
|
+
def _is_sse_response(response: StreamingResponse) -> bool:
|
|
539
|
+
"""Determine whether a streaming response is Server-Sent Events."""
|
|
540
|
+
media_type = (response.media_type or "").lower() if response.media_type else ""
|
|
541
|
+
if "text/event-stream" in media_type:
|
|
542
|
+
return True
|
|
543
|
+
content_type = response.headers.get("content-type", "")
|
|
544
|
+
return "text/event-stream" in content_type.lower()
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def create_hooks_middleware(
|
|
548
|
+
hook_manager: HookManager | None = None,
|
|
549
|
+
) -> type[HooksMiddleware]:
|
|
550
|
+
"""Create a hooks middleware class with the provided hook manager.
|
|
551
|
+
|
|
552
|
+
Args:
|
|
553
|
+
hook_manager: Hook manager for emitting events
|
|
554
|
+
|
|
555
|
+
Returns:
|
|
556
|
+
HooksMiddleware class configured with the hook manager
|
|
557
|
+
"""
|
|
558
|
+
|
|
559
|
+
class ConfiguredHooksMiddleware(HooksMiddleware):
|
|
560
|
+
def __init__(self, app: Any) -> None:
|
|
561
|
+
super().__init__(app, hook_manager)
|
|
562
|
+
|
|
563
|
+
return ConfiguredHooksMiddleware
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import MutableMapping
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from starlette.types import ASGIApp, Receive, Scope, Send
|
|
7
|
+
|
|
8
|
+
from ccproxy.core.logging import get_logger
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
logger = get_logger()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class NormalizeHeadersMiddleware:
|
|
15
|
+
"""Middleware to normalize outgoing response headers.
|
|
16
|
+
|
|
17
|
+
- Strips unsafe/mismatched headers (Content-Length, Transfer-Encoding)
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, app: ASGIApp) -> None:
|
|
21
|
+
self.app = app
|
|
22
|
+
|
|
23
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
24
|
+
if scope["type"] != "http":
|
|
25
|
+
await self.app(scope, receive, send)
|
|
26
|
+
return
|
|
27
|
+
|
|
28
|
+
send_called = False
|
|
29
|
+
|
|
30
|
+
async def send_wrapper(message: MutableMapping[str, Any]) -> None:
|
|
31
|
+
nonlocal send_called
|
|
32
|
+
if message.get("type") == "http.response.start":
|
|
33
|
+
headers = message.get("headers", [])
|
|
34
|
+
# Filter out content-length and transfer-encoding
|
|
35
|
+
filtered: list[tuple[bytes, bytes]] = []
|
|
36
|
+
has_server = False
|
|
37
|
+
for name, value in headers:
|
|
38
|
+
lower = name.lower()
|
|
39
|
+
if lower in (b"content-length", b"transfer-encoding"):
|
|
40
|
+
continue
|
|
41
|
+
if lower == b"server":
|
|
42
|
+
has_server = True
|
|
43
|
+
filtered.append((name, value))
|
|
44
|
+
|
|
45
|
+
# Ensure a Server header exists; default to "ccproxy"
|
|
46
|
+
if not has_server:
|
|
47
|
+
filtered.append((b"server", b"ccproxy"))
|
|
48
|
+
|
|
49
|
+
message = {**message, "headers": filtered}
|
|
50
|
+
send_called = True
|
|
51
|
+
await send(message)
|
|
52
|
+
|
|
53
|
+
# Call downstream app
|
|
54
|
+
await self.app(scope, receive, send_wrapper)
|
|
55
|
+
|
|
56
|
+
# Note: We are not re-wrapping to ProxyResponse here because we operate
|
|
57
|
+
# at ASGI message level. Header normalization is sufficient; Starlette
|
|
58
|
+
# computes Content-Length automatically from body when omitted.
|
|
59
|
+
return
|