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,523 @@
|
|
|
1
|
+
"""Base token manager for all authentication providers."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from typing import Any, Generic, TypeVar
|
|
8
|
+
|
|
9
|
+
from pydantic import ValidationError
|
|
10
|
+
|
|
11
|
+
from ccproxy.auth.exceptions import (
|
|
12
|
+
CredentialsInvalidError,
|
|
13
|
+
CredentialsStorageError,
|
|
14
|
+
)
|
|
15
|
+
from ccproxy.auth.managers.token_snapshot import TokenSnapshot
|
|
16
|
+
from ccproxy.auth.models.credentials import BaseCredentials
|
|
17
|
+
from ccproxy.auth.storage.base import TokenStorage
|
|
18
|
+
from ccproxy.core.logging import get_logger
|
|
19
|
+
from ccproxy.utils.caching import AuthStatusCache, async_ttl_cache
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
logger = get_logger(__name__)
|
|
23
|
+
|
|
24
|
+
# Type variable for credentials
|
|
25
|
+
CredentialsT = TypeVar("CredentialsT", bound=BaseCredentials)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class BaseTokenManager(ABC, Generic[CredentialsT]):
|
|
29
|
+
"""Base manager for token storage and refresh operations.
|
|
30
|
+
|
|
31
|
+
This generic base class provides common functionality for managing
|
|
32
|
+
authentication tokens across different providers (OpenAI, Claude, etc.).
|
|
33
|
+
|
|
34
|
+
Type Parameters:
|
|
35
|
+
CredentialsT: The specific credential type (e.g., OpenAICredentials, ClaudeCredentials)
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
storage: TokenStorage[CredentialsT],
|
|
41
|
+
credentials_ttl: float | None = None,
|
|
42
|
+
refresh_grace_seconds: float | None = None,
|
|
43
|
+
):
|
|
44
|
+
"""Initialize token manager.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
storage: Token storage backend that matches the credential type
|
|
48
|
+
"""
|
|
49
|
+
self.storage = storage
|
|
50
|
+
self._auth_cache = AuthStatusCache(ttl=60.0) # 1 minute TTL for auth status
|
|
51
|
+
self._profile_cache: Any = None # For subclasses that cache profiles
|
|
52
|
+
# In-memory credentials cache to reduce file checks
|
|
53
|
+
self._credentials_cache: CredentialsT | None = None
|
|
54
|
+
self._credentials_loaded_at: float | None = None
|
|
55
|
+
# TTL for rechecking credentials from storage (config-driven)
|
|
56
|
+
# Prefer explicit parameter; fallback to environment; then default.
|
|
57
|
+
if credentials_ttl is not None:
|
|
58
|
+
try:
|
|
59
|
+
ttl_val = float(credentials_ttl)
|
|
60
|
+
self._credentials_ttl = ttl_val if ttl_val >= 0 else 30.0
|
|
61
|
+
except Exception:
|
|
62
|
+
self._credentials_ttl = 30.0
|
|
63
|
+
else:
|
|
64
|
+
env_val = os.getenv("AUTH__CREDENTIALS_TTL_SECONDS")
|
|
65
|
+
try:
|
|
66
|
+
self._credentials_ttl = float(env_val) if env_val is not None else 30.0
|
|
67
|
+
if self._credentials_ttl < 0:
|
|
68
|
+
self._credentials_ttl = 30.0
|
|
69
|
+
except Exception:
|
|
70
|
+
self._credentials_ttl = 30.0
|
|
71
|
+
|
|
72
|
+
# Grace period before expiry to trigger proactive refresh
|
|
73
|
+
if refresh_grace_seconds is not None:
|
|
74
|
+
try:
|
|
75
|
+
grace_val = float(refresh_grace_seconds)
|
|
76
|
+
self._refresh_grace_seconds = grace_val if grace_val >= 0 else 0.0
|
|
77
|
+
except Exception:
|
|
78
|
+
self._refresh_grace_seconds = 120.0
|
|
79
|
+
else:
|
|
80
|
+
env_grace = os.getenv("AUTH__REFRESH_GRACE_SECONDS")
|
|
81
|
+
try:
|
|
82
|
+
grace_val = float(env_grace) if env_grace is not None else 120.0
|
|
83
|
+
if grace_val < 0:
|
|
84
|
+
grace_val = 0.0
|
|
85
|
+
self._refresh_grace_seconds = grace_val
|
|
86
|
+
except Exception:
|
|
87
|
+
self._refresh_grace_seconds = 120.0
|
|
88
|
+
|
|
89
|
+
# ==================== Core Operations ====================
|
|
90
|
+
|
|
91
|
+
async def load_credentials(self) -> CredentialsT | None:
|
|
92
|
+
"""Load credentials from storage.
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
Credentials if found and valid, None otherwise
|
|
96
|
+
"""
|
|
97
|
+
try:
|
|
98
|
+
# Serve from cache when fresh and not expired
|
|
99
|
+
if self._credentials_cache is not None and self._credentials_loaded_at:
|
|
100
|
+
from time import time as _now
|
|
101
|
+
|
|
102
|
+
age = _now() - self._credentials_loaded_at
|
|
103
|
+
if age < self._credentials_ttl and not self.is_expired(
|
|
104
|
+
self._credentials_cache
|
|
105
|
+
):
|
|
106
|
+
logger.debug(
|
|
107
|
+
"credentials_cache_hit",
|
|
108
|
+
age_seconds=round(age, 2),
|
|
109
|
+
ttl_seconds=self._credentials_ttl,
|
|
110
|
+
)
|
|
111
|
+
return self._credentials_cache
|
|
112
|
+
|
|
113
|
+
# Otherwise, reload from storage (also triggers on expired or stale cache)
|
|
114
|
+
creds = await self.storage.load()
|
|
115
|
+
# Update cache regardless of result (None clears cache)
|
|
116
|
+
self._credentials_cache = creds
|
|
117
|
+
from time import time as _now
|
|
118
|
+
|
|
119
|
+
self._credentials_loaded_at = _now()
|
|
120
|
+
logger.debug(
|
|
121
|
+
"credentials_cache_refreshed",
|
|
122
|
+
has_credentials=bool(creds),
|
|
123
|
+
ttl_seconds=self._credentials_ttl,
|
|
124
|
+
)
|
|
125
|
+
return creds
|
|
126
|
+
except (OSError, PermissionError) as e:
|
|
127
|
+
logger.error("storage_access_failed", error=str(e), exc_info=e)
|
|
128
|
+
return None
|
|
129
|
+
except (CredentialsStorageError, CredentialsInvalidError) as e:
|
|
130
|
+
logger.error("credentials_load_failed", error=str(e), exc_info=e)
|
|
131
|
+
return None
|
|
132
|
+
except json.JSONDecodeError as e:
|
|
133
|
+
logger.error("credentials_json_decode_error", error=str(e), exc_info=e)
|
|
134
|
+
return None
|
|
135
|
+
except ValidationError as e:
|
|
136
|
+
logger.error("credentials_validation_error", error=str(e), exc_info=e)
|
|
137
|
+
return None
|
|
138
|
+
except Exception as e:
|
|
139
|
+
logger.error("unexpected_load_error", error=str(e), exc_info=e)
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
async def save_credentials(self, credentials: CredentialsT) -> bool:
|
|
143
|
+
"""Save credentials to storage.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
credentials: Credentials to save
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
True if saved successfully, False otherwise
|
|
150
|
+
"""
|
|
151
|
+
try:
|
|
152
|
+
ok = await self.storage.save(credentials)
|
|
153
|
+
if ok:
|
|
154
|
+
# Update cache immediately
|
|
155
|
+
self._credentials_cache = credentials
|
|
156
|
+
from time import time as _now
|
|
157
|
+
|
|
158
|
+
self._credentials_loaded_at = _now()
|
|
159
|
+
return ok
|
|
160
|
+
except (OSError, PermissionError) as e:
|
|
161
|
+
logger.error("storage_access_failed", error=str(e), exc_info=e)
|
|
162
|
+
return False
|
|
163
|
+
except CredentialsStorageError as e:
|
|
164
|
+
logger.error("credentials_save_failed", error=str(e), exc_info=e)
|
|
165
|
+
return False
|
|
166
|
+
except json.JSONDecodeError as e:
|
|
167
|
+
logger.error("credentials_json_encode_error", error=str(e), exc_info=e)
|
|
168
|
+
return False
|
|
169
|
+
except ValidationError as e:
|
|
170
|
+
logger.error("credentials_validation_error", error=str(e), exc_info=e)
|
|
171
|
+
return False
|
|
172
|
+
except Exception as e:
|
|
173
|
+
logger.error("unexpected_save_error", error=str(e), exc_info=e)
|
|
174
|
+
return False
|
|
175
|
+
|
|
176
|
+
async def clear_credentials(self) -> bool:
|
|
177
|
+
"""Clear stored credentials.
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
True if cleared successfully, False otherwise
|
|
181
|
+
"""
|
|
182
|
+
try:
|
|
183
|
+
# Clear the caches
|
|
184
|
+
self._auth_cache.clear()
|
|
185
|
+
self._credentials_cache = None
|
|
186
|
+
self._credentials_loaded_at = None
|
|
187
|
+
|
|
188
|
+
# Delete from storage
|
|
189
|
+
return await self.storage.delete()
|
|
190
|
+
except Exception as e:
|
|
191
|
+
logger.error("failed_to_clear_credentials", error=str(e), exc_info=e)
|
|
192
|
+
return False
|
|
193
|
+
|
|
194
|
+
def get_storage_location(self) -> str:
|
|
195
|
+
"""Get the storage location for credentials.
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
Storage location description
|
|
199
|
+
"""
|
|
200
|
+
return self.storage.get_location()
|
|
201
|
+
|
|
202
|
+
@abstractmethod
|
|
203
|
+
def _build_token_snapshot(self, credentials: CredentialsT) -> TokenSnapshot:
|
|
204
|
+
"""Construct a token snapshot for the given credentials."""
|
|
205
|
+
|
|
206
|
+
def _safe_token_snapshot(self, credentials: CredentialsT) -> TokenSnapshot | None:
|
|
207
|
+
"""Safely build a token snapshot with defensive logging."""
|
|
208
|
+
try:
|
|
209
|
+
return self._build_token_snapshot(credentials)
|
|
210
|
+
except NotImplementedError:
|
|
211
|
+
raise
|
|
212
|
+
except Exception as exc: # pragma: no cover - defensive logging
|
|
213
|
+
logger.debug(
|
|
214
|
+
"token_snapshot_failed",
|
|
215
|
+
error=str(exc),
|
|
216
|
+
credentials_type=type(credentials).__name__,
|
|
217
|
+
category="auth",
|
|
218
|
+
)
|
|
219
|
+
return None
|
|
220
|
+
|
|
221
|
+
async def get_token_snapshot(self) -> TokenSnapshot | None:
|
|
222
|
+
"""Return a lightweight snapshot of stored token metadata."""
|
|
223
|
+
credentials = await self.load_credentials()
|
|
224
|
+
if not credentials:
|
|
225
|
+
return None
|
|
226
|
+
try:
|
|
227
|
+
return self._build_token_snapshot(credentials)
|
|
228
|
+
except NotImplementedError:
|
|
229
|
+
raise
|
|
230
|
+
except Exception as exc: # pragma: no cover - defensive logging
|
|
231
|
+
logger.debug(
|
|
232
|
+
"token_snapshot_failed",
|
|
233
|
+
error=str(exc),
|
|
234
|
+
credentials_type=type(credentials).__name__,
|
|
235
|
+
category="auth",
|
|
236
|
+
)
|
|
237
|
+
return None
|
|
238
|
+
|
|
239
|
+
# ==================== Common Implementations ====================
|
|
240
|
+
|
|
241
|
+
@property
|
|
242
|
+
def refresh_grace_seconds(self) -> float:
|
|
243
|
+
"""Seconds before expiry when tokens should be proactively refreshed."""
|
|
244
|
+
|
|
245
|
+
return self._refresh_grace_seconds
|
|
246
|
+
|
|
247
|
+
def seconds_until_expiration(self, credentials: CredentialsT) -> float | None:
|
|
248
|
+
"""Return seconds until the access token expires, if available."""
|
|
249
|
+
|
|
250
|
+
expires_at = self.get_expiration_time(credentials)
|
|
251
|
+
if not isinstance(expires_at, datetime):
|
|
252
|
+
return None
|
|
253
|
+
|
|
254
|
+
# Normalise naive datetimes to UTC to avoid comparison issues
|
|
255
|
+
if expires_at.tzinfo is None:
|
|
256
|
+
expires_at = expires_at.replace(tzinfo=UTC)
|
|
257
|
+
|
|
258
|
+
delta = expires_at - datetime.now(UTC)
|
|
259
|
+
return delta.total_seconds()
|
|
260
|
+
|
|
261
|
+
def should_refresh(
|
|
262
|
+
self, credentials: CredentialsT, grace_seconds: float | None = None
|
|
263
|
+
) -> bool:
|
|
264
|
+
"""Determine whether credentials should be refreshed."""
|
|
265
|
+
|
|
266
|
+
seconds_remaining = self.seconds_until_expiration(credentials)
|
|
267
|
+
if seconds_remaining is None:
|
|
268
|
+
return False
|
|
269
|
+
|
|
270
|
+
grace = (
|
|
271
|
+
self.refresh_grace_seconds
|
|
272
|
+
if grace_seconds is None
|
|
273
|
+
else max(grace_seconds, 0.0)
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
return seconds_remaining <= grace
|
|
277
|
+
|
|
278
|
+
async def validate_token(self) -> bool:
|
|
279
|
+
"""Check if stored token is valid and not expired.
|
|
280
|
+
|
|
281
|
+
Returns:
|
|
282
|
+
True if valid, False otherwise
|
|
283
|
+
"""
|
|
284
|
+
credentials = await self.load_credentials()
|
|
285
|
+
if not credentials:
|
|
286
|
+
return False
|
|
287
|
+
|
|
288
|
+
if self.is_expired(credentials):
|
|
289
|
+
logger.info("token_expired")
|
|
290
|
+
return False
|
|
291
|
+
|
|
292
|
+
return True
|
|
293
|
+
|
|
294
|
+
# Subclasses should implement protocol methods
|
|
295
|
+
|
|
296
|
+
@abstractmethod
|
|
297
|
+
async def refresh_token(self) -> CredentialsT | None:
|
|
298
|
+
"""Refresh the access token using the refresh token.
|
|
299
|
+
|
|
300
|
+
Returns:
|
|
301
|
+
Updated credentials or None if refresh failed
|
|
302
|
+
"""
|
|
303
|
+
pass
|
|
304
|
+
|
|
305
|
+
async def get_auth_status(self) -> dict[str, Any]:
|
|
306
|
+
"""Get current authentication status.
|
|
307
|
+
|
|
308
|
+
Returns:
|
|
309
|
+
Dictionary with authentication status information
|
|
310
|
+
"""
|
|
311
|
+
credentials = await self.load_credentials()
|
|
312
|
+
|
|
313
|
+
if not credentials:
|
|
314
|
+
return {
|
|
315
|
+
"authenticated": False,
|
|
316
|
+
"reason": "No credentials found",
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if self.is_expired(credentials):
|
|
320
|
+
status = {
|
|
321
|
+
"authenticated": False,
|
|
322
|
+
"reason": "Token expired",
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
# Add expiration info if available
|
|
326
|
+
expires_at = self.get_expiration_time(credentials)
|
|
327
|
+
if expires_at:
|
|
328
|
+
status["expires_at"] = expires_at.isoformat()
|
|
329
|
+
|
|
330
|
+
# Add account ID if available
|
|
331
|
+
account_id = self.get_account_id(credentials)
|
|
332
|
+
if account_id:
|
|
333
|
+
status["account_id"] = account_id
|
|
334
|
+
|
|
335
|
+
return status
|
|
336
|
+
|
|
337
|
+
# Token is valid
|
|
338
|
+
status = {"authenticated": True}
|
|
339
|
+
|
|
340
|
+
# Add expiration info if available
|
|
341
|
+
expires_at = self.get_expiration_time(credentials)
|
|
342
|
+
if expires_at:
|
|
343
|
+
status["expires_at"] = expires_at.isoformat()
|
|
344
|
+
seconds_remaining = self.seconds_until_expiration(credentials)
|
|
345
|
+
if seconds_remaining is not None:
|
|
346
|
+
status["expires_in"] = max(0, int(seconds_remaining))
|
|
347
|
+
|
|
348
|
+
# Add account ID if available
|
|
349
|
+
account_id = self.get_account_id(credentials)
|
|
350
|
+
if account_id:
|
|
351
|
+
status["account_id"] = account_id
|
|
352
|
+
|
|
353
|
+
return status
|
|
354
|
+
|
|
355
|
+
@abstractmethod
|
|
356
|
+
def is_expired(self, credentials: CredentialsT) -> bool:
|
|
357
|
+
"""Check if credentials are expired.
|
|
358
|
+
|
|
359
|
+
Args:
|
|
360
|
+
credentials: Credentials to check
|
|
361
|
+
|
|
362
|
+
Returns:
|
|
363
|
+
True if expired, False otherwise
|
|
364
|
+
"""
|
|
365
|
+
pass
|
|
366
|
+
|
|
367
|
+
@abstractmethod
|
|
368
|
+
def get_account_id(self, credentials: CredentialsT) -> str | None:
|
|
369
|
+
"""Get account ID from credentials.
|
|
370
|
+
|
|
371
|
+
Args:
|
|
372
|
+
credentials: Credentials to extract account ID from
|
|
373
|
+
|
|
374
|
+
Returns:
|
|
375
|
+
Account ID if available, None otherwise
|
|
376
|
+
"""
|
|
377
|
+
pass
|
|
378
|
+
|
|
379
|
+
def get_expiration_time(self, credentials: CredentialsT) -> Any:
|
|
380
|
+
"""Get expiration time from credentials.
|
|
381
|
+
|
|
382
|
+
Args:
|
|
383
|
+
credentials: Credentials to extract expiration time from
|
|
384
|
+
|
|
385
|
+
Returns:
|
|
386
|
+
Expiration datetime if available, None otherwise
|
|
387
|
+
"""
|
|
388
|
+
snapshot = self._safe_token_snapshot(credentials)
|
|
389
|
+
if snapshot:
|
|
390
|
+
return snapshot.expires_at
|
|
391
|
+
return None
|
|
392
|
+
|
|
393
|
+
# ==================== Unified Profile Support ====================
|
|
394
|
+
|
|
395
|
+
async def get_profile(self) -> Any:
|
|
396
|
+
"""Get profile information.
|
|
397
|
+
|
|
398
|
+
To be implemented by provider-specific managers.
|
|
399
|
+
Returns provider-specific profile model.
|
|
400
|
+
"""
|
|
401
|
+
return None
|
|
402
|
+
|
|
403
|
+
async def get_profile_quick(self) -> Any:
|
|
404
|
+
"""Get profile information without performing I/O or network when possible.
|
|
405
|
+
|
|
406
|
+
Default behavior returns any cached profile stored on the manager.
|
|
407
|
+
Provider implementations may override to derive lightweight profiles
|
|
408
|
+
directly from credentials (e.g., JWT claims) without remote calls.
|
|
409
|
+
|
|
410
|
+
Returns:
|
|
411
|
+
Provider-specific profile model or None if unavailable
|
|
412
|
+
"""
|
|
413
|
+
# Return cached profile if a subclass maintains one
|
|
414
|
+
return getattr(self, "_profile_cache", None)
|
|
415
|
+
|
|
416
|
+
async def get_unified_profile(self) -> dict[str, Any]:
|
|
417
|
+
"""Get profile in a unified format across all providers.
|
|
418
|
+
|
|
419
|
+
Returns:
|
|
420
|
+
Dictionary with standardized fields plus provider-specific extras
|
|
421
|
+
"""
|
|
422
|
+
profile = await self.get_profile()
|
|
423
|
+
if not profile:
|
|
424
|
+
return {}
|
|
425
|
+
|
|
426
|
+
extras = getattr(profile, "extras", None)
|
|
427
|
+
if extras is None:
|
|
428
|
+
extras = getattr(profile, "features", {}) or {}
|
|
429
|
+
|
|
430
|
+
return {
|
|
431
|
+
"account_id": profile.account_id,
|
|
432
|
+
"email": profile.email,
|
|
433
|
+
"display_name": profile.display_name,
|
|
434
|
+
"provider": profile.provider_type,
|
|
435
|
+
"extras": extras,
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
async def get_unified_profile_quick(self) -> dict[str, Any]:
|
|
439
|
+
"""Get a lightweight unified profile across providers.
|
|
440
|
+
|
|
441
|
+
Uses cached or locally derivable data only. Implementations can
|
|
442
|
+
override get_profile_quick() to provide provider-specific logic.
|
|
443
|
+
|
|
444
|
+
Returns:
|
|
445
|
+
Dictionary with standardized fields or empty dict if unavailable
|
|
446
|
+
"""
|
|
447
|
+
profile = await self.get_profile_quick()
|
|
448
|
+
if not profile:
|
|
449
|
+
return {}
|
|
450
|
+
|
|
451
|
+
extras = getattr(profile, "extras", None)
|
|
452
|
+
if extras is None:
|
|
453
|
+
extras = getattr(profile, "features", {}) or {}
|
|
454
|
+
|
|
455
|
+
return {
|
|
456
|
+
"account_id": getattr(profile, "account_id", ""),
|
|
457
|
+
"email": getattr(profile, "email", ""),
|
|
458
|
+
"display_name": getattr(profile, "display_name", None),
|
|
459
|
+
"provider": getattr(profile, "provider_type", "unknown"),
|
|
460
|
+
"extras": extras,
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
async def clear_cache(self) -> None:
|
|
464
|
+
"""Clear any cached data (profiles, etc.).
|
|
465
|
+
|
|
466
|
+
Should be called after token refresh or logout.
|
|
467
|
+
"""
|
|
468
|
+
# Clear auth status cache
|
|
469
|
+
if hasattr(self, "_auth_cache"):
|
|
470
|
+
self._auth_cache.clear()
|
|
471
|
+
|
|
472
|
+
# Clear profile cache if exists
|
|
473
|
+
if hasattr(self, "_profile_cache"):
|
|
474
|
+
self._profile_cache = None
|
|
475
|
+
|
|
476
|
+
# Clear credentials cache so next access rechecks storage
|
|
477
|
+
self._credentials_cache = None
|
|
478
|
+
self._credentials_loaded_at = None
|
|
479
|
+
|
|
480
|
+
# ==================== Common Utility Methods ====================
|
|
481
|
+
|
|
482
|
+
async def is_authenticated(self) -> bool:
|
|
483
|
+
"""Check if current authentication is valid.
|
|
484
|
+
|
|
485
|
+
Returns:
|
|
486
|
+
True if authenticated, False otherwise
|
|
487
|
+
"""
|
|
488
|
+
credentials = await self.load_credentials()
|
|
489
|
+
if not credentials:
|
|
490
|
+
return False
|
|
491
|
+
|
|
492
|
+
return not self.is_expired(credentials)
|
|
493
|
+
|
|
494
|
+
async def get_access_token(self) -> str | None:
|
|
495
|
+
"""Get valid access token from credentials.
|
|
496
|
+
|
|
497
|
+
Returns:
|
|
498
|
+
Access token if available and valid, None otherwise
|
|
499
|
+
"""
|
|
500
|
+
credentials = await self.load_credentials()
|
|
501
|
+
if not credentials:
|
|
502
|
+
return None
|
|
503
|
+
|
|
504
|
+
if self.is_expired(credentials):
|
|
505
|
+
logger.info("token_expired")
|
|
506
|
+
return None
|
|
507
|
+
|
|
508
|
+
snapshot = self._safe_token_snapshot(credentials)
|
|
509
|
+
if snapshot and snapshot.access_token:
|
|
510
|
+
return snapshot.access_token
|
|
511
|
+
|
|
512
|
+
return None
|
|
513
|
+
|
|
514
|
+
@async_ttl_cache(ttl=60.0) # Cache auth status for 1 minute
|
|
515
|
+
async def get_cached_auth_status(self) -> dict[str, Any]:
|
|
516
|
+
"""Get current authentication status with caching.
|
|
517
|
+
|
|
518
|
+
This is a convenience method that wraps get_auth_status() with caching.
|
|
519
|
+
|
|
520
|
+
Returns:
|
|
521
|
+
Dictionary with authentication status information
|
|
522
|
+
"""
|
|
523
|
+
return await self.get_auth_status()
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Enhanced base token manager with automatic token refresh."""
|
|
2
|
+
|
|
3
|
+
from ccproxy.auth.exceptions import OAuthTokenRefreshError
|
|
4
|
+
from ccproxy.auth.managers.base import BaseTokenManager, CredentialsT
|
|
5
|
+
from ccproxy.core.logging import get_logger
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
logger = get_logger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class EnhancedTokenManager(BaseTokenManager[CredentialsT]):
|
|
12
|
+
"""Enhanced token manager with automatic refresh capability."""
|
|
13
|
+
|
|
14
|
+
async def get_access_token_with_refresh(self) -> str | None:
|
|
15
|
+
"""Get valid access token, automatically refreshing if expired.
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
Access token if available and valid, None otherwise
|
|
19
|
+
"""
|
|
20
|
+
credentials = await self.load_credentials()
|
|
21
|
+
if not credentials:
|
|
22
|
+
logger.debug("no_credentials_found")
|
|
23
|
+
return None
|
|
24
|
+
|
|
25
|
+
# Check if token is expired
|
|
26
|
+
if self.should_refresh(credentials):
|
|
27
|
+
expires_in = self.seconds_until_expiration(credentials)
|
|
28
|
+
reason = "expired" if self.is_expired(credentials) else "expiring_soon"
|
|
29
|
+
logger.info(
|
|
30
|
+
"token_refresh_needed",
|
|
31
|
+
reason=reason,
|
|
32
|
+
expires_in=expires_in,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
refreshed = await self.refresh_token()
|
|
37
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
38
|
+
logger.warning(
|
|
39
|
+
"token_refresh_exception", error=str(exc), category="auth"
|
|
40
|
+
)
|
|
41
|
+
raise OAuthTokenRefreshError("Token refresh failed") from exc
|
|
42
|
+
|
|
43
|
+
if refreshed:
|
|
44
|
+
logger.info("token_refreshed_successfully")
|
|
45
|
+
credentials = refreshed
|
|
46
|
+
else:
|
|
47
|
+
logger.warning("token_refresh_failed")
|
|
48
|
+
raise OAuthTokenRefreshError("Token refresh failed")
|
|
49
|
+
|
|
50
|
+
snapshot = self._safe_token_snapshot(credentials)
|
|
51
|
+
if snapshot and snapshot.access_token:
|
|
52
|
+
return snapshot.access_token
|
|
53
|
+
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
async def ensure_valid_token(self) -> bool:
|
|
57
|
+
"""Ensure we have a valid (non-expired) token, refreshing if needed.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
True if we have a valid token (after refresh if needed), False otherwise
|
|
61
|
+
"""
|
|
62
|
+
token = await self.get_access_token_with_refresh()
|
|
63
|
+
return token is not None
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Shared token snapshot model for credential managers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(slots=True)
|
|
12
|
+
class TokenSnapshot:
|
|
13
|
+
"""Immutable view over sensitive token metadata.
|
|
14
|
+
|
|
15
|
+
Token managers return this lightweight structure to share
|
|
16
|
+
credential state without exposing implementation details.
|
|
17
|
+
Secrets should only appear in the access/refresh token fields
|
|
18
|
+
and remain masked when rendered via the helper methods.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
provider: str | None = None
|
|
22
|
+
account_id: str | None = None
|
|
23
|
+
access_token: str | None = None
|
|
24
|
+
refresh_token: str | None = None
|
|
25
|
+
expires_at: datetime | None = None
|
|
26
|
+
scopes: tuple[str, ...] = ()
|
|
27
|
+
extras: dict[str, Any] = field(default_factory=dict)
|
|
28
|
+
|
|
29
|
+
def has_access_token(self) -> bool:
|
|
30
|
+
"""Whether an access token is present."""
|
|
31
|
+
return bool(self.access_token)
|
|
32
|
+
|
|
33
|
+
def has_refresh_token(self) -> bool:
|
|
34
|
+
"""Whether a refresh token is present."""
|
|
35
|
+
return bool(self.refresh_token)
|
|
36
|
+
|
|
37
|
+
def access_token_preview(self, visible: int = 8) -> str | None:
|
|
38
|
+
"""Return a masked preview of the access token."""
|
|
39
|
+
token = self.access_token
|
|
40
|
+
if not token:
|
|
41
|
+
return None
|
|
42
|
+
if visible <= 0 or len(token) <= visible * 2:
|
|
43
|
+
return "*" * len(token)
|
|
44
|
+
return f"{token[:visible]}...{token[-visible:]}"
|
|
45
|
+
|
|
46
|
+
def refresh_token_preview(self, visible: int = 4) -> str | None:
|
|
47
|
+
"""Return a masked preview of the refresh token."""
|
|
48
|
+
token = self.refresh_token
|
|
49
|
+
if not token:
|
|
50
|
+
return None
|
|
51
|
+
if visible <= 0 or len(token) <= visible * 2:
|
|
52
|
+
return "*" * len(token)
|
|
53
|
+
return f"{token[:visible]}...{token[-visible:]}"
|
|
54
|
+
|
|
55
|
+
def expires_in_seconds(self) -> int | None:
|
|
56
|
+
"""Return seconds until expiration when available."""
|
|
57
|
+
if not self.expires_at:
|
|
58
|
+
return None
|
|
59
|
+
now = datetime.now(UTC)
|
|
60
|
+
delta = self.expires_at - now
|
|
61
|
+
return max(0, int(delta.total_seconds()))
|
|
62
|
+
|
|
63
|
+
def with_scopes(self, scopes: Iterable[str]) -> TokenSnapshot:
|
|
64
|
+
"""Return a copy with the provided scopes tuple."""
|
|
65
|
+
scope_tuple = tuple(scope for scope in scopes if scope)
|
|
66
|
+
return TokenSnapshot(
|
|
67
|
+
provider=self.provider,
|
|
68
|
+
account_id=self.account_id,
|
|
69
|
+
access_token=self.access_token,
|
|
70
|
+
refresh_token=self.refresh_token,
|
|
71
|
+
expires_at=self.expires_at,
|
|
72
|
+
scopes=scope_tuple,
|
|
73
|
+
extras=dict(self.extras),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
__all__ = ["TokenSnapshot"]
|