ccproxy-api 0.1.6__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 +439 -212
- ccproxy/api/bootstrap.py +30 -0
- ccproxy/api/decorators.py +85 -0
- ccproxy/api/dependencies.py +145 -176
- ccproxy/api/format_validation.py +54 -0
- ccproxy/api/middleware/cors.py +6 -3
- ccproxy/api/middleware/errors.py +402 -530
- 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 +558 -0
- ccproxy/data/codex_headers_fallback.json +121 -0
- 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 +63 -107
- ccproxy/scheduler/registry.py +6 -32
- ccproxy/scheduler/tasks.py +346 -314
- 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 +95 -342
- ccproxy/utils/version_checker.py +279 -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.6.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 -1231
- 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 -269
- ccproxy/services/codex_detection_service.py +0 -263
- 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.6.dist-info/METADATA +0 -615
- ccproxy_api-0.1.6.dist-info/RECORD +0 -189
- ccproxy_api-0.1.6.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.6.dist-info → ccproxy_api-0.2.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"CopilotEmbeddingRequestAPI routes for GitHub Copilot plugin."
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Annotated, Any, Literal, cast
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, Body, Depends, Request
|
|
6
|
+
from fastapi.responses import JSONResponse, Response, StreamingResponse
|
|
7
|
+
|
|
8
|
+
from ccproxy.api.decorators import with_format_chain
|
|
9
|
+
from ccproxy.api.dependencies import (
|
|
10
|
+
get_plugin_adapter,
|
|
11
|
+
get_provider_config_dependency,
|
|
12
|
+
)
|
|
13
|
+
from ccproxy.core.constants import (
|
|
14
|
+
FORMAT_ANTHROPIC_MESSAGES,
|
|
15
|
+
FORMAT_OPENAI_CHAT,
|
|
16
|
+
FORMAT_OPENAI_RESPONSES,
|
|
17
|
+
UPSTREAM_ENDPOINT_COPILOT_INTERNAL_TOKEN,
|
|
18
|
+
UPSTREAM_ENDPOINT_COPILOT_INTERNAL_USER,
|
|
19
|
+
UPSTREAM_ENDPOINT_OPENAI_CHAT_COMPLETIONS,
|
|
20
|
+
UPSTREAM_ENDPOINT_OPENAI_EMBEDDINGS,
|
|
21
|
+
UPSTREAM_ENDPOINT_OPENAI_MODELS,
|
|
22
|
+
)
|
|
23
|
+
from ccproxy.core.logging import get_plugin_logger
|
|
24
|
+
from ccproxy.llms.models import anthropic as anthropic_models
|
|
25
|
+
from ccproxy.llms.models import openai as openai_models
|
|
26
|
+
from ccproxy.streaming import DeferredStreaming
|
|
27
|
+
|
|
28
|
+
from .config import CopilotProviderConfig
|
|
29
|
+
from .models import (
|
|
30
|
+
CopilotHealthResponse,
|
|
31
|
+
CopilotTokenStatus,
|
|
32
|
+
CopilotUserInternalResponse,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
if TYPE_CHECKING:
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
logger = get_plugin_logger()
|
|
40
|
+
|
|
41
|
+
CopilotAdapterDep = Annotated[Any, Depends(get_plugin_adapter("copilot"))]
|
|
42
|
+
CopilotConfigDep = Annotated[
|
|
43
|
+
CopilotProviderConfig,
|
|
44
|
+
Depends(get_provider_config_dependency("copilot", CopilotProviderConfig)),
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
APIResponse = Response | StreamingResponse | DeferredStreaming
|
|
48
|
+
OpenAIResponse = APIResponse | openai_models.ErrorResponse
|
|
49
|
+
|
|
50
|
+
# V1 API Router - OpenAI/Anthropic compatible endpoints
|
|
51
|
+
router_v1 = APIRouter()
|
|
52
|
+
|
|
53
|
+
# GitHub Copilot specific router - usage, token, health endpoints
|
|
54
|
+
router_github = APIRouter()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _cast_result(result: object) -> OpenAIResponse:
|
|
58
|
+
return cast(APIResponse, result)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
async def _handle_adapter_request(
|
|
62
|
+
request: Request,
|
|
63
|
+
adapter: Any,
|
|
64
|
+
) -> OpenAIResponse:
|
|
65
|
+
result = await adapter.handle_request(request)
|
|
66
|
+
return _cast_result(result)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _get_request_body(request: Request) -> Any:
|
|
70
|
+
"""Hidden dependency to get raw body."""
|
|
71
|
+
|
|
72
|
+
async def _inner() -> Any:
|
|
73
|
+
return await request.json()
|
|
74
|
+
|
|
75
|
+
return _inner
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@router_v1.post(
|
|
79
|
+
"/chat/completions",
|
|
80
|
+
response_model=openai_models.ChatCompletionResponse,
|
|
81
|
+
)
|
|
82
|
+
async def create_openai_chat_completion(
|
|
83
|
+
request: Request,
|
|
84
|
+
adapter: CopilotAdapterDep,
|
|
85
|
+
_: openai_models.ChatCompletionRequest = Body(..., include_in_schema=True),
|
|
86
|
+
body: dict[str, Any] = Depends(_get_request_body, use_cache=False),
|
|
87
|
+
) -> openai_models.ChatCompletionResponse | OpenAIResponse:
|
|
88
|
+
"""Create a chat completion using Copilot with OpenAI-compatible format."""
|
|
89
|
+
request.state.context.metadata["endpoint"] = (
|
|
90
|
+
UPSTREAM_ENDPOINT_OPENAI_CHAT_COMPLETIONS
|
|
91
|
+
)
|
|
92
|
+
return await _handle_adapter_request(request, adapter)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@router_v1.post(
|
|
96
|
+
"/messages",
|
|
97
|
+
response_model=anthropic_models.MessageResponse,
|
|
98
|
+
)
|
|
99
|
+
@with_format_chain(
|
|
100
|
+
[FORMAT_ANTHROPIC_MESSAGES, FORMAT_OPENAI_CHAT],
|
|
101
|
+
endpoint=UPSTREAM_ENDPOINT_OPENAI_CHAT_COMPLETIONS,
|
|
102
|
+
)
|
|
103
|
+
async def create_anthropic_message(
|
|
104
|
+
request: Request,
|
|
105
|
+
_: anthropic_models.CreateMessageRequest,
|
|
106
|
+
adapter: CopilotAdapterDep,
|
|
107
|
+
) -> anthropic_models.MessageResponse | OpenAIResponse:
|
|
108
|
+
return await _handle_adapter_request(request, adapter)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@with_format_chain(
|
|
112
|
+
[FORMAT_OPENAI_RESPONSES, FORMAT_OPENAI_CHAT],
|
|
113
|
+
endpoint=UPSTREAM_ENDPOINT_OPENAI_CHAT_COMPLETIONS,
|
|
114
|
+
)
|
|
115
|
+
@router_v1.post(
|
|
116
|
+
"/responses",
|
|
117
|
+
response_model=anthropic_models.MessageResponse,
|
|
118
|
+
)
|
|
119
|
+
async def create_responses_message(
|
|
120
|
+
request: Request,
|
|
121
|
+
_: openai_models.ResponseRequest,
|
|
122
|
+
adapter: CopilotAdapterDep,
|
|
123
|
+
) -> anthropic_models.MessageResponse | OpenAIResponse:
|
|
124
|
+
"""Create a message using Response API with OpenAI provider."""
|
|
125
|
+
# Ensure format chain is present in context even if decorator injection is bypassed
|
|
126
|
+
request.state.context.metadata["endpoint"] = (
|
|
127
|
+
UPSTREAM_ENDPOINT_OPENAI_CHAT_COMPLETIONS
|
|
128
|
+
)
|
|
129
|
+
# Explicitly set format_chain so BaseHTTPAdapter applies request conversion
|
|
130
|
+
try:
|
|
131
|
+
prev_chain = getattr(request.state.context, "format_chain", None)
|
|
132
|
+
new_chain = [FORMAT_OPENAI_RESPONSES, FORMAT_OPENAI_CHAT]
|
|
133
|
+
request.state.context.format_chain = new_chain
|
|
134
|
+
logger.debug(
|
|
135
|
+
"copilot_responses_route_enter",
|
|
136
|
+
prev_chain=prev_chain,
|
|
137
|
+
applied_chain=new_chain,
|
|
138
|
+
category="format",
|
|
139
|
+
)
|
|
140
|
+
# Peek at incoming body keys for debugging
|
|
141
|
+
try:
|
|
142
|
+
body_json = await request.json()
|
|
143
|
+
stream_flag = (
|
|
144
|
+
body_json.get("stream") if isinstance(body_json, dict) else None
|
|
145
|
+
)
|
|
146
|
+
logger.debug(
|
|
147
|
+
"copilot_responses_request_body_inspect",
|
|
148
|
+
keys=list(body_json.keys()) if isinstance(body_json, dict) else None,
|
|
149
|
+
stream=stream_flag,
|
|
150
|
+
category="format",
|
|
151
|
+
)
|
|
152
|
+
except Exception as exc: # best-effort logging only
|
|
153
|
+
logger.debug("copilot_responses_request_body_parse_failed", error=str(exc))
|
|
154
|
+
except Exception as exc: # defensive
|
|
155
|
+
logger.debug("copilot_responses_set_chain_failed", error=str(exc))
|
|
156
|
+
return await _handle_adapter_request(request, adapter)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@router_v1.post(
|
|
160
|
+
"/embeddings",
|
|
161
|
+
response_model=openai_models.EmbeddingResponse,
|
|
162
|
+
)
|
|
163
|
+
async def create_embeddings(
|
|
164
|
+
request: Request, _: openai_models.EmbeddingRequest, adapter: CopilotAdapterDep
|
|
165
|
+
) -> openai_models.EmbeddingResponse | OpenAIResponse:
|
|
166
|
+
request.state.context.metadata["endpoint"] = UPSTREAM_ENDPOINT_OPENAI_EMBEDDINGS
|
|
167
|
+
return await _handle_adapter_request(request, adapter)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@router_v1.get("/models", response_model=openai_models.ModelList)
|
|
171
|
+
async def list_models_v1(
|
|
172
|
+
request: Request,
|
|
173
|
+
adapter: CopilotAdapterDep,
|
|
174
|
+
config: CopilotConfigDep,
|
|
175
|
+
) -> OpenAIResponse:
|
|
176
|
+
"""List available Copilot models."""
|
|
177
|
+
# if config.models_endpoint:
|
|
178
|
+
# models = [card.model_dump(mode="json") for card in config.models_endpoint]
|
|
179
|
+
# return JSONResponse(content={"object": "list", "data": models})
|
|
180
|
+
|
|
181
|
+
# Forward request to upstream Copilot API when no override configured
|
|
182
|
+
request.state.context.metadata["endpoint"] = UPSTREAM_ENDPOINT_OPENAI_MODELS
|
|
183
|
+
return await _handle_adapter_request(request, adapter)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@router_github.get("/usage", response_model=CopilotUserInternalResponse)
|
|
187
|
+
async def get_usage_stats(adapter: CopilotAdapterDep, request: Request) -> Response:
|
|
188
|
+
"""Get Copilot usage statistics."""
|
|
189
|
+
request.state.context.metadata["endpoint"] = UPSTREAM_ENDPOINT_COPILOT_INTERNAL_USER
|
|
190
|
+
request.state.context.metadata["method"] = "get"
|
|
191
|
+
result = await adapter.handle_request_gh_api(request)
|
|
192
|
+
return cast(Response, result)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@router_github.get("/token", response_model=CopilotTokenStatus)
|
|
196
|
+
async def get_token_status(adapter: CopilotAdapterDep, request: Request) -> Response:
|
|
197
|
+
"""Get Copilot usage statistics."""
|
|
198
|
+
request.state.context.metadata["endpoint"] = (
|
|
199
|
+
UPSTREAM_ENDPOINT_COPILOT_INTERNAL_TOKEN
|
|
200
|
+
)
|
|
201
|
+
request.state.context.metadata["method"] = "get"
|
|
202
|
+
result = await adapter.handle_request_gh_api(request)
|
|
203
|
+
return cast(Response, result)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@router_github.get("/health", response_model=CopilotHealthResponse)
|
|
207
|
+
async def health_check(adapter: CopilotAdapterDep) -> JSONResponse:
|
|
208
|
+
"""Check Copilot plugin health."""
|
|
209
|
+
try:
|
|
210
|
+
logger.debug("performing_health_check")
|
|
211
|
+
|
|
212
|
+
# Check components
|
|
213
|
+
details: dict[str, Any] = {}
|
|
214
|
+
|
|
215
|
+
# Check OAuth provider
|
|
216
|
+
oauth_healthy = True
|
|
217
|
+
if adapter.oauth_provider:
|
|
218
|
+
try:
|
|
219
|
+
oauth_healthy = await adapter.oauth_provider.is_authenticated()
|
|
220
|
+
details["oauth"] = {
|
|
221
|
+
"authenticated": oauth_healthy,
|
|
222
|
+
"provider": "github_copilot",
|
|
223
|
+
}
|
|
224
|
+
except Exception as e:
|
|
225
|
+
oauth_healthy = False
|
|
226
|
+
details["oauth"] = {
|
|
227
|
+
"authenticated": False,
|
|
228
|
+
"error": str(e),
|
|
229
|
+
}
|
|
230
|
+
else:
|
|
231
|
+
oauth_healthy = False
|
|
232
|
+
details["oauth"] = {"error": "OAuth provider not initialized"}
|
|
233
|
+
|
|
234
|
+
# Check detection service
|
|
235
|
+
detection_healthy = True
|
|
236
|
+
if adapter.detection_service:
|
|
237
|
+
try:
|
|
238
|
+
cli_info = adapter.detection_service.get_cli_health_info()
|
|
239
|
+
details["github_cli"] = {
|
|
240
|
+
"available": cli_info.available,
|
|
241
|
+
"version": cli_info.version,
|
|
242
|
+
"authenticated": cli_info.authenticated,
|
|
243
|
+
"username": cli_info.username,
|
|
244
|
+
"error": cli_info.error,
|
|
245
|
+
}
|
|
246
|
+
detection_healthy = cli_info.available and cli_info.authenticated
|
|
247
|
+
except Exception as e:
|
|
248
|
+
detection_healthy = False
|
|
249
|
+
details["github_cli"] = {"error": str(e)}
|
|
250
|
+
else:
|
|
251
|
+
details["github_cli"] = {"error": "Detection service not initialized"}
|
|
252
|
+
|
|
253
|
+
# Overall health
|
|
254
|
+
overall_status: Literal["healthy", "unhealthy"] = (
|
|
255
|
+
"healthy" if oauth_healthy and detection_healthy else "unhealthy"
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
health_response = CopilotHealthResponse(
|
|
259
|
+
status=overall_status,
|
|
260
|
+
provider="copilot",
|
|
261
|
+
details=details,
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
status_code = 200 if overall_status == "healthy" else 503
|
|
265
|
+
|
|
266
|
+
logger.info(
|
|
267
|
+
"health_check_completed",
|
|
268
|
+
status=overall_status,
|
|
269
|
+
oauth_healthy=oauth_healthy,
|
|
270
|
+
detection_healthy=detection_healthy,
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
return JSONResponse(
|
|
274
|
+
content=health_response.model_dump(),
|
|
275
|
+
status_code=status_code,
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
except Exception as e:
|
|
279
|
+
logger.error(
|
|
280
|
+
"health_check_failed",
|
|
281
|
+
error=str(e),
|
|
282
|
+
exc_info=e,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
health_response = CopilotHealthResponse(
|
|
286
|
+
status="unhealthy",
|
|
287
|
+
provider="copilot",
|
|
288
|
+
details={"error": str(e)},
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
return JSONResponse(
|
|
292
|
+
content=health_response.model_dump(),
|
|
293
|
+
status_code=503,
|
|
294
|
+
)
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# Credential Balancer (system plugin)
|
|
2
|
+
|
|
3
|
+
The credential balancer manages pools of upstream credentials (API keys, OAuth tokens, etc.) for a given provider and rotates between them based on health. It integrates as a system plugin and exposes a registry key (auth manager) that provider plugins can use to fetch a currently healthy credential at request time.
|
|
4
|
+
|
|
5
|
+
- Balances across multiple credential files per provider.
|
|
6
|
+
- Detects failures from HTTP responses and temporarily disables bad credentials with cooldowns.
|
|
7
|
+
- Supports manual refresh, proportional selection, sticky-on-success, and backoff.
|
|
8
|
+
- Exposes a named auth manager registry key (defaults to `<provider>_credential_balancer`).
|
|
9
|
+
|
|
10
|
+
## When to use
|
|
11
|
+
|
|
12
|
+
Use the balancer when you have multiple tokens for the same provider and want resilient failover and automatic rotation without changing application code or secrets storage.
|
|
13
|
+
|
|
14
|
+
## Quick Start (minimal)
|
|
15
|
+
|
|
16
|
+
The following minimal example configures CCproxy with the Codex provider to use a pool of Codex OAuth tokens.
|
|
17
|
+
|
|
18
|
+
```toml
|
|
19
|
+
[plugins]
|
|
20
|
+
# Enable the credential balancer system plugin
|
|
21
|
+
enabled_plugins = [
|
|
22
|
+
"codex",
|
|
23
|
+
"oauth_codex",
|
|
24
|
+
"credential_balancer"
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
# Point the codex provider at the balancer-managed auth manager
|
|
28
|
+
[plugins.codex]
|
|
29
|
+
auth_manager = "codex_credential_balancer"
|
|
30
|
+
|
|
31
|
+
[[plugins.credential_balancer.providers]]
|
|
32
|
+
provider = "codex"
|
|
33
|
+
strategy = "round_robin" # or "failover"
|
|
34
|
+
|
|
35
|
+
manager_class = "ccproxy.plugins.oauth_codex.manager.CodexTokenManager"
|
|
36
|
+
storage_class = "ccproxy.plugins.oauth_codex.storage.CodexTokenStorage"
|
|
37
|
+
|
|
38
|
+
credentials = [
|
|
39
|
+
{ path = "~/.config/ccproxy/codex_plus.json" },
|
|
40
|
+
{ path = "~/.config/ccproxy/codex_pro.json" },
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
## Full Configuration Reference
|
|
45
|
+
|
|
46
|
+
Enable the system plugin and define one or more provider pools. Each pool declares where to read credentials from and optional tuning parameters. See `config.example.toml` for full, commented examples.
|
|
47
|
+
|
|
48
|
+
```toml
|
|
49
|
+
[[plugins.credential_balancer.providers]]
|
|
50
|
+
# Provider identifier, e.g. "claude-api", "openai", "codex".
|
|
51
|
+
provider = "claude-api"
|
|
52
|
+
strategy = "round_robin" # or "failover"
|
|
53
|
+
max_failures_before_disable = 2
|
|
54
|
+
cooldown_seconds = 120.0
|
|
55
|
+
failure_status_codes = [401, 403]
|
|
56
|
+
|
|
57
|
+
# Pool defaults (example: Claude OAuth manager/storage)
|
|
58
|
+
manager_class = "ccproxy.plugins.oauth_claude.manager.ClaudeApiTokenManager"
|
|
59
|
+
storage_class = "ccproxy.plugins.oauth_claude.storage.ClaudeOAuthStorage"
|
|
60
|
+
|
|
61
|
+
credentials = [
|
|
62
|
+
{ type = "manager", file = "~/.config/ccproxy/claude_primary.json", label = "primary" },
|
|
63
|
+
{ type = "manager", file = "~/.config/ccproxy/claude_backup.json", label = "backup" },
|
|
64
|
+
]
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
After defining a pool, point the corresponding provider plugin at the balancer by overriding its auth manager to the registry key:
|
|
68
|
+
|
|
69
|
+
```toml
|
|
70
|
+
[plugins.claude-api]
|
|
71
|
+
# Use the balancer-provided registry entry instead of a static key file
|
|
72
|
+
auth_manager = "claude-api_credential_balancer"
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
If you set a custom `manager_name` in the balancer configuration, use that value for `auth_manager` instead.
|
|
76
|
+
|
|
77
|
+
## How it works
|
|
78
|
+
|
|
79
|
+
- Startup: for each entry in `[[plugins.credential_balancer.providers]]`, the plugin constructs a Manager that loads credentials from the declared files and registers it under `manager_name`.
|
|
80
|
+
- Request path: provider adapters ask the registry for a credential via the `auth_manager` key; the balancer selects a currently healthy token.
|
|
81
|
+
- Feedback loop: the `credential_balancer` hook observes provider HTTP responses and records failures/successes to update health, handle cooldowns, and trigger failover when necessary.
|
|
82
|
+
|
|
83
|
+
## TODO
|
|
84
|
+
|
|
85
|
+
- Extract cooldown period from provider error responses and apply dynamic per-credential cooldowns.
|
|
86
|
+
- Collect and parse HTTP error payloads/headers in the hook (e.g., Retry-After or equivalent fields).
|
|
87
|
+
- Pass an optional cooldown override with the failure event to the manager.
|
|
88
|
+
- Ensure logs include the derived cooldown value for observability.
|
|
89
|
+
|
|
90
|
+
## Logs and observability
|
|
91
|
+
|
|
92
|
+
The plugin emits structured events to aid troubleshooting, including (non-exhaustive):
|
|
93
|
+
- `credential_balancer_manager_registered`
|
|
94
|
+
- `credential_balancer_token_selected`
|
|
95
|
+
- `credential_balancer_failure_detected`
|
|
96
|
+
- `credential_balancer_failover`
|
|
97
|
+
- `credential_balancer_manual_refresh_succeeded`
|
|
98
|
+
|
|
99
|
+
During development, server logs stream to `/tmp/ccproxy/ccproxy.log` when running `ccproxy serve`.
|
|
100
|
+
|
|
101
|
+
## Files and APIs
|
|
102
|
+
|
|
103
|
+
- Runtime code: `ccproxy/plugins/credential_balancer/`
|
|
104
|
+
- `plugin.py`: plugin factory and lifecycle wiring
|
|
105
|
+
- `manager.py`: rotation, health, selection, and feedback processing
|
|
106
|
+
- `hook.py`: HTTP lifecycle hook that feeds response outcomes back to the manager
|
|
107
|
+
- `config.py`: Pydantic models for pool configuration and defaults
|
|
108
|
+
- Enable via `pyproject.toml` entry point `credential_balancer` (already wired).
|
|
109
|
+
|
|
110
|
+
## Testing
|
|
111
|
+
|
|
112
|
+
- Unit tests: `tests/plugins/credential_balancer/unit/`
|
|
113
|
+
- Run fast tests: `./Taskfile test-unit`
|
|
114
|
+
- Full suite: `./Taskfile test`
|
|
115
|
+
|
|
116
|
+
Follow the project’s testing markers and async patterns as described in `TESTING.md`.
|
|
117
|
+
|
|
118
|
+
## Further reading
|
|
119
|
+
|
|
120
|
+
- Authentication overview: `docs/user-guide/authentication.md`
|
|
121
|
+
- Example configuration: `config.example.toml`
|
|
122
|
+
|
|
123
|
+
Commands
|
|
124
|
+
- `uv run ccproxy serve` (logs at `/tmp/ccproxy/ccproxy.log`)
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""Configuration models for the credential balancer plugin."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Literal
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, Field, ValidationInfo, field_validator, model_validator
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RotationStrategy(str, Enum):
|
|
14
|
+
"""Supported credential selection strategies."""
|
|
15
|
+
|
|
16
|
+
ROUND_ROBIN = "round_robin"
|
|
17
|
+
FAILOVER = "failover"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CredentialSource(BaseModel):
|
|
21
|
+
"""Base model for credential sources."""
|
|
22
|
+
|
|
23
|
+
type: Literal["manager"] = Field(
|
|
24
|
+
default="manager", description="Type of credential source"
|
|
25
|
+
)
|
|
26
|
+
label: str | None = Field(
|
|
27
|
+
default=None,
|
|
28
|
+
description="Optional friendly name used for logging and metrics",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def resolved_label(self) -> str:
|
|
33
|
+
"""Return a non-empty label for this credential source."""
|
|
34
|
+
return self.label or "unlabeled"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class CredentialManager(CredentialSource):
|
|
38
|
+
"""Configuration for a manager-based credential source with provider-specific logic.
|
|
39
|
+
|
|
40
|
+
Specify either manager_key (registry lookup) or manager_class (direct import).
|
|
41
|
+
|
|
42
|
+
The config dict supports additional options:
|
|
43
|
+
|
|
44
|
+
**Storage options:**
|
|
45
|
+
- `enable_backups` (bool): Create timestamped backups before overwriting credentials (default: True)
|
|
46
|
+
|
|
47
|
+
**Manager options:**
|
|
48
|
+
- `credentials_ttl` (float): Seconds to cache credentials before rechecking storage (default: 30.0)
|
|
49
|
+
- `refresh_grace_seconds` (float): Seconds before expiry to trigger proactive token refresh (default: 120.0)
|
|
50
|
+
|
|
51
|
+
Example:
|
|
52
|
+
```toml
|
|
53
|
+
{ type = "manager",
|
|
54
|
+
file = "~/.config/ccproxy/codex_pro.json",
|
|
55
|
+
config = {
|
|
56
|
+
enable_backups = true,
|
|
57
|
+
credentials_ttl = 60.0,
|
|
58
|
+
refresh_grace_seconds = 300.0
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
type: Literal["manager"] = "manager"
|
|
65
|
+
file: Path | None = Field(
|
|
66
|
+
default=None,
|
|
67
|
+
description="Path to custom credential file (overrides default storage location)",
|
|
68
|
+
)
|
|
69
|
+
manager_key: str | None = Field(
|
|
70
|
+
default=None,
|
|
71
|
+
description="Auth manager registry key (e.g., 'codex', 'claude-api'). Mutually exclusive with manager_class.",
|
|
72
|
+
)
|
|
73
|
+
manager_class: str | None = Field(
|
|
74
|
+
default=None,
|
|
75
|
+
description="Fully qualified manager class name (e.g., 'ccproxy.plugins.oauth_codex.manager.CodexTokenManager'). Mutually exclusive with manager_key.",
|
|
76
|
+
)
|
|
77
|
+
storage_class: str | None = Field(
|
|
78
|
+
default=None,
|
|
79
|
+
description="Fully qualified storage class name (e.g., 'ccproxy.plugins.oauth_codex.storage.CodexTokenStorage'). Required when using manager_class with custom file.",
|
|
80
|
+
)
|
|
81
|
+
config: dict[str, Any] = Field(
|
|
82
|
+
default_factory=dict,
|
|
83
|
+
description="Additional manager and storage configuration options (see class docstring for supported keys)",
|
|
84
|
+
)
|
|
85
|
+
label: str | None = Field(
|
|
86
|
+
default=None,
|
|
87
|
+
description="Optional friendly name used for logging and metrics",
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
@field_validator("file", mode="before")
|
|
91
|
+
@classmethod
|
|
92
|
+
def _expand_file_path(cls, value: Path | str | None) -> Path | None:
|
|
93
|
+
"""Expand environment variables and user home directory in file path."""
|
|
94
|
+
if value is None:
|
|
95
|
+
return None
|
|
96
|
+
raw_value = str(value)
|
|
97
|
+
expanded = os.path.expandvars(raw_value)
|
|
98
|
+
return Path(expanded).expanduser()
|
|
99
|
+
|
|
100
|
+
@model_validator(mode="after")
|
|
101
|
+
def _validate_manager_specification(self) -> CredentialManager:
|
|
102
|
+
# Allow both to be None - they may be inherited from pool-level defaults
|
|
103
|
+
# But if both are specified, that's an error
|
|
104
|
+
if self.manager_key and self.manager_class:
|
|
105
|
+
raise ValueError(
|
|
106
|
+
"manager_key and manager_class are mutually exclusive, specify only one"
|
|
107
|
+
)
|
|
108
|
+
# If using manager_class with custom file, storage_class is required
|
|
109
|
+
# (unless it will be inherited from pool defaults)
|
|
110
|
+
if self.manager_class and self.file and not self.storage_class:
|
|
111
|
+
raise ValueError(
|
|
112
|
+
"storage_class is required when using manager_class with custom file path"
|
|
113
|
+
)
|
|
114
|
+
return self
|
|
115
|
+
|
|
116
|
+
@model_validator(mode="after")
|
|
117
|
+
def _populate_default_label(self) -> CredentialManager:
|
|
118
|
+
if self.label is None:
|
|
119
|
+
if self.manager_key:
|
|
120
|
+
self.label = self.manager_key
|
|
121
|
+
elif self.manager_class:
|
|
122
|
+
# Extract class name from fully qualified path
|
|
123
|
+
self.label = self.manager_class.rsplit(".", 1)[-1]
|
|
124
|
+
else:
|
|
125
|
+
self.label = "unlabeled"
|
|
126
|
+
return self
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def resolved_label(self) -> str:
|
|
130
|
+
"""Return a non-empty label for this credential manager."""
|
|
131
|
+
if self.label:
|
|
132
|
+
return self.label
|
|
133
|
+
if self.manager_key:
|
|
134
|
+
return self.manager_key
|
|
135
|
+
if self.manager_class:
|
|
136
|
+
return self.manager_class.rsplit(".", 1)[-1]
|
|
137
|
+
return "unlabeled"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class CredentialPoolConfig(BaseModel):
|
|
141
|
+
"""Configuration for an individual credential pool."""
|
|
142
|
+
|
|
143
|
+
provider: str = Field(..., description="Internal provider identifier")
|
|
144
|
+
manager_name: str | None = Field(
|
|
145
|
+
default=None,
|
|
146
|
+
description="Registry key to expose this balancer (defaults to '<provider>_credential_balancer')",
|
|
147
|
+
)
|
|
148
|
+
strategy: RotationStrategy = Field(
|
|
149
|
+
default=RotationStrategy.FAILOVER,
|
|
150
|
+
description="How credentials are selected for new requests",
|
|
151
|
+
)
|
|
152
|
+
manager_class: str | None = Field(
|
|
153
|
+
default=None,
|
|
154
|
+
description="Default manager class for all credentials in this pool (can be overridden per credential)",
|
|
155
|
+
)
|
|
156
|
+
storage_class: str | None = Field(
|
|
157
|
+
default=None,
|
|
158
|
+
description="Default storage class for all credentials in this pool (can be overridden per credential)",
|
|
159
|
+
)
|
|
160
|
+
credentials: list[CredentialManager] = Field(
|
|
161
|
+
default_factory=list,
|
|
162
|
+
description="Ordered list of manager-based credential sources participating in the pool",
|
|
163
|
+
)
|
|
164
|
+
max_failures_before_disable: int = Field(
|
|
165
|
+
default=2,
|
|
166
|
+
ge=1,
|
|
167
|
+
description="Number of failed responses tolerated before disabling a credential",
|
|
168
|
+
)
|
|
169
|
+
cooldown_seconds: float = Field(
|
|
170
|
+
default=60.0,
|
|
171
|
+
ge=0.0,
|
|
172
|
+
description="Cooldown window before a failed credential becomes eligible again",
|
|
173
|
+
)
|
|
174
|
+
failure_status_codes: list[int] = Field(
|
|
175
|
+
default_factory=lambda: [401, 403],
|
|
176
|
+
description="HTTP status codes that indicate credential failure",
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
@field_validator("credentials")
|
|
180
|
+
@classmethod
|
|
181
|
+
def _ensure_credentials_present(
|
|
182
|
+
cls, value: list[CredentialManager], _info: ValidationInfo
|
|
183
|
+
) -> list[CredentialManager]:
|
|
184
|
+
if not value:
|
|
185
|
+
raise ValueError(
|
|
186
|
+
"credential pool must contain at least one credential file"
|
|
187
|
+
)
|
|
188
|
+
return value
|
|
189
|
+
|
|
190
|
+
@field_validator("failure_status_codes")
|
|
191
|
+
@classmethod
|
|
192
|
+
def _validate_status_codes(cls, codes: list[int]) -> list[int]:
|
|
193
|
+
normalised = sorted({code for code in codes if code >= 400})
|
|
194
|
+
if not normalised:
|
|
195
|
+
raise ValueError("at least one failure status code is required")
|
|
196
|
+
return normalised
|
|
197
|
+
|
|
198
|
+
@model_validator(mode="after")
|
|
199
|
+
def _apply_default_manager_name(self) -> CredentialPoolConfig:
|
|
200
|
+
if not self.manager_name:
|
|
201
|
+
self.manager_name = f"{self.provider}_credential_balancer"
|
|
202
|
+
return self
|
|
203
|
+
|
|
204
|
+
@model_validator(mode="after")
|
|
205
|
+
def _apply_pool_defaults_to_credentials(self) -> CredentialPoolConfig:
|
|
206
|
+
"""Apply pool-level manager_class and storage_class to credentials that don't specify them."""
|
|
207
|
+
if not self.manager_class and not self.storage_class:
|
|
208
|
+
# No pool-level defaults to apply
|
|
209
|
+
return self
|
|
210
|
+
|
|
211
|
+
for cred in self.credentials:
|
|
212
|
+
# Only apply to CredentialManager type
|
|
213
|
+
if isinstance(cred, CredentialManager):
|
|
214
|
+
# Apply pool-level manager_class if credential doesn't specify one
|
|
215
|
+
if (
|
|
216
|
+
self.manager_class
|
|
217
|
+
and not cred.manager_class
|
|
218
|
+
and not cred.manager_key
|
|
219
|
+
):
|
|
220
|
+
cred.manager_class = self.manager_class
|
|
221
|
+
|
|
222
|
+
# Apply pool-level storage_class if credential doesn't specify one
|
|
223
|
+
if self.storage_class and not cred.storage_class:
|
|
224
|
+
cred.storage_class = self.storage_class
|
|
225
|
+
|
|
226
|
+
return self
|
|
227
|
+
|
|
228
|
+
@model_validator(mode="after")
|
|
229
|
+
def _validate_credentials_after_defaults(self) -> CredentialPoolConfig:
|
|
230
|
+
"""Validate that all credentials have required manager information after applying defaults."""
|
|
231
|
+
for idx, cred in enumerate(self.credentials):
|
|
232
|
+
if isinstance(cred, CredentialManager):
|
|
233
|
+
# After applying defaults, each credential must have either manager_key or manager_class
|
|
234
|
+
if not cred.manager_key and not cred.manager_class:
|
|
235
|
+
raise ValueError(
|
|
236
|
+
f"Credential at index {idx} missing manager specification. "
|
|
237
|
+
f"Either set manager_key/manager_class on the credential, "
|
|
238
|
+
f"or set manager_class at pool level."
|
|
239
|
+
)
|
|
240
|
+
# If using manager_class with file, storage_class is required
|
|
241
|
+
if cred.manager_class and cred.file and not cred.storage_class:
|
|
242
|
+
raise ValueError(
|
|
243
|
+
f"Credential at index {idx} with manager_class and file path "
|
|
244
|
+
f"requires storage_class (either on credential or at pool level)"
|
|
245
|
+
)
|
|
246
|
+
return self
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
class CredentialBalancerSettings(BaseModel):
|
|
250
|
+
"""Top-level plugin settings."""
|
|
251
|
+
|
|
252
|
+
enabled: bool = Field(default=True, description="Enable credential balancer")
|
|
253
|
+
providers: list[CredentialPoolConfig] = Field(
|
|
254
|
+
default_factory=list, description="Pools managed by the balancer"
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
@field_validator("providers")
|
|
258
|
+
@classmethod
|
|
259
|
+
def _ensure_unique_manager_names(
|
|
260
|
+
cls, value: list[CredentialPoolConfig]
|
|
261
|
+
) -> list[CredentialPoolConfig]:
|
|
262
|
+
seen: set[str] = set()
|
|
263
|
+
for pool in value:
|
|
264
|
+
manager_name = pool.manager_name
|
|
265
|
+
if manager_name is None:
|
|
266
|
+
raise ValueError("manager name resolution failed")
|
|
267
|
+
if manager_name in seen:
|
|
268
|
+
raise ValueError(f"duplicate manager name detected: {manager_name}")
|
|
269
|
+
seen.add(manager_name)
|
|
270
|
+
return value
|