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,587 @@
|
|
|
1
|
+
"""Credential rotation manager for the credential balancer plugin."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import time
|
|
7
|
+
import uuid
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from datetime import UTC, datetime
|
|
10
|
+
from types import TracebackType
|
|
11
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
12
|
+
|
|
13
|
+
from ccproxy.auth.exceptions import AuthenticationError
|
|
14
|
+
from ccproxy.auth.manager import AuthManager
|
|
15
|
+
from ccproxy.auth.managers.token_snapshot import TokenSnapshot
|
|
16
|
+
from ccproxy.auth.models.credentials import BaseCredentials
|
|
17
|
+
from ccproxy.auth.oauth.protocol import StandardProfileFields
|
|
18
|
+
from ccproxy.core.logging import TraceBoundLogger, get_plugin_logger
|
|
19
|
+
from ccproxy.core.request_context import RequestContext
|
|
20
|
+
|
|
21
|
+
from .config import CredentialPoolConfig, CredentialSource, RotationStrategy
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from .factory import AuthManagerFactory
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
logger = get_plugin_logger(__name__)
|
|
29
|
+
|
|
30
|
+
SNAPSHOT_REFRESH_GRACE_SECONDS = 120.0
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(slots=True)
|
|
34
|
+
class CredentialEntry:
|
|
35
|
+
"""Wrapper for an AuthManager with failure tracking and cooldown logic."""
|
|
36
|
+
|
|
37
|
+
config: CredentialSource
|
|
38
|
+
manager: AuthManager
|
|
39
|
+
max_failures: int
|
|
40
|
+
cooldown_seconds: float
|
|
41
|
+
logger: TraceBoundLogger
|
|
42
|
+
_failure_count: int = 0
|
|
43
|
+
_disabled_until: float | None = None
|
|
44
|
+
_lock: asyncio.Lock = field(default_factory=asyncio.Lock, init=False)
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def label(self) -> str:
|
|
48
|
+
"""Return a stable label for this credential entry."""
|
|
49
|
+
return self.config.resolved_label
|
|
50
|
+
|
|
51
|
+
async def get_access_token(self) -> str:
|
|
52
|
+
"""Get access token from the composed manager.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
Access token string
|
|
56
|
+
|
|
57
|
+
Raises:
|
|
58
|
+
AuthenticationError: If no valid token available
|
|
59
|
+
"""
|
|
60
|
+
async with self._lock:
|
|
61
|
+
return await self.manager.get_access_token()
|
|
62
|
+
|
|
63
|
+
async def get_access_token_with_refresh(self) -> str:
|
|
64
|
+
"""Get access token with automatic refresh if supported.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
Access token string
|
|
68
|
+
|
|
69
|
+
Raises:
|
|
70
|
+
AuthenticationError: If no valid token available
|
|
71
|
+
"""
|
|
72
|
+
async with self._lock:
|
|
73
|
+
# Try to use enhanced refresh if available
|
|
74
|
+
if hasattr(self.manager, "get_access_token_with_refresh"):
|
|
75
|
+
return await self.manager.get_access_token_with_refresh() # type: ignore
|
|
76
|
+
# Fallback to basic get_access_token
|
|
77
|
+
return await self.manager.get_access_token()
|
|
78
|
+
|
|
79
|
+
async def is_authenticated(self) -> bool:
|
|
80
|
+
"""Check if manager has valid authentication.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
True if authenticated, False otherwise
|
|
84
|
+
"""
|
|
85
|
+
try:
|
|
86
|
+
async with self._lock:
|
|
87
|
+
return await self.manager.is_authenticated()
|
|
88
|
+
except Exception:
|
|
89
|
+
return False
|
|
90
|
+
|
|
91
|
+
def mark_failure(self) -> None:
|
|
92
|
+
"""Record a failure and potentially disable this credential."""
|
|
93
|
+
self._failure_count += 1
|
|
94
|
+
self.logger.debug(
|
|
95
|
+
"credential_balancer_failure_recorded",
|
|
96
|
+
credential=self.label,
|
|
97
|
+
failures=self._failure_count,
|
|
98
|
+
)
|
|
99
|
+
if self._failure_count >= self.max_failures:
|
|
100
|
+
if self.cooldown_seconds > 0:
|
|
101
|
+
self._disabled_until = time.monotonic() + self.cooldown_seconds
|
|
102
|
+
else:
|
|
103
|
+
self._disabled_until = float("inf")
|
|
104
|
+
self.logger.warning(
|
|
105
|
+
"credential_balancer_credential_disabled",
|
|
106
|
+
credential=self.label,
|
|
107
|
+
cooldown_seconds=self.cooldown_seconds,
|
|
108
|
+
failures=self._failure_count,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
def reset_failures(self) -> None:
|
|
112
|
+
"""Reset failure count and re-enable this credential."""
|
|
113
|
+
if self._failure_count or self._disabled_until:
|
|
114
|
+
self.logger.debug(
|
|
115
|
+
"credential_balancer_failure_reset",
|
|
116
|
+
credential=self.label,
|
|
117
|
+
)
|
|
118
|
+
self._failure_count = 0
|
|
119
|
+
self._disabled_until = None
|
|
120
|
+
|
|
121
|
+
def is_disabled(self, now: float) -> bool:
|
|
122
|
+
"""Check if this credential is currently disabled.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
now: Current monotonic time
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
True if disabled, False if available
|
|
129
|
+
"""
|
|
130
|
+
if self._disabled_until is None:
|
|
131
|
+
return False
|
|
132
|
+
if self._disabled_until == float("inf"):
|
|
133
|
+
return True
|
|
134
|
+
if now >= self._disabled_until:
|
|
135
|
+
self.logger.debug(
|
|
136
|
+
"credential_balancer_cooldown_expired",
|
|
137
|
+
credential=self.label,
|
|
138
|
+
)
|
|
139
|
+
self._disabled_until = None
|
|
140
|
+
self._failure_count = 0
|
|
141
|
+
return False
|
|
142
|
+
return True
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@dataclass(slots=True)
|
|
146
|
+
class _RequestState:
|
|
147
|
+
entry: CredentialEntry
|
|
148
|
+
renew_attempted: bool = False
|
|
149
|
+
created_at: float = field(default_factory=time.monotonic)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class CredentialBalancerTokenManager(AuthManager):
|
|
153
|
+
"""Auth manager that rotates across multiple credential sources."""
|
|
154
|
+
|
|
155
|
+
def __init__(
|
|
156
|
+
self,
|
|
157
|
+
config: CredentialPoolConfig,
|
|
158
|
+
entries: list[CredentialEntry],
|
|
159
|
+
*,
|
|
160
|
+
logger: TraceBoundLogger | None = None,
|
|
161
|
+
) -> None:
|
|
162
|
+
"""Initialize credential balancer with pre-created entries.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
config: Pool configuration
|
|
166
|
+
entries: List of credential entries with composed managers
|
|
167
|
+
logger: Optional logger for this manager
|
|
168
|
+
"""
|
|
169
|
+
self._config = config
|
|
170
|
+
self._logger = (logger or get_plugin_logger(__name__)).bind(
|
|
171
|
+
manager=config.manager_name,
|
|
172
|
+
provider=config.provider,
|
|
173
|
+
)
|
|
174
|
+
self._entries = entries
|
|
175
|
+
self._strategy = config.strategy
|
|
176
|
+
self._failure_codes = set(config.failure_status_codes)
|
|
177
|
+
self._lock = asyncio.Lock()
|
|
178
|
+
self._state_lock = asyncio.Lock()
|
|
179
|
+
self._request_states: dict[str, _RequestState] = {}
|
|
180
|
+
self._active_index = 0
|
|
181
|
+
self._next_index = 0
|
|
182
|
+
|
|
183
|
+
@classmethod
|
|
184
|
+
async def create(
|
|
185
|
+
cls,
|
|
186
|
+
config: CredentialPoolConfig,
|
|
187
|
+
factory: AuthManagerFactory | None = None,
|
|
188
|
+
*,
|
|
189
|
+
logger: TraceBoundLogger | None = None,
|
|
190
|
+
) -> CredentialBalancerTokenManager:
|
|
191
|
+
"""Async factory to create balancer with composed managers.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
config: Pool configuration
|
|
195
|
+
factory: Auth manager factory for creating managers from sources
|
|
196
|
+
logger: Optional logger for this manager
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
Initialized CredentialBalancerTokenManager instance
|
|
200
|
+
"""
|
|
201
|
+
from ccproxy.plugins.credential_balancer.factory import AuthManagerFactory
|
|
202
|
+
|
|
203
|
+
if factory is None:
|
|
204
|
+
factory = AuthManagerFactory(logger=logger)
|
|
205
|
+
|
|
206
|
+
bound_logger = (logger or get_plugin_logger(__name__)).bind(
|
|
207
|
+
manager=config.manager_name,
|
|
208
|
+
provider=config.provider,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
# Create entries with composed managers
|
|
212
|
+
entries: list[CredentialEntry] = []
|
|
213
|
+
failed_credentials: list[str] = []
|
|
214
|
+
|
|
215
|
+
for credential in config.credentials:
|
|
216
|
+
try:
|
|
217
|
+
manager = await factory.create_from_source(credential, config.provider)
|
|
218
|
+
entry = CredentialEntry(
|
|
219
|
+
config=credential,
|
|
220
|
+
manager=manager,
|
|
221
|
+
max_failures=config.max_failures_before_disable,
|
|
222
|
+
cooldown_seconds=config.cooldown_seconds,
|
|
223
|
+
logger=bound_logger.bind(credential=credential.resolved_label),
|
|
224
|
+
)
|
|
225
|
+
entries.append(entry)
|
|
226
|
+
except AuthenticationError as e:
|
|
227
|
+
# Log clean warning for failed credential without stack trace
|
|
228
|
+
label = credential.resolved_label
|
|
229
|
+
bound_logger.warning(
|
|
230
|
+
"credential_balancer_credential_skipped",
|
|
231
|
+
credential=label,
|
|
232
|
+
reason=str(e),
|
|
233
|
+
category="auth",
|
|
234
|
+
)
|
|
235
|
+
failed_credentials.append(label)
|
|
236
|
+
continue
|
|
237
|
+
except Exception as e:
|
|
238
|
+
# Unexpected errors still get logged with type info
|
|
239
|
+
label = credential.resolved_label
|
|
240
|
+
bound_logger.error(
|
|
241
|
+
"credential_balancer_credential_failed",
|
|
242
|
+
credential=label,
|
|
243
|
+
error=str(e),
|
|
244
|
+
error_type=type(e).__name__,
|
|
245
|
+
category="auth",
|
|
246
|
+
)
|
|
247
|
+
failed_credentials.append(label)
|
|
248
|
+
continue
|
|
249
|
+
|
|
250
|
+
# Warn if some credentials failed
|
|
251
|
+
if failed_credentials:
|
|
252
|
+
bound_logger.warning(
|
|
253
|
+
"credential_balancer_partial_initialization",
|
|
254
|
+
total=len(config.credentials),
|
|
255
|
+
failed=len(failed_credentials),
|
|
256
|
+
succeeded=len(entries),
|
|
257
|
+
failed_labels=failed_credentials,
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
# Ensure we have at least one valid credential
|
|
261
|
+
if not entries:
|
|
262
|
+
raise AuthenticationError(
|
|
263
|
+
f"No valid credentials available for {config.manager_name}. "
|
|
264
|
+
f"All {len(config.credentials)} credential(s) failed to load."
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
return cls(config, entries, logger=logger)
|
|
268
|
+
|
|
269
|
+
async def get_access_token(self) -> str:
|
|
270
|
+
"""Get access token from selected credential entry.
|
|
271
|
+
|
|
272
|
+
Returns:
|
|
273
|
+
Access token string
|
|
274
|
+
|
|
275
|
+
Raises:
|
|
276
|
+
AuthenticationError: If no valid token available
|
|
277
|
+
"""
|
|
278
|
+
entry = await self._select_entry()
|
|
279
|
+
try:
|
|
280
|
+
token = await entry.get_access_token()
|
|
281
|
+
request_id = await self._register_request(entry)
|
|
282
|
+
self._logger.debug(
|
|
283
|
+
"credential_balancer_token_selected",
|
|
284
|
+
credential=entry.label,
|
|
285
|
+
request_id=request_id,
|
|
286
|
+
)
|
|
287
|
+
return token
|
|
288
|
+
except AuthenticationError:
|
|
289
|
+
entry.mark_failure()
|
|
290
|
+
await self._handle_entry_failure(entry)
|
|
291
|
+
raise
|
|
292
|
+
|
|
293
|
+
async def get_access_token_with_refresh(self) -> str:
|
|
294
|
+
"""Get access token with automatic refresh if supported.
|
|
295
|
+
|
|
296
|
+
Returns:
|
|
297
|
+
Access token string
|
|
298
|
+
|
|
299
|
+
Raises:
|
|
300
|
+
AuthenticationError: If no valid token available
|
|
301
|
+
"""
|
|
302
|
+
try:
|
|
303
|
+
return await self.get_access_token()
|
|
304
|
+
except AuthenticationError as exc:
|
|
305
|
+
# Try to refresh the active entry's token
|
|
306
|
+
entry = await self._select_entry(require_active=True)
|
|
307
|
+
try:
|
|
308
|
+
token = await entry.get_access_token_with_refresh()
|
|
309
|
+
request_id = await self._register_request(entry)
|
|
310
|
+
self._logger.debug(
|
|
311
|
+
"credential_balancer_manual_refresh_succeeded",
|
|
312
|
+
credential=entry.label,
|
|
313
|
+
request_id=request_id,
|
|
314
|
+
)
|
|
315
|
+
return token
|
|
316
|
+
except AuthenticationError:
|
|
317
|
+
self._logger.debug(
|
|
318
|
+
"credential_balancer_manual_refresh_failed",
|
|
319
|
+
credential=entry.label,
|
|
320
|
+
)
|
|
321
|
+
raise exc
|
|
322
|
+
|
|
323
|
+
async def get_credentials(self) -> BaseCredentials:
|
|
324
|
+
raise AuthenticationError(
|
|
325
|
+
"Credential balancer does not expose provider-specific credential models"
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
async def is_authenticated(self) -> bool:
|
|
329
|
+
"""Check if any credential is authenticated.
|
|
330
|
+
|
|
331
|
+
Returns:
|
|
332
|
+
True if at least one credential is authenticated, False otherwise
|
|
333
|
+
"""
|
|
334
|
+
try:
|
|
335
|
+
entry = await self._select_entry()
|
|
336
|
+
except AuthenticationError:
|
|
337
|
+
return False
|
|
338
|
+
return await entry.is_authenticated()
|
|
339
|
+
|
|
340
|
+
async def get_user_profile(self) -> StandardProfileFields | None:
|
|
341
|
+
"""Get user profile (not available for balancer).
|
|
342
|
+
|
|
343
|
+
Returns:
|
|
344
|
+
None, as balancer aggregates multiple credentials
|
|
345
|
+
"""
|
|
346
|
+
return None
|
|
347
|
+
|
|
348
|
+
async def get_profile_quick(self) -> Any:
|
|
349
|
+
"""Get profile information without I/O (for compatibility).
|
|
350
|
+
|
|
351
|
+
Returns:
|
|
352
|
+
None, as balancer doesn't maintain profile cache
|
|
353
|
+
"""
|
|
354
|
+
return None
|
|
355
|
+
|
|
356
|
+
async def validate_credentials(self) -> bool:
|
|
357
|
+
"""Validate that credentials are available and valid.
|
|
358
|
+
|
|
359
|
+
Returns:
|
|
360
|
+
True if valid credentials available, False otherwise
|
|
361
|
+
"""
|
|
362
|
+
return await self.is_authenticated()
|
|
363
|
+
|
|
364
|
+
def get_provider_name(self) -> str:
|
|
365
|
+
"""Get the provider name for this balancer.
|
|
366
|
+
|
|
367
|
+
Returns:
|
|
368
|
+
Provider name string
|
|
369
|
+
"""
|
|
370
|
+
return self._config.provider
|
|
371
|
+
|
|
372
|
+
async def __aenter__(self) -> CredentialBalancerTokenManager:
|
|
373
|
+
"""Async context manager entry."""
|
|
374
|
+
return self
|
|
375
|
+
|
|
376
|
+
async def __aexit__(
|
|
377
|
+
self,
|
|
378
|
+
exc_type: type[BaseException] | None,
|
|
379
|
+
exc: BaseException | None,
|
|
380
|
+
tb: TracebackType | None,
|
|
381
|
+
) -> None:
|
|
382
|
+
"""Async context manager exit."""
|
|
383
|
+
return None
|
|
384
|
+
|
|
385
|
+
async def load_credentials(self) -> dict[str, TokenSnapshot | None]:
|
|
386
|
+
"""Load token snapshots from all credential entries.
|
|
387
|
+
|
|
388
|
+
Returns:
|
|
389
|
+
Dictionary mapping credential labels to their token snapshots
|
|
390
|
+
"""
|
|
391
|
+
results: dict[str, TokenSnapshot | None] = {}
|
|
392
|
+
for entry in self._entries:
|
|
393
|
+
# Try to get token snapshot from manager if supported
|
|
394
|
+
if hasattr(entry.manager, "get_token_snapshot"):
|
|
395
|
+
try:
|
|
396
|
+
# Cast to avoid mypy errors with protocol
|
|
397
|
+
get_snapshot = cast(Any, entry.manager).get_token_snapshot
|
|
398
|
+
snapshot = cast(TokenSnapshot | None, await get_snapshot())
|
|
399
|
+
results[entry.label] = snapshot
|
|
400
|
+
except Exception:
|
|
401
|
+
results[entry.label] = None
|
|
402
|
+
else:
|
|
403
|
+
results[entry.label] = None
|
|
404
|
+
return results
|
|
405
|
+
|
|
406
|
+
async def get_token_snapshot(self) -> TokenSnapshot | None:
|
|
407
|
+
"""Get token snapshot from selected credential entry.
|
|
408
|
+
|
|
409
|
+
Returns:
|
|
410
|
+
TokenSnapshot if available, None otherwise
|
|
411
|
+
"""
|
|
412
|
+
entry = await self._select_entry()
|
|
413
|
+
if hasattr(entry.manager, "get_token_snapshot"):
|
|
414
|
+
try:
|
|
415
|
+
# Cast to avoid mypy errors with protocol
|
|
416
|
+
get_snapshot = cast(Any, entry.manager).get_token_snapshot
|
|
417
|
+
return cast(TokenSnapshot | None, await get_snapshot())
|
|
418
|
+
except Exception:
|
|
419
|
+
return None
|
|
420
|
+
return None
|
|
421
|
+
|
|
422
|
+
def should_refresh(
|
|
423
|
+
self, credentials: object, grace_seconds: float | None = None
|
|
424
|
+
) -> bool:
|
|
425
|
+
snapshots: list[TokenSnapshot] = []
|
|
426
|
+
if isinstance(credentials, dict):
|
|
427
|
+
for value in credentials.values():
|
|
428
|
+
if value is None:
|
|
429
|
+
return True
|
|
430
|
+
if isinstance(value, TokenSnapshot):
|
|
431
|
+
snapshots.append(value)
|
|
432
|
+
elif isinstance(credentials, TokenSnapshot):
|
|
433
|
+
snapshots = [credentials]
|
|
434
|
+
else:
|
|
435
|
+
return False
|
|
436
|
+
|
|
437
|
+
if not snapshots:
|
|
438
|
+
return False
|
|
439
|
+
|
|
440
|
+
threshold = (
|
|
441
|
+
SNAPSHOT_REFRESH_GRACE_SECONDS
|
|
442
|
+
if grace_seconds is None
|
|
443
|
+
else max(grace_seconds, 0.0)
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
now = datetime.now(UTC)
|
|
447
|
+
for snapshot in snapshots:
|
|
448
|
+
expires_at = snapshot.expires_at
|
|
449
|
+
if expires_at is None:
|
|
450
|
+
continue
|
|
451
|
+
if expires_at.tzinfo is None:
|
|
452
|
+
expires_at = expires_at.replace(tzinfo=UTC)
|
|
453
|
+
remaining = (expires_at - now).total_seconds()
|
|
454
|
+
if remaining <= threshold:
|
|
455
|
+
return True
|
|
456
|
+
|
|
457
|
+
return any(not snapshot.access_token for snapshot in snapshots)
|
|
458
|
+
|
|
459
|
+
async def handle_response_event(
|
|
460
|
+
self, request_id: str | None, status_code: int | None
|
|
461
|
+
) -> bool:
|
|
462
|
+
if not request_id:
|
|
463
|
+
return False
|
|
464
|
+
|
|
465
|
+
async with self._state_lock:
|
|
466
|
+
state = self._request_states.pop(request_id, None)
|
|
467
|
+
if state is None:
|
|
468
|
+
return False
|
|
469
|
+
|
|
470
|
+
entry = state.entry
|
|
471
|
+
if status_code is None:
|
|
472
|
+
self._logger.debug(
|
|
473
|
+
"credential_balancer_event_without_status",
|
|
474
|
+
credential=entry.label,
|
|
475
|
+
request_id=request_id,
|
|
476
|
+
)
|
|
477
|
+
return True
|
|
478
|
+
|
|
479
|
+
if status_code < 400:
|
|
480
|
+
entry.reset_failures()
|
|
481
|
+
return True
|
|
482
|
+
|
|
483
|
+
if status_code not in self._failure_codes:
|
|
484
|
+
return True
|
|
485
|
+
|
|
486
|
+
self._logger.warning(
|
|
487
|
+
"credential_balancer_failure_detected",
|
|
488
|
+
credential=entry.label,
|
|
489
|
+
request_id=request_id,
|
|
490
|
+
status_code=status_code,
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
entry.mark_failure()
|
|
494
|
+
await self._handle_entry_failure(entry)
|
|
495
|
+
return True
|
|
496
|
+
|
|
497
|
+
async def cleanup_expired_requests(self, max_age_seconds: float = 120.0) -> None:
|
|
498
|
+
cutoff = time.monotonic() - max_age_seconds
|
|
499
|
+
async with self._state_lock:
|
|
500
|
+
stale = [
|
|
501
|
+
key
|
|
502
|
+
for key, value in self._request_states.items()
|
|
503
|
+
if value.created_at < cutoff
|
|
504
|
+
]
|
|
505
|
+
for key in stale:
|
|
506
|
+
del self._request_states[key]
|
|
507
|
+
|
|
508
|
+
async def _register_request(self, entry: CredentialEntry) -> str:
|
|
509
|
+
request_id: str | None = None
|
|
510
|
+
context = RequestContext.get_current()
|
|
511
|
+
if context is not None:
|
|
512
|
+
request_id = getattr(context, "request_id", None)
|
|
513
|
+
if not request_id:
|
|
514
|
+
request_id = f"cred-{uuid.uuid4()}"
|
|
515
|
+
|
|
516
|
+
state = _RequestState(entry=entry)
|
|
517
|
+
async with self._state_lock:
|
|
518
|
+
self._request_states[request_id] = state
|
|
519
|
+
return request_id
|
|
520
|
+
|
|
521
|
+
async def _select_entry(self, *, require_active: bool = False) -> CredentialEntry:
|
|
522
|
+
"""Select an available credential entry based on strategy.
|
|
523
|
+
|
|
524
|
+
Args:
|
|
525
|
+
require_active: If True, start with the active entry (for failover)
|
|
526
|
+
|
|
527
|
+
Returns:
|
|
528
|
+
Selected CredentialEntry
|
|
529
|
+
|
|
530
|
+
Raises:
|
|
531
|
+
AuthenticationError: If no credentials available
|
|
532
|
+
"""
|
|
533
|
+
if not self._entries:
|
|
534
|
+
raise AuthenticationError("No credentials configured")
|
|
535
|
+
|
|
536
|
+
async with self._lock:
|
|
537
|
+
total = len(self._entries)
|
|
538
|
+
if require_active and self._strategy == RotationStrategy.FAILOVER:
|
|
539
|
+
indices = [self._active_index] + [
|
|
540
|
+
(self._active_index + offset) % total for offset in range(1, total)
|
|
541
|
+
]
|
|
542
|
+
elif self._strategy == RotationStrategy.ROUND_ROBIN:
|
|
543
|
+
start = self._next_index
|
|
544
|
+
self._next_index = (self._next_index + 1) % total
|
|
545
|
+
indices = [(start + offset) % total for offset in range(total)]
|
|
546
|
+
else:
|
|
547
|
+
start = self._active_index
|
|
548
|
+
indices = [(start + offset) % total for offset in range(total)]
|
|
549
|
+
|
|
550
|
+
now = time.monotonic()
|
|
551
|
+
last_error: Exception | None = None
|
|
552
|
+
for idx in indices:
|
|
553
|
+
entry = self._entries[idx]
|
|
554
|
+
if entry.is_disabled(now):
|
|
555
|
+
continue
|
|
556
|
+
|
|
557
|
+
# Check if entry is authenticated using composed manager
|
|
558
|
+
is_auth = await entry.is_authenticated()
|
|
559
|
+
if not is_auth:
|
|
560
|
+
entry.mark_failure()
|
|
561
|
+
last_error = AuthenticationError("Credential not authenticated")
|
|
562
|
+
continue
|
|
563
|
+
|
|
564
|
+
if self._strategy == RotationStrategy.FAILOVER:
|
|
565
|
+
async with self._lock:
|
|
566
|
+
self._active_index = idx
|
|
567
|
+
return entry
|
|
568
|
+
|
|
569
|
+
if last_error:
|
|
570
|
+
raise last_error
|
|
571
|
+
raise AuthenticationError("No credential is currently available")
|
|
572
|
+
|
|
573
|
+
async def _handle_entry_failure(self, entry: CredentialEntry) -> None:
|
|
574
|
+
if self._strategy != RotationStrategy.FAILOVER:
|
|
575
|
+
return
|
|
576
|
+
async with self._lock:
|
|
577
|
+
current = self._active_index
|
|
578
|
+
if self._entries[current] is entry:
|
|
579
|
+
self._active_index = (current + 1) % len(self._entries)
|
|
580
|
+
self._logger.info(
|
|
581
|
+
"credential_balancer_failover",
|
|
582
|
+
previous=entry.label,
|
|
583
|
+
next=self._entries[self._active_index].label,
|
|
584
|
+
)
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
__all__ = ["CredentialBalancerTokenManager", "CredentialEntry"]
|