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
ccproxy/cli/commands/auth.py
CHANGED
|
@@ -1,964 +1,1690 @@
|
|
|
1
1
|
"""Authentication and credential management commands."""
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
+
import contextlib
|
|
5
|
+
import inspect
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
from collections.abc import Coroutine
|
|
4
9
|
from datetime import UTC, datetime
|
|
5
10
|
from pathlib import Path
|
|
6
|
-
from typing import
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
if TYPE_CHECKING:
|
|
10
|
-
from ccproxy.auth.openai import OpenAIOAuthClient, OpenAITokenManager
|
|
11
|
-
from ccproxy.config.codex import CodexSettings
|
|
11
|
+
from typing import Annotated, Any, cast
|
|
12
12
|
|
|
13
|
+
import structlog
|
|
13
14
|
import typer
|
|
14
15
|
from rich import box
|
|
15
16
|
from rich.console import Console
|
|
16
17
|
from rich.table import Table
|
|
17
|
-
from structlog import get_logger
|
|
18
18
|
|
|
19
|
+
from ccproxy.auth.managers.token_snapshot import TokenSnapshot
|
|
20
|
+
from ccproxy.auth.oauth.cli_errors import (
|
|
21
|
+
AuthProviderError,
|
|
22
|
+
AuthTimedOutError,
|
|
23
|
+
AuthUserAbortedError,
|
|
24
|
+
NetworkError,
|
|
25
|
+
PortBindError,
|
|
26
|
+
)
|
|
27
|
+
from ccproxy.auth.oauth.flows import BrowserFlow, DeviceCodeFlow, ManualCodeFlow
|
|
28
|
+
from ccproxy.auth.oauth.registry import FlowType, OAuthRegistry
|
|
19
29
|
from ccproxy.cli.helpers import get_rich_toolkit
|
|
20
|
-
from ccproxy.config.settings import
|
|
21
|
-
from ccproxy.core.
|
|
22
|
-
from ccproxy.
|
|
30
|
+
from ccproxy.config.settings import Settings
|
|
31
|
+
from ccproxy.core.logging import bootstrap_cli_logging, get_logger, setup_logging
|
|
32
|
+
from ccproxy.core.plugins import load_cli_plugins
|
|
33
|
+
from ccproxy.core.plugins.hooks.manager import HookManager
|
|
34
|
+
from ccproxy.core.plugins.hooks.registry import HookRegistry
|
|
35
|
+
from ccproxy.services.container import ServiceContainer
|
|
23
36
|
|
|
24
37
|
|
|
25
|
-
app = typer.Typer(
|
|
38
|
+
app = typer.Typer(
|
|
39
|
+
name="auth", help="Authentication and credential management", no_args_is_help=True
|
|
40
|
+
)
|
|
26
41
|
|
|
27
42
|
console = Console()
|
|
28
43
|
logger = get_logger(__name__)
|
|
29
44
|
|
|
30
45
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
"""Get a CredentialsManager instance with custom paths if provided."""
|
|
35
|
-
if custom_paths:
|
|
36
|
-
# Get base settings and update storage paths
|
|
37
|
-
settings = get_settings()
|
|
38
|
-
settings.auth.storage.storage_paths = custom_paths
|
|
39
|
-
return CredentialsManager(config=settings.auth)
|
|
40
|
-
else:
|
|
41
|
-
# Use default settings
|
|
42
|
-
settings = get_settings()
|
|
43
|
-
return CredentialsManager(config=settings.auth)
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
def get_docker_credential_paths() -> list[Path]:
|
|
47
|
-
"""Get credential file paths for Docker environment."""
|
|
48
|
-
docker_home = Path(get_claude_docker_home_dir())
|
|
49
|
-
return [
|
|
50
|
-
docker_home / ".claude" / ".credentials.json",
|
|
51
|
-
docker_home / ".config" / "claude" / ".credentials.json",
|
|
52
|
-
Path(".credentials.json"),
|
|
53
|
-
]
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
@app.command(name="validate")
|
|
57
|
-
def validate_credentials(
|
|
58
|
-
docker: Annotated[
|
|
59
|
-
bool,
|
|
60
|
-
typer.Option(
|
|
61
|
-
"--docker",
|
|
62
|
-
help="Use Docker credential paths (from get_claude_docker_home_dir())",
|
|
63
|
-
),
|
|
64
|
-
] = False,
|
|
65
|
-
credential_file: Annotated[
|
|
66
|
-
str | None,
|
|
67
|
-
typer.Option(
|
|
68
|
-
"--credential-file",
|
|
69
|
-
help="Path to specific credential file to validate",
|
|
70
|
-
),
|
|
71
|
-
] = None,
|
|
72
|
-
) -> None:
|
|
73
|
-
"""Validate Claude CLI credentials.
|
|
46
|
+
# Cache settings and container to avoid repeated config file loading
|
|
47
|
+
_cached_settings: Settings | None = None
|
|
48
|
+
_cached_container: ServiceContainer | None = None
|
|
74
49
|
|
|
75
|
-
Checks for valid Claude credentials in standard locations:
|
|
76
|
-
- ~/.claude/credentials.json
|
|
77
|
-
- ~/.config/claude/credentials.json
|
|
78
50
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
51
|
+
@contextlib.contextmanager
|
|
52
|
+
def _temporary_disable_provider_storage(provider: Any, *, disable: bool) -> Any:
|
|
53
|
+
"""Temporarily disable provider/client storage (used for custom credential paths)."""
|
|
82
54
|
|
|
83
|
-
|
|
55
|
+
if not disable:
|
|
56
|
+
yield
|
|
57
|
+
return
|
|
84
58
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
ccproxy auth validate --credential-file /path/to/credentials.json
|
|
89
|
-
"""
|
|
90
|
-
toolkit = get_rich_toolkit()
|
|
91
|
-
toolkit.print("[bold cyan]Claude Credentials Validation[/bold cyan]", centered=True)
|
|
92
|
-
toolkit.print_line()
|
|
59
|
+
original_provider_storage = getattr(provider, "storage", None)
|
|
60
|
+
client = getattr(provider, "client", None)
|
|
61
|
+
original_client_storage = getattr(client, "storage", None) if client else None
|
|
93
62
|
|
|
94
63
|
try:
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
if
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
64
|
+
if hasattr(provider, "storage"):
|
|
65
|
+
provider.storage = None
|
|
66
|
+
if client is not None and hasattr(client, "storage"):
|
|
67
|
+
client.storage = None
|
|
68
|
+
yield
|
|
69
|
+
finally:
|
|
70
|
+
if hasattr(provider, "storage"):
|
|
71
|
+
provider.storage = original_provider_storage
|
|
72
|
+
if client is not None and hasattr(client, "storage"):
|
|
73
|
+
client.storage = original_client_storage
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _normalize_credentials_file_option(
|
|
77
|
+
toolkit: Any,
|
|
78
|
+
file_option: Path | None,
|
|
79
|
+
*,
|
|
80
|
+
require_exists: bool,
|
|
81
|
+
create_parent: bool = False,
|
|
82
|
+
) -> Path | None:
|
|
83
|
+
"""Resolve and validate a user-supplied credential file path."""
|
|
84
|
+
|
|
85
|
+
if file_option is None:
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
custom_path = file_option.expanduser()
|
|
89
|
+
try:
|
|
90
|
+
custom_path = custom_path.resolve()
|
|
91
|
+
except FileNotFoundError:
|
|
92
|
+
# If parents do not exist, fall back to absolute path for messaging
|
|
93
|
+
custom_path = custom_path.absolute()
|
|
94
|
+
|
|
95
|
+
if custom_path.exists() and custom_path.is_dir():
|
|
96
|
+
toolkit.print(
|
|
97
|
+
f"Target path '{custom_path}' is a directory. Provide a file path.",
|
|
98
|
+
tag="error",
|
|
99
|
+
)
|
|
100
|
+
raise typer.Exit(1)
|
|
101
|
+
|
|
102
|
+
if require_exists and not custom_path.exists():
|
|
103
|
+
toolkit.print(
|
|
104
|
+
f"Credential file '{custom_path}' not found.",
|
|
105
|
+
tag="error",
|
|
106
|
+
)
|
|
107
|
+
raise typer.Exit(1)
|
|
108
|
+
|
|
109
|
+
if create_parent:
|
|
110
|
+
try:
|
|
111
|
+
custom_path.parent.mkdir(parents=True, exist_ok=True)
|
|
112
|
+
except Exception as exc:
|
|
113
|
+
toolkit.print(
|
|
114
|
+
f"Failed to create directory '{custom_path.parent}': {exc}",
|
|
115
|
+
tag="error",
|
|
114
116
|
)
|
|
115
|
-
|
|
116
|
-
table.add_column("Value", style="white")
|
|
117
|
-
|
|
118
|
-
# Status
|
|
119
|
-
status = "Valid" if not validation_result.expired else "Expired"
|
|
120
|
-
status_style = "green" if not validation_result.expired else "red"
|
|
121
|
-
table.add_row("Status", f"[{status_style}]{status}[/{status_style}]")
|
|
122
|
-
|
|
123
|
-
# Path
|
|
124
|
-
if validation_result.path:
|
|
125
|
-
table.add_row("Location", f"[dim]{validation_result.path}[/dim]")
|
|
126
|
-
|
|
127
|
-
# Subscription type
|
|
128
|
-
if validation_result.credentials:
|
|
129
|
-
sub_type = (
|
|
130
|
-
validation_result.credentials.claude_ai_oauth.subscription_type
|
|
131
|
-
or "Unknown"
|
|
132
|
-
)
|
|
133
|
-
table.add_row("Subscription", f"[bold]{sub_type}[/bold]")
|
|
134
|
-
|
|
135
|
-
# Expiration
|
|
136
|
-
oauth_token = validation_result.credentials.claude_ai_oauth
|
|
137
|
-
exp_dt = oauth_token.expires_at_datetime
|
|
138
|
-
now = datetime.now(UTC)
|
|
139
|
-
time_diff = exp_dt - now
|
|
140
|
-
|
|
141
|
-
if time_diff.total_seconds() > 0:
|
|
142
|
-
days = time_diff.days
|
|
143
|
-
hours = time_diff.seconds // 3600
|
|
144
|
-
exp_str = f"{exp_dt.strftime('%Y-%m-%d %H:%M:%S UTC')} ({days}d {hours}h remaining)"
|
|
145
|
-
else:
|
|
146
|
-
exp_str = f"{exp_dt.strftime('%Y-%m-%d %H:%M:%S UTC')} [red](Expired)[/red]"
|
|
117
|
+
raise typer.Exit(1) from exc
|
|
147
118
|
|
|
148
|
-
|
|
119
|
+
return custom_path
|
|
149
120
|
|
|
150
|
-
# Scopes
|
|
151
|
-
scopes = oauth_token.scopes
|
|
152
|
-
if scopes:
|
|
153
|
-
table.add_row("Scopes", ", ".join(str(s) for s in scopes))
|
|
154
121
|
|
|
155
|
-
|
|
122
|
+
def _get_cached_settings() -> Settings:
|
|
123
|
+
"""Get cached settings instance."""
|
|
124
|
+
global _cached_settings
|
|
125
|
+
if _cached_settings is None:
|
|
126
|
+
_cached_settings = Settings.from_config()
|
|
127
|
+
return _cached_settings
|
|
156
128
|
|
|
157
|
-
# Success message
|
|
158
|
-
if not validation_result.expired:
|
|
159
|
-
toolkit.print(
|
|
160
|
-
"[green]✓[/green] Valid Claude credentials found", tag="success"
|
|
161
|
-
)
|
|
162
|
-
else:
|
|
163
|
-
toolkit.print(
|
|
164
|
-
"[yellow]![/yellow] Claude credentials found but expired",
|
|
165
|
-
tag="warning",
|
|
166
|
-
)
|
|
167
|
-
toolkit.print(
|
|
168
|
-
"\nPlease refresh your credentials by logging into Claude CLI",
|
|
169
|
-
tag="info",
|
|
170
|
-
)
|
|
171
129
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
130
|
+
def _get_service_container() -> ServiceContainer:
|
|
131
|
+
"""Create a service container for the auth commands."""
|
|
132
|
+
global _cached_container
|
|
133
|
+
if _cached_container is None:
|
|
134
|
+
settings = _get_cached_settings()
|
|
135
|
+
_cached_container = ServiceContainer(settings)
|
|
136
|
+
return _cached_container
|
|
175
137
|
|
|
176
|
-
console.print("\n[dim]To authenticate with Claude CLI, run:[/dim]")
|
|
177
|
-
console.print("[cyan]claude login[/cyan]")
|
|
178
138
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
139
|
+
def _apply_auth_logger_level() -> None:
|
|
140
|
+
"""Set logger level from settings without configuring handlers."""
|
|
141
|
+
try:
|
|
142
|
+
settings = _get_cached_settings()
|
|
143
|
+
level_name = settings.logging.level
|
|
144
|
+
level = getattr(logging, level_name.upper(), logging.INFO)
|
|
145
|
+
except Exception:
|
|
146
|
+
level = logging.INFO
|
|
182
147
|
|
|
148
|
+
logging.getLogger("ccproxy").setLevel(level)
|
|
149
|
+
logging.getLogger(__name__).setLevel(level)
|
|
183
150
|
|
|
184
|
-
@app.command(name="info")
|
|
185
|
-
def credential_info(
|
|
186
|
-
docker: Annotated[
|
|
187
|
-
bool,
|
|
188
|
-
typer.Option(
|
|
189
|
-
"--docker",
|
|
190
|
-
help="Use Docker credential paths (from get_claude_docker_home_dir())",
|
|
191
|
-
),
|
|
192
|
-
] = False,
|
|
193
|
-
credential_file: Annotated[
|
|
194
|
-
str | None,
|
|
195
|
-
typer.Option(
|
|
196
|
-
"--credential-file",
|
|
197
|
-
help="Path to specific credential file to display info for",
|
|
198
|
-
),
|
|
199
|
-
] = None,
|
|
200
|
-
) -> None:
|
|
201
|
-
"""Display detailed credential information.
|
|
202
151
|
|
|
203
|
-
|
|
204
|
-
|
|
152
|
+
def _ensure_logging_configured() -> None:
|
|
153
|
+
"""Ensure global logging is configured with the standard format."""
|
|
154
|
+
if structlog.is_configured():
|
|
155
|
+
return
|
|
205
156
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
toolkit = get_rich_toolkit()
|
|
212
|
-
toolkit.print("[bold cyan]Claude Credential Information[/bold cyan]", centered=True)
|
|
213
|
-
toolkit.print_line()
|
|
157
|
+
with contextlib.suppress(Exception):
|
|
158
|
+
bootstrap_cli_logging()
|
|
159
|
+
|
|
160
|
+
if structlog.is_configured():
|
|
161
|
+
return
|
|
214
162
|
|
|
163
|
+
level_name = os.getenv("LOGGING__LEVEL", "INFO")
|
|
164
|
+
log_file = os.getenv("LOGGING__FILE")
|
|
215
165
|
try:
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
custom_paths = [Path(credential_file)]
|
|
220
|
-
elif docker:
|
|
221
|
-
custom_paths = get_docker_credential_paths()
|
|
166
|
+
setup_logging(json_logs=False, log_level_name=level_name, log_file=log_file)
|
|
167
|
+
except Exception:
|
|
168
|
+
_apply_auth_logger_level()
|
|
222
169
|
|
|
223
|
-
# Get credentials manager and try to load credentials
|
|
224
|
-
manager = get_credentials_manager(custom_paths)
|
|
225
|
-
credentials = asyncio.run(manager.load())
|
|
226
170
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
for path in manager.config.storage.storage_paths:
|
|
231
|
-
console.print(f" - {path}")
|
|
232
|
-
raise typer.Exit(1)
|
|
171
|
+
def _expected_plugin_class_name(provider: str) -> str:
|
|
172
|
+
"""Return the expected plugin class name from provider input for messaging."""
|
|
173
|
+
import re
|
|
233
174
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
# Login method based on subscription type
|
|
239
|
-
login_method = "Claude Account"
|
|
240
|
-
if oauth.subscription_type:
|
|
241
|
-
login_method = f"Claude {oauth.subscription_type.title()} Account"
|
|
242
|
-
console.print(f" L Login Method: {login_method}")
|
|
243
|
-
|
|
244
|
-
# Try to load saved account profile first
|
|
245
|
-
profile = asyncio.run(manager.get_account_profile())
|
|
246
|
-
|
|
247
|
-
if profile:
|
|
248
|
-
# Display saved account data
|
|
249
|
-
if profile.organization:
|
|
250
|
-
console.print(f" L Organization: {profile.organization.name}")
|
|
251
|
-
if profile.organization.organization_type:
|
|
252
|
-
console.print(
|
|
253
|
-
f" L Organization Type: {profile.organization.organization_type}"
|
|
254
|
-
)
|
|
255
|
-
if profile.organization.billing_type:
|
|
256
|
-
console.print(
|
|
257
|
-
f" L Billing Type: {profile.organization.billing_type}"
|
|
258
|
-
)
|
|
259
|
-
if profile.organization.rate_limit_tier:
|
|
260
|
-
console.print(
|
|
261
|
-
f" L Rate Limit Tier: {profile.organization.rate_limit_tier}"
|
|
262
|
-
)
|
|
263
|
-
else:
|
|
264
|
-
console.print(" L Organization: [dim]Not available[/dim]")
|
|
265
|
-
|
|
266
|
-
if profile.account:
|
|
267
|
-
console.print(f" L Email: {profile.account.email}")
|
|
268
|
-
if profile.account.full_name:
|
|
269
|
-
console.print(f" L Full Name: {profile.account.full_name}")
|
|
270
|
-
if profile.account.display_name:
|
|
271
|
-
console.print(f" L Display Name: {profile.account.display_name}")
|
|
272
|
-
console.print(
|
|
273
|
-
f" L Has Claude Pro: {'Yes' if profile.account.has_claude_pro else 'No'}"
|
|
274
|
-
)
|
|
275
|
-
console.print(
|
|
276
|
-
f" L Has Claude Max: {'Yes' if profile.account.has_claude_max else 'No'}"
|
|
277
|
-
)
|
|
278
|
-
else:
|
|
279
|
-
console.print(" L Email: [dim]Not available[/dim]")
|
|
280
|
-
else:
|
|
281
|
-
# No saved profile, try to fetch fresh data
|
|
282
|
-
try:
|
|
283
|
-
# First try to get a valid access token (with refresh if needed)
|
|
284
|
-
valid_token = asyncio.run(manager.get_access_token())
|
|
285
|
-
if valid_token:
|
|
286
|
-
profile = asyncio.run(manager.fetch_user_profile())
|
|
287
|
-
if profile:
|
|
288
|
-
# Save the profile for future use
|
|
289
|
-
asyncio.run(manager._save_account_profile(profile))
|
|
290
|
-
|
|
291
|
-
if profile.organization:
|
|
292
|
-
console.print(
|
|
293
|
-
f" L Organization: {profile.organization.name}"
|
|
294
|
-
)
|
|
295
|
-
else:
|
|
296
|
-
console.print(
|
|
297
|
-
" L Organization: [dim]Unable to fetch[/dim]"
|
|
298
|
-
)
|
|
175
|
+
base = re.sub(r"[^a-zA-Z0-9]+", "_", provider.strip()).strip("_")
|
|
176
|
+
parts = [p for p in base.split("_") if p]
|
|
177
|
+
camel = "".join(s[:1].upper() + s[1:] for s in parts)
|
|
178
|
+
return f"Oauth{camel}Plugin"
|
|
299
179
|
|
|
300
|
-
if profile.account:
|
|
301
|
-
console.print(f" L Email: {profile.account.email}")
|
|
302
|
-
else:
|
|
303
|
-
console.print(" L Email: [dim]Unable to fetch[/dim]")
|
|
304
|
-
else:
|
|
305
|
-
console.print(" L Organization: [dim]Unable to fetch[/dim]")
|
|
306
|
-
console.print(" L Email: [dim]Unable to fetch[/dim]")
|
|
307
180
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
else:
|
|
313
|
-
console.print(" L Organization: [dim]Token refresh failed[/dim]")
|
|
314
|
-
console.print(" L Email: [dim]Token refresh failed[/dim]")
|
|
315
|
-
except Exception as e:
|
|
316
|
-
logger.debug(f"Could not fetch user profile: {e}")
|
|
317
|
-
console.print(" L Organization: [dim]Unable to fetch[/dim]")
|
|
318
|
-
console.print(" L Email: [dim]Unable to fetch[/dim]")
|
|
181
|
+
def _token_snapshot_from_credentials(
|
|
182
|
+
credentials: Any, provider: str | None = None
|
|
183
|
+
) -> TokenSnapshot | None:
|
|
184
|
+
"""Best-effort conversion of provider credentials into a token snapshot.
|
|
319
185
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
show_header=True,
|
|
324
|
-
header_style="bold cyan",
|
|
325
|
-
box=box.ROUNDED,
|
|
326
|
-
title="Credential Details",
|
|
327
|
-
title_style="bold white",
|
|
328
|
-
)
|
|
329
|
-
table.add_column("Property", style="cyan")
|
|
330
|
-
table.add_column("Value", style="white")
|
|
186
|
+
Uses the BaseCredentials protocol instead of direct imports to avoid boundary violations.
|
|
187
|
+
"""
|
|
188
|
+
from ccproxy.auth.models.credentials import BaseCredentials
|
|
331
189
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
else:
|
|
337
|
-
table.add_row("File Location", "Keyring storage")
|
|
190
|
+
# Check if credentials follow the BaseCredentials protocol
|
|
191
|
+
if not isinstance(credentials, BaseCredentials):
|
|
192
|
+
# If not following the protocol, try to extract basic info using duck typing
|
|
193
|
+
return _extract_token_snapshot_duck_typing(credentials, provider)
|
|
338
194
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
195
|
+
# Use the protocol methods
|
|
196
|
+
try:
|
|
197
|
+
data = credentials.to_dict()
|
|
198
|
+
return _build_token_snapshot_from_dict(data, provider)
|
|
199
|
+
except Exception:
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _extract_token_snapshot_duck_typing(
|
|
204
|
+
credentials: Any, provider: str | None = None
|
|
205
|
+
) -> TokenSnapshot | None:
|
|
206
|
+
"""Extract token snapshot using duck typing for non-protocol credentials."""
|
|
207
|
+
if not credentials:
|
|
208
|
+
return None
|
|
209
|
+
|
|
210
|
+
# Generic duck-typing approach - look for common attributes
|
|
211
|
+
access_token: str | None = None
|
|
212
|
+
refresh_token: str | None = None
|
|
213
|
+
expires_at: datetime | None = None
|
|
214
|
+
account_id: str | None = None
|
|
215
|
+
extras: dict[str, Any] = {}
|
|
216
|
+
|
|
217
|
+
# Try to extract access token from various possible attributes
|
|
218
|
+
for attr in ["access_token", "token"]:
|
|
219
|
+
if hasattr(credentials, attr):
|
|
220
|
+
token_obj = getattr(credentials, attr)
|
|
221
|
+
if token_obj:
|
|
222
|
+
if hasattr(token_obj, "get_secret_value"):
|
|
223
|
+
access_token = token_obj.get_secret_value()
|
|
224
|
+
elif isinstance(token_obj, str):
|
|
225
|
+
access_token = token_obj
|
|
226
|
+
break
|
|
227
|
+
|
|
228
|
+
# Try to extract refresh token
|
|
229
|
+
if hasattr(credentials, "refresh_token"):
|
|
230
|
+
refresh_obj = credentials.refresh_token
|
|
231
|
+
if refresh_obj and hasattr(refresh_obj, "get_secret_value"):
|
|
232
|
+
refresh_token = refresh_obj.get_secret_value()
|
|
233
|
+
elif isinstance(refresh_obj, str):
|
|
234
|
+
refresh_token = refresh_obj
|
|
235
|
+
|
|
236
|
+
# Try to extract expiration
|
|
237
|
+
for attr in ["expires_at", "expires_at_datetime", "expiry"]:
|
|
238
|
+
if hasattr(credentials, attr):
|
|
239
|
+
expires_obj = getattr(credentials, attr)
|
|
240
|
+
if isinstance(expires_obj, datetime):
|
|
241
|
+
expires_at = expires_obj
|
|
242
|
+
break
|
|
243
|
+
|
|
244
|
+
# Try to extract account ID
|
|
245
|
+
for attr in ["account_id", "user_id", "id"]:
|
|
246
|
+
if hasattr(credentials, attr):
|
|
247
|
+
id_obj = getattr(credentials, attr)
|
|
248
|
+
if isinstance(id_obj, str):
|
|
249
|
+
account_id = id_obj
|
|
250
|
+
break
|
|
251
|
+
|
|
252
|
+
if not access_token:
|
|
253
|
+
return None
|
|
254
|
+
|
|
255
|
+
return TokenSnapshot(
|
|
256
|
+
provider=provider or "unknown",
|
|
257
|
+
account_id=account_id,
|
|
258
|
+
access_token=access_token,
|
|
259
|
+
refresh_token=refresh_token,
|
|
260
|
+
expires_at=expires_at,
|
|
261
|
+
extras={},
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _build_token_snapshot_from_dict(
|
|
266
|
+
data: dict[str, Any], provider: str | None = None
|
|
267
|
+
) -> TokenSnapshot | None:
|
|
268
|
+
"""Build token snapshot from dictionary data."""
|
|
269
|
+
if not data:
|
|
270
|
+
return None
|
|
271
|
+
|
|
272
|
+
def _unwrap_secret(value: Any) -> str | None:
|
|
273
|
+
"""Return plain string from SecretStr-like values."""
|
|
274
|
+
if value is None:
|
|
275
|
+
return None
|
|
276
|
+
if hasattr(value, "get_secret_value"):
|
|
277
|
+
try:
|
|
278
|
+
result = value.get_secret_value()
|
|
279
|
+
return str(result) if result is not None else None
|
|
280
|
+
except Exception:
|
|
281
|
+
return None
|
|
282
|
+
if isinstance(value, str):
|
|
283
|
+
return value or None
|
|
284
|
+
return None
|
|
285
|
+
|
|
286
|
+
def _coerce_datetime(value: Any) -> datetime | None:
|
|
287
|
+
"""Convert supported values into timezone-aware datetime objects."""
|
|
288
|
+
if value is None:
|
|
289
|
+
return None
|
|
290
|
+
if isinstance(value, datetime):
|
|
291
|
+
return value if value.tzinfo else value.replace(tzinfo=UTC)
|
|
292
|
+
if isinstance(value, str):
|
|
293
|
+
try:
|
|
294
|
+
parsed = datetime.fromisoformat(value)
|
|
295
|
+
except ValueError:
|
|
296
|
+
return None
|
|
297
|
+
return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC)
|
|
298
|
+
if isinstance(value, int | float):
|
|
299
|
+
timestamp = float(value)
|
|
300
|
+
# Treat very large integers as millisecond timestamps
|
|
301
|
+
if timestamp > 1e11:
|
|
302
|
+
timestamp /= 1000
|
|
303
|
+
try:
|
|
304
|
+
return datetime.fromtimestamp(timestamp, tz=UTC)
|
|
305
|
+
except (OSError, ValueError):
|
|
306
|
+
return None
|
|
307
|
+
return None
|
|
308
|
+
|
|
309
|
+
provider_value = provider or data.get("provider") or "unknown"
|
|
310
|
+
provider_normalized = provider_value.replace("_", "-")
|
|
311
|
+
|
|
312
|
+
extras: dict[str, Any] = dict(data.get("extras", {}))
|
|
313
|
+
scopes: tuple[str, ...] = tuple(
|
|
314
|
+
str(scope) for scope in data.get("scopes", []) if scope
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
account_id: str | None = _unwrap_secret(data.get("account_id"))
|
|
318
|
+
access_token: str | None = _unwrap_secret(data.get("access_token"))
|
|
319
|
+
refresh_token: str | None = _unwrap_secret(data.get("refresh_token"))
|
|
320
|
+
expires_at: datetime | None = _coerce_datetime(data.get("expires_at"))
|
|
321
|
+
|
|
322
|
+
claude_data = data.get("claudeAiOauth") or data.get("claude_ai_oauth")
|
|
323
|
+
if isinstance(claude_data, dict):
|
|
324
|
+
provider_normalized = "claude-api"
|
|
325
|
+
access_token = access_token or _unwrap_secret(
|
|
326
|
+
claude_data.get("accessToken") or claude_data.get("access_token")
|
|
344
327
|
)
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
328
|
+
refresh_token = refresh_token or _unwrap_secret(
|
|
329
|
+
claude_data.get("refreshToken") or claude_data.get("refresh_token")
|
|
330
|
+
)
|
|
331
|
+
expires_at = expires_at or _coerce_datetime(
|
|
332
|
+
claude_data.get("expiresAt") or claude_data.get("expires_at")
|
|
333
|
+
)
|
|
334
|
+
scopes = tuple(str(scope) for scope in claude_data.get("scopes", []) if scope)
|
|
335
|
+
subscription = claude_data.get("subscriptionType") or claude_data.get(
|
|
336
|
+
"subscription_type"
|
|
337
|
+
)
|
|
338
|
+
if subscription:
|
|
339
|
+
extras.setdefault("subscription_type", subscription)
|
|
340
|
+
|
|
341
|
+
tokens_data = data.get("tokens")
|
|
342
|
+
if isinstance(tokens_data, dict):
|
|
343
|
+
provider_normalized = "codex"
|
|
344
|
+
access_token = access_token or _unwrap_secret(tokens_data.get("access_token"))
|
|
345
|
+
refresh_token = refresh_token or _unwrap_secret(
|
|
346
|
+
tokens_data.get("refresh_token")
|
|
347
|
+
)
|
|
348
|
+
account_id = account_id or tokens_data.get("account_id")
|
|
349
|
+
if "id_token_present" not in extras:
|
|
350
|
+
extras["id_token_present"] = bool(tokens_data.get("id_token"))
|
|
351
|
+
|
|
352
|
+
oauth_token_data = data.get("oauth_token") or data.get("oauthToken")
|
|
353
|
+
copilot_token_data = data.get("copilot_token") or data.get("copilotToken")
|
|
354
|
+
if isinstance(oauth_token_data, dict) or isinstance(copilot_token_data, dict):
|
|
355
|
+
provider_normalized = "copilot"
|
|
356
|
+
|
|
357
|
+
if isinstance(copilot_token_data, dict):
|
|
358
|
+
token_value = _unwrap_secret(copilot_token_data.get("token"))
|
|
359
|
+
if token_value:
|
|
360
|
+
access_token = token_value
|
|
361
|
+
expires_at = (
|
|
362
|
+
_coerce_datetime(copilot_token_data.get("expires_at")) or expires_at
|
|
379
363
|
)
|
|
364
|
+
extras.setdefault("has_copilot_token", True)
|
|
380
365
|
|
|
366
|
+
if isinstance(oauth_token_data, dict):
|
|
367
|
+
access_token = access_token or _unwrap_secret(
|
|
368
|
+
oauth_token_data.get("access_token")
|
|
369
|
+
)
|
|
370
|
+
refresh_token = refresh_token or _unwrap_secret(
|
|
371
|
+
oauth_token_data.get("refresh_token")
|
|
372
|
+
)
|
|
373
|
+
scope_field = oauth_token_data.get("scope") or ""
|
|
374
|
+
if scope_field and not scopes:
|
|
375
|
+
scopes = tuple(
|
|
376
|
+
scope
|
|
377
|
+
for scope in (item.strip() for item in str(scope_field).split(" "))
|
|
378
|
+
if scope
|
|
379
|
+
)
|
|
380
|
+
if not extras.get("has_copilot_token"):
|
|
381
|
+
extras["has_copilot_token"] = False
|
|
382
|
+
if not expires_at:
|
|
383
|
+
created_at = oauth_token_data.get("created_at")
|
|
384
|
+
expires_in = oauth_token_data.get("expires_in")
|
|
385
|
+
if isinstance(created_at, int | float) and isinstance(
|
|
386
|
+
expires_in, int | float
|
|
387
|
+
):
|
|
388
|
+
expires_at = _coerce_datetime(created_at + expires_in)
|
|
389
|
+
|
|
390
|
+
if provider_normalized == "copilot":
|
|
391
|
+
if "refresh_token_present" not in extras:
|
|
392
|
+
extras["refresh_token_present"] = bool(refresh_token)
|
|
393
|
+
extras.setdefault("id_token_present", bool(extras.get("has_copilot_token")))
|
|
394
|
+
|
|
395
|
+
return TokenSnapshot(
|
|
396
|
+
provider=provider_normalized,
|
|
397
|
+
account_id=account_id,
|
|
398
|
+
access_token=access_token,
|
|
399
|
+
refresh_token=refresh_token,
|
|
400
|
+
expires_at=expires_at,
|
|
401
|
+
scopes=scopes,
|
|
402
|
+
extras=extras,
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _render_profile_table(
|
|
407
|
+
profile: dict[str, Any],
|
|
408
|
+
title: str = "Account Information",
|
|
409
|
+
) -> None:
|
|
410
|
+
"""Render a clean, two-column table of profile data using Rich."""
|
|
411
|
+
table = Table(show_header=False, box=box.SIMPLE, title=title)
|
|
412
|
+
table.add_column("Field", style="bold")
|
|
413
|
+
table.add_column("Value")
|
|
414
|
+
|
|
415
|
+
def _val(v: Any) -> str:
|
|
416
|
+
if v is None:
|
|
417
|
+
return ""
|
|
418
|
+
if hasattr(v, "isoformat"):
|
|
419
|
+
try:
|
|
420
|
+
return str(v)
|
|
421
|
+
except Exception:
|
|
422
|
+
return str(v)
|
|
423
|
+
if isinstance(v, bool):
|
|
424
|
+
return "Yes" if v else "No"
|
|
425
|
+
if isinstance(v, list):
|
|
426
|
+
return ", ".join(str(x) for x in v)
|
|
427
|
+
s = str(v)
|
|
428
|
+
return s
|
|
429
|
+
|
|
430
|
+
def _row(label: str, key: str) -> None:
|
|
431
|
+
if key in profile and profile[key] not in (None, "", []):
|
|
432
|
+
table.add_row(label, _val(profile[key]))
|
|
433
|
+
|
|
434
|
+
_row("Provider", "provider_type")
|
|
435
|
+
_row("Account ID", "account_id")
|
|
436
|
+
_row("Email", "email")
|
|
437
|
+
_row("Display Name", "display_name")
|
|
438
|
+
|
|
439
|
+
_row("Subscription", "subscription_type")
|
|
440
|
+
_row("Subscription Status", "subscription_status")
|
|
441
|
+
_row("Subscription Expires", "subscription_expires_at")
|
|
442
|
+
|
|
443
|
+
_row("Organization", "organization_name")
|
|
444
|
+
_row("Organization Role", "organization_role")
|
|
445
|
+
|
|
446
|
+
_row("Has Refresh Token", "has_refresh_token")
|
|
447
|
+
_row("Has ID Token", "has_id_token")
|
|
448
|
+
_row("Token Expires", "token_expires_at")
|
|
449
|
+
|
|
450
|
+
_row("Email Verified", "email_verified")
|
|
451
|
+
|
|
452
|
+
if len(table.rows) > 0:
|
|
381
453
|
console.print(table)
|
|
382
454
|
|
|
383
|
-
except Exception as e:
|
|
384
|
-
toolkit.print(f"Error getting credential info: {e}", tag="error")
|
|
385
|
-
raise typer.Exit(1) from e
|
|
386
455
|
|
|
456
|
+
def _render_profile_features(profile: dict[str, Any]) -> None:
|
|
457
|
+
"""Render provider-specific features if present."""
|
|
458
|
+
features = profile.get("features")
|
|
459
|
+
if isinstance(features, dict) and features:
|
|
460
|
+
table = Table(show_header=False, box=box.SIMPLE, title="Features")
|
|
461
|
+
table.add_column("Feature", style="bold")
|
|
462
|
+
table.add_column("Value")
|
|
463
|
+
for k, v in features.items():
|
|
464
|
+
name = k.replace("_", " ").title()
|
|
465
|
+
val = (
|
|
466
|
+
"Yes"
|
|
467
|
+
if isinstance(v, bool) and v
|
|
468
|
+
else ("No" if isinstance(v, bool) else str(v))
|
|
469
|
+
)
|
|
470
|
+
if val and val != "No":
|
|
471
|
+
table.add_row(name, val)
|
|
472
|
+
if len(table.rows) > 0:
|
|
473
|
+
console.print(table)
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _provider_plugin_name(provider: str) -> str | None:
|
|
477
|
+
"""Map CLI provider name to plugin manifest name."""
|
|
478
|
+
key = provider.strip().lower()
|
|
479
|
+
mapping: dict[str, str] = {
|
|
480
|
+
"codex": "oauth_codex",
|
|
481
|
+
"claude-api": "oauth_claude",
|
|
482
|
+
"claude_api": "oauth_claude",
|
|
483
|
+
}
|
|
484
|
+
return mapping.get(key)
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _await_if_needed(value: Any) -> Any:
|
|
488
|
+
"""Await coroutine values in synchronous CLI context."""
|
|
489
|
+
if inspect.isawaitable(value):
|
|
490
|
+
return asyncio.run(cast(Coroutine[Any, Any, Any], value))
|
|
491
|
+
return value
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _resolve_token_manager_from_registry(
|
|
495
|
+
provider: str, oauth_provider: Any, container: ServiceContainer
|
|
496
|
+
) -> Any | None:
|
|
497
|
+
"""Try fetching an auth manager from the global registry."""
|
|
498
|
+
try:
|
|
499
|
+
registry = container.get_auth_manager_registry()
|
|
500
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
501
|
+
logger.debug("auth_manager_registry_unavailable", error=str(exc))
|
|
502
|
+
return None
|
|
503
|
+
|
|
504
|
+
candidates: list[str] = []
|
|
505
|
+
|
|
506
|
+
def _push(name: str | None) -> None:
|
|
507
|
+
if not name:
|
|
508
|
+
return
|
|
509
|
+
normalized = name.strip()
|
|
510
|
+
if not normalized:
|
|
511
|
+
return
|
|
512
|
+
for variant in {
|
|
513
|
+
normalized,
|
|
514
|
+
normalized.replace("-", "_"),
|
|
515
|
+
}: # normalize hyphen/underscore
|
|
516
|
+
if variant not in candidates:
|
|
517
|
+
candidates.append(variant)
|
|
518
|
+
|
|
519
|
+
_push(provider)
|
|
520
|
+
_push(_provider_plugin_name(provider))
|
|
521
|
+
_push(getattr(oauth_provider, "provider_name", None))
|
|
522
|
+
|
|
523
|
+
try:
|
|
524
|
+
info = oauth_provider.get_provider_info()
|
|
525
|
+
_push(getattr(info, "plugin_name", None))
|
|
526
|
+
except Exception as exc: # pragma: no cover - defensive logging only
|
|
527
|
+
logger.debug("provider_info_lookup_failed", error=str(exc))
|
|
528
|
+
|
|
529
|
+
for candidate in candidates:
|
|
530
|
+
try:
|
|
531
|
+
manager = asyncio.run(registry.get(candidate))
|
|
532
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
533
|
+
logger.debug(
|
|
534
|
+
"auth_manager_registry_get_failed", name=candidate, error=str(exc)
|
|
535
|
+
)
|
|
536
|
+
continue
|
|
537
|
+
if manager:
|
|
538
|
+
return manager
|
|
539
|
+
|
|
540
|
+
return None
|
|
387
541
|
|
|
388
|
-
@app.command(name="login")
|
|
389
|
-
def login_command(
|
|
390
|
-
docker: Annotated[
|
|
391
|
-
bool,
|
|
392
|
-
typer.Option(
|
|
393
|
-
"--docker",
|
|
394
|
-
help="Use Docker credential paths (from get_claude_docker_home_dir())",
|
|
395
|
-
),
|
|
396
|
-
] = False,
|
|
397
|
-
credential_file: Annotated[
|
|
398
|
-
str | None,
|
|
399
|
-
typer.Option(
|
|
400
|
-
"--credential-file",
|
|
401
|
-
help="Path to specific credential file to save to",
|
|
402
|
-
),
|
|
403
|
-
] = None,
|
|
404
|
-
) -> None:
|
|
405
|
-
"""Login to Claude using OAuth authentication.
|
|
406
542
|
|
|
407
|
-
|
|
408
|
-
|
|
543
|
+
def _resolve_token_manager(
|
|
544
|
+
provider: str, oauth_provider: Any, container: ServiceContainer
|
|
545
|
+
) -> Any | None:
|
|
546
|
+
"""Resolve token manager via registry or provider helpers."""
|
|
547
|
+
manager = _resolve_token_manager_from_registry(provider, oauth_provider, container)
|
|
548
|
+
if manager:
|
|
549
|
+
return manager
|
|
409
550
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
551
|
+
if hasattr(oauth_provider, "get_token_manager"):
|
|
552
|
+
try:
|
|
553
|
+
candidate = oauth_provider.get_token_manager()
|
|
554
|
+
manager = _await_if_needed(candidate)
|
|
555
|
+
if manager:
|
|
556
|
+
return manager
|
|
557
|
+
except Exception as exc: # pragma: no cover - defensive logging
|
|
558
|
+
logger.debug("get_token_manager_failed", error=str(exc))
|
|
559
|
+
|
|
560
|
+
if hasattr(oauth_provider, "create_token_manager"):
|
|
561
|
+
try:
|
|
562
|
+
candidate = oauth_provider.create_token_manager()
|
|
563
|
+
manager = _await_if_needed(candidate)
|
|
564
|
+
if manager:
|
|
565
|
+
return manager
|
|
566
|
+
except Exception as exc: # pragma: no cover - defensive logging
|
|
567
|
+
logger.debug("create_token_manager_failed", error=str(exc))
|
|
568
|
+
|
|
569
|
+
return None
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def _format_seconds(seconds: int | None) -> str:
|
|
573
|
+
"""Format seconds into a short human-readable duration."""
|
|
574
|
+
if seconds is None:
|
|
575
|
+
return "Unknown"
|
|
576
|
+
if seconds <= 0:
|
|
577
|
+
return "Expired"
|
|
578
|
+
|
|
579
|
+
remaining = int(seconds)
|
|
580
|
+
parts: list[str] = []
|
|
581
|
+
for label, divisor in (("d", 86_400), ("h", 3_600), ("m", 60)):
|
|
582
|
+
value, remaining = divmod(remaining, divisor)
|
|
583
|
+
if value:
|
|
584
|
+
parts.append(f"{value}{label}")
|
|
585
|
+
if len(parts) == 2:
|
|
586
|
+
break
|
|
587
|
+
|
|
588
|
+
if remaining and len(parts) < 2:
|
|
589
|
+
parts.append(f"{remaining}s")
|
|
590
|
+
|
|
591
|
+
return " ".join(parts) if parts else "<1s"
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
async def _lazy_register_oauth_provider(
|
|
595
|
+
provider: str,
|
|
596
|
+
registry: OAuthRegistry,
|
|
597
|
+
container: ServiceContainer,
|
|
598
|
+
) -> Any | None:
|
|
599
|
+
"""Initialize filtered CLI plugin system and ensure provider is registered.
|
|
600
|
+
|
|
601
|
+
This bootstraps the hook system and initializes only CLI-safe plugins plus
|
|
602
|
+
the specific auth provider needed. This avoids DuckDB locks, task manager
|
|
603
|
+
errors, and other side effects from heavy provider plugins.
|
|
414
604
|
"""
|
|
605
|
+
settings = container.get_service(Settings)
|
|
606
|
+
|
|
607
|
+
# Respect global plugin enablement flag
|
|
608
|
+
if not getattr(settings, "enable_plugins", True):
|
|
609
|
+
return None
|
|
610
|
+
|
|
611
|
+
# Load only CLI-safe plugins + the specific auth provider needed
|
|
612
|
+
plugin_registry = load_cli_plugins(settings, auth_provider=provider)
|
|
613
|
+
|
|
614
|
+
# Create hook system for CLI HTTP flows
|
|
615
|
+
hook_registry = HookRegistry()
|
|
616
|
+
hook_manager = HookManager(hook_registry)
|
|
617
|
+
# Make HookManager available to any services resolved from the container
|
|
618
|
+
with contextlib.suppress(Exception):
|
|
619
|
+
container.register_service(HookManager, instance=hook_manager)
|
|
620
|
+
|
|
621
|
+
# Provide core services needed by plugins at runtime
|
|
622
|
+
|
|
623
|
+
try:
|
|
624
|
+
# Initialize all plugins; auth providers will register to oauth_registry
|
|
625
|
+
import asyncio as _asyncio
|
|
626
|
+
|
|
627
|
+
if _asyncio.get_event_loop().is_running():
|
|
628
|
+
# In practice, we're already in async context; just await directly
|
|
629
|
+
await plugin_registry.initialize_all(container)
|
|
630
|
+
else: # pragma: no cover - defensive path
|
|
631
|
+
_asyncio.run(plugin_registry.initialize_all(container))
|
|
632
|
+
except Exception as e:
|
|
633
|
+
logger.debug(
|
|
634
|
+
"plugin_initialization_failed_cli",
|
|
635
|
+
error=str(e),
|
|
636
|
+
exc_info=e,
|
|
637
|
+
category="auth",
|
|
638
|
+
)
|
|
639
|
+
|
|
640
|
+
# Normalize provider key and return the registered provider instance
|
|
641
|
+
def _norm(p: str) -> str:
|
|
642
|
+
key = p.strip().lower().replace("_", "-")
|
|
643
|
+
if key in {"claude", "claude-api"}:
|
|
644
|
+
return "claude-api"
|
|
645
|
+
if key in {"codex", "openai", "openai-api"}:
|
|
646
|
+
return "codex"
|
|
647
|
+
return key
|
|
648
|
+
|
|
649
|
+
try:
|
|
650
|
+
return registry.get(_norm(provider))
|
|
651
|
+
except Exception:
|
|
652
|
+
return None
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
async def discover_oauth_providers(
|
|
656
|
+
container: ServiceContainer,
|
|
657
|
+
) -> dict[str, tuple[str, str]]:
|
|
658
|
+
"""Return available OAuth providers discovered via the plugin loader."""
|
|
659
|
+
providers: dict[str, tuple[str, str]] = {}
|
|
660
|
+
try:
|
|
661
|
+
settings = container.get_service(Settings)
|
|
662
|
+
# For discovery, we can load all plugins temporarily since we don't initialize them
|
|
663
|
+
from ccproxy.core.plugins import load_plugin_system
|
|
664
|
+
|
|
665
|
+
registry, _ = load_plugin_system(settings)
|
|
666
|
+
for name, factory in registry.factories.items():
|
|
667
|
+
from ccproxy.core.plugins import AuthProviderPluginFactory
|
|
668
|
+
|
|
669
|
+
if isinstance(factory, AuthProviderPluginFactory):
|
|
670
|
+
if name == "oauth_claude":
|
|
671
|
+
providers["claude-api"] = ("oauth", "Claude API OAuth")
|
|
672
|
+
elif name == "oauth_codex":
|
|
673
|
+
providers["codex"] = ("oauth", "OpenAI Codex OAuth")
|
|
674
|
+
elif name == "copilot":
|
|
675
|
+
providers["copilot"] = ("oauth", "GitHub Copilot OAuth")
|
|
676
|
+
except Exception as e:
|
|
677
|
+
logger.debug("discover_oauth_providers_failed", error=str(e), exc_info=e)
|
|
678
|
+
return providers
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def get_oauth_provider_choices() -> list[str]:
|
|
682
|
+
"""Get list of available OAuth provider names for CLI choices."""
|
|
683
|
+
container = _get_service_container()
|
|
684
|
+
providers = asyncio.run(discover_oauth_providers(container))
|
|
685
|
+
return list(providers.keys())
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
async def get_oauth_client_for_provider(
|
|
689
|
+
provider: str,
|
|
690
|
+
registry: OAuthRegistry,
|
|
691
|
+
container: ServiceContainer,
|
|
692
|
+
) -> Any:
|
|
693
|
+
"""Get OAuth client for the specified provider."""
|
|
694
|
+
oauth_provider = await get_oauth_provider_for_name(provider, registry, container)
|
|
695
|
+
if not oauth_provider:
|
|
696
|
+
raise ValueError(f"Provider '{provider}' not found")
|
|
697
|
+
oauth_client = getattr(oauth_provider, "client", None)
|
|
698
|
+
if not oauth_client:
|
|
699
|
+
raise ValueError(f"Provider '{provider}' does not implement OAuth client")
|
|
700
|
+
return oauth_client
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
async def check_provider_credentials(
|
|
704
|
+
provider: str,
|
|
705
|
+
registry: OAuthRegistry,
|
|
706
|
+
container: ServiceContainer,
|
|
707
|
+
) -> dict[str, Any]:
|
|
708
|
+
"""Check if provider has valid stored credentials."""
|
|
709
|
+
try:
|
|
710
|
+
oauth_provider = await get_oauth_provider_for_name(
|
|
711
|
+
provider, registry, container
|
|
712
|
+
)
|
|
713
|
+
if not oauth_provider:
|
|
714
|
+
return {
|
|
715
|
+
"has_credentials": False,
|
|
716
|
+
"expired": True,
|
|
717
|
+
"path": None,
|
|
718
|
+
"credentials": None,
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
creds = await oauth_provider.load_credentials()
|
|
722
|
+
has_credentials = creds is not None
|
|
723
|
+
|
|
724
|
+
return {
|
|
725
|
+
"has_credentials": has_credentials,
|
|
726
|
+
"expired": not has_credentials,
|
|
727
|
+
"path": None,
|
|
728
|
+
"credentials": None,
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
except AttributeError as e:
|
|
732
|
+
logger.debug(
|
|
733
|
+
"credentials_check_missing_attribute",
|
|
734
|
+
provider=provider,
|
|
735
|
+
error=str(e),
|
|
736
|
+
exc_info=e,
|
|
737
|
+
)
|
|
738
|
+
return {
|
|
739
|
+
"has_credentials": False,
|
|
740
|
+
"expired": True,
|
|
741
|
+
"path": None,
|
|
742
|
+
"credentials": None,
|
|
743
|
+
}
|
|
744
|
+
except FileNotFoundError as e:
|
|
745
|
+
logger.debug(
|
|
746
|
+
"credentials_file_not_found", provider=provider, error=str(e), exc_info=e
|
|
747
|
+
)
|
|
748
|
+
return {
|
|
749
|
+
"has_credentials": False,
|
|
750
|
+
"expired": True,
|
|
751
|
+
"path": None,
|
|
752
|
+
"credentials": None,
|
|
753
|
+
}
|
|
754
|
+
except Exception as e:
|
|
755
|
+
logger.debug(
|
|
756
|
+
"credentials_check_failed", provider=provider, error=str(e), exc_info=e
|
|
757
|
+
)
|
|
758
|
+
return {
|
|
759
|
+
"has_credentials": False,
|
|
760
|
+
"expired": True,
|
|
761
|
+
"path": None,
|
|
762
|
+
"credentials": None,
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
@app.command(name="providers")
|
|
767
|
+
def list_providers() -> None:
|
|
768
|
+
"""List all available OAuth providers."""
|
|
769
|
+
_ensure_logging_configured()
|
|
415
770
|
toolkit = get_rich_toolkit()
|
|
416
|
-
toolkit.print("[bold cyan]
|
|
771
|
+
toolkit.print("[bold cyan]Available OAuth Providers[/bold cyan]", centered=True)
|
|
417
772
|
toolkit.print_line()
|
|
418
773
|
|
|
419
774
|
try:
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
if credential_file:
|
|
423
|
-
custom_paths = [Path(credential_file)]
|
|
424
|
-
elif docker:
|
|
425
|
-
custom_paths = get_docker_credential_paths()
|
|
426
|
-
|
|
427
|
-
# Check if already logged in
|
|
428
|
-
manager = get_credentials_manager(custom_paths)
|
|
429
|
-
validation_result = asyncio.run(manager.validate())
|
|
430
|
-
if validation_result.valid and not validation_result.expired:
|
|
431
|
-
console.print(
|
|
432
|
-
"[yellow]You are already logged in with valid credentials.[/yellow]"
|
|
433
|
-
)
|
|
434
|
-
console.print(
|
|
435
|
-
"Use [cyan]ccproxy auth info[/cyan] to view current credentials."
|
|
436
|
-
)
|
|
775
|
+
container = _get_service_container()
|
|
776
|
+
providers = asyncio.run(discover_oauth_providers(container))
|
|
437
777
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
if not overwrite:
|
|
442
|
-
console.print("Login cancelled.")
|
|
443
|
-
return
|
|
444
|
-
|
|
445
|
-
# Perform OAuth login
|
|
446
|
-
console.print("Starting OAuth login process...")
|
|
447
|
-
console.print("Your browser will open for authentication.")
|
|
448
|
-
console.print(
|
|
449
|
-
"A temporary server will start on port 54545 for the OAuth callback..."
|
|
450
|
-
)
|
|
778
|
+
if not providers:
|
|
779
|
+
toolkit.print("No OAuth providers found", tag="warning")
|
|
780
|
+
return
|
|
451
781
|
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
782
|
+
table = Table(
|
|
783
|
+
show_header=True,
|
|
784
|
+
header_style="bold cyan",
|
|
785
|
+
box=box.ROUNDED,
|
|
786
|
+
title="OAuth Providers",
|
|
787
|
+
title_style="bold white",
|
|
788
|
+
)
|
|
789
|
+
table.add_column("Provider", style="cyan")
|
|
790
|
+
table.add_column("Auth Type", style="white")
|
|
791
|
+
table.add_column("Description", style="dim")
|
|
458
792
|
|
|
459
|
-
|
|
460
|
-
|
|
793
|
+
for name, (auth_type, description) in providers.items():
|
|
794
|
+
table.add_row(name, auth_type, description)
|
|
461
795
|
|
|
462
|
-
|
|
463
|
-
console.print("\n[dim]Credential information:[/dim]")
|
|
464
|
-
updated_validation = asyncio.run(manager.validate())
|
|
465
|
-
if updated_validation.valid and updated_validation.credentials:
|
|
466
|
-
oauth_token = updated_validation.credentials.claude_ai_oauth
|
|
467
|
-
console.print(
|
|
468
|
-
f" Subscription: {oauth_token.subscription_type or 'Unknown'}"
|
|
469
|
-
)
|
|
470
|
-
if oauth_token.scopes:
|
|
471
|
-
console.print(f" Scopes: {', '.join(oauth_token.scopes)}")
|
|
472
|
-
exp_dt = oauth_token.expires_at_datetime
|
|
473
|
-
console.print(f" Expires: {exp_dt.strftime('%Y-%m-%d %H:%M:%S UTC')}")
|
|
474
|
-
else:
|
|
475
|
-
toolkit.print("Login failed. Please try again.", tag="error")
|
|
476
|
-
raise typer.Exit(1)
|
|
796
|
+
console.print(table)
|
|
477
797
|
|
|
478
|
-
except
|
|
479
|
-
|
|
480
|
-
raise typer.Exit(1) from
|
|
798
|
+
except ImportError as e:
|
|
799
|
+
toolkit.print(f"Plugin import error: {e}", tag="error")
|
|
800
|
+
raise typer.Exit(1) from e
|
|
801
|
+
except AttributeError as e:
|
|
802
|
+
toolkit.print(f"Plugin configuration error: {e}", tag="error")
|
|
803
|
+
raise typer.Exit(1) from e
|
|
481
804
|
except Exception as e:
|
|
482
|
-
toolkit.print(f"Error
|
|
805
|
+
toolkit.print(f"Error listing providers: {e}", tag="error")
|
|
483
806
|
raise typer.Exit(1) from e
|
|
484
807
|
|
|
485
808
|
|
|
486
|
-
@app.command()
|
|
487
|
-
def
|
|
488
|
-
|
|
809
|
+
@app.command(name="login")
|
|
810
|
+
def login_command(
|
|
811
|
+
provider: Annotated[
|
|
812
|
+
str,
|
|
813
|
+
typer.Argument(
|
|
814
|
+
help="Provider to authenticate with (claude-api, codex, copilot)"
|
|
815
|
+
),
|
|
816
|
+
],
|
|
817
|
+
no_browser: Annotated[
|
|
818
|
+
bool,
|
|
819
|
+
typer.Option("--no-browser", help="Don't automatically open browser for OAuth"),
|
|
820
|
+
] = False,
|
|
821
|
+
manual: Annotated[
|
|
489
822
|
bool,
|
|
490
823
|
typer.Option(
|
|
491
|
-
"--
|
|
492
|
-
"-d",
|
|
493
|
-
help="Renew credentials for Docker environment",
|
|
824
|
+
"--manual", "-m", help="Skip callback server and enter code manually"
|
|
494
825
|
),
|
|
495
826
|
] = False,
|
|
496
|
-
|
|
827
|
+
output_file: Annotated[
|
|
497
828
|
Path | None,
|
|
498
829
|
typer.Option(
|
|
499
|
-
"--
|
|
500
|
-
"
|
|
501
|
-
help="Path to custom credential file",
|
|
830
|
+
"--file",
|
|
831
|
+
help="Write credentials to this path instead of the default storage",
|
|
502
832
|
),
|
|
503
833
|
] = None,
|
|
834
|
+
force: Annotated[
|
|
835
|
+
bool,
|
|
836
|
+
typer.Option(
|
|
837
|
+
"--force",
|
|
838
|
+
help="Overwrite existing credential file when using --file",
|
|
839
|
+
),
|
|
840
|
+
] = False,
|
|
504
841
|
) -> None:
|
|
505
|
-
"""
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
842
|
+
"""Login to a provider using OAuth authentication."""
|
|
843
|
+
_ensure_logging_configured()
|
|
844
|
+
# Capture plugin-injected CLI args for potential use by auth providers
|
|
845
|
+
try:
|
|
846
|
+
from ccproxy.cli.helpers import get_plugin_cli_args
|
|
509
847
|
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
"""
|
|
848
|
+
_ = get_plugin_cli_args()
|
|
849
|
+
# Currently not used directly here, but available to providers
|
|
850
|
+
except Exception:
|
|
851
|
+
pass
|
|
515
852
|
toolkit = get_rich_toolkit()
|
|
516
|
-
toolkit.print("[bold cyan]Claude Credentials Renewal[/bold cyan]", centered=True)
|
|
517
|
-
toolkit.print_line()
|
|
518
853
|
|
|
519
|
-
|
|
854
|
+
if force and output_file is None:
|
|
855
|
+
toolkit.print("--force can only be used together with --file", tag="error")
|
|
856
|
+
raise typer.Exit(1)
|
|
520
857
|
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
# Create credentials manager
|
|
530
|
-
manager = get_credentials_manager(custom_paths)
|
|
531
|
-
|
|
532
|
-
# Check if credentials exist
|
|
533
|
-
validation_result = asyncio.run(manager.validate())
|
|
534
|
-
if not validation_result.valid:
|
|
535
|
-
toolkit.print("[red]✗[/red] No credentials found to renew", tag="error")
|
|
536
|
-
console.print("\n[dim]Please login first:[/dim]")
|
|
537
|
-
console.print("[cyan]ccproxy auth login[/cyan]")
|
|
538
|
-
raise typer.Exit(1)
|
|
858
|
+
custom_path: Path | None = None
|
|
859
|
+
if output_file is not None:
|
|
860
|
+
custom_path = output_file.expanduser()
|
|
861
|
+
try:
|
|
862
|
+
custom_path = custom_path.resolve()
|
|
863
|
+
except FileNotFoundError:
|
|
864
|
+
# Path.resolve() on some platforms raises when parents missing; fallback to absolute()
|
|
865
|
+
custom_path = custom_path.absolute()
|
|
539
866
|
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
867
|
+
if custom_path.exists() and custom_path.is_dir():
|
|
868
|
+
toolkit.print(
|
|
869
|
+
f"Target path '{custom_path}' is a directory. Provide a file path.",
|
|
870
|
+
tag="error",
|
|
871
|
+
)
|
|
872
|
+
raise typer.Exit(1)
|
|
543
873
|
|
|
544
|
-
if
|
|
874
|
+
if custom_path.exists() and not force:
|
|
545
875
|
toolkit.print(
|
|
546
|
-
"
|
|
876
|
+
f"Credential file '{custom_path}' already exists. Use --force to overwrite.",
|
|
877
|
+
tag="error",
|
|
547
878
|
)
|
|
879
|
+
raise typer.Exit(1)
|
|
548
880
|
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
f"
|
|
881
|
+
try:
|
|
882
|
+
custom_path.parent.mkdir(parents=True, exist_ok=True)
|
|
883
|
+
except Exception as exc:
|
|
884
|
+
toolkit.print(
|
|
885
|
+
f"Failed to create directory '{custom_path.parent}': {exc}",
|
|
886
|
+
tag="error",
|
|
554
887
|
)
|
|
555
|
-
if oauth_token.scopes:
|
|
556
|
-
console.print(f" Scopes: {', '.join(oauth_token.scopes)}")
|
|
557
|
-
exp_dt = oauth_token.expires_at_datetime
|
|
558
|
-
console.print(f" Expires: {exp_dt.strftime('%Y-%m-%d %H:%M:%S UTC')}")
|
|
559
|
-
else:
|
|
560
|
-
toolkit.print("[red]✗[/red] Failed to renew credentials", tag="error")
|
|
561
888
|
raise typer.Exit(1)
|
|
562
889
|
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
raise typer.Exit(1) from None
|
|
566
|
-
except Exception as e:
|
|
567
|
-
toolkit.print(f"Error during renewal: {e}", tag="error")
|
|
568
|
-
raise typer.Exit(1) from e
|
|
890
|
+
provider = provider.strip().lower()
|
|
891
|
+
display_name = provider.replace("_", "-").title()
|
|
569
892
|
|
|
893
|
+
toolkit.print(
|
|
894
|
+
f"[bold cyan]OAuth Login - {display_name}[/bold cyan]",
|
|
895
|
+
centered=True,
|
|
896
|
+
)
|
|
897
|
+
toolkit.print_line()
|
|
570
898
|
|
|
571
|
-
|
|
899
|
+
custom_path_str = str(custom_path) if custom_path else None
|
|
572
900
|
|
|
901
|
+
try:
|
|
902
|
+
container = _get_service_container()
|
|
903
|
+
registry = container.get_oauth_registry()
|
|
904
|
+
oauth_provider = asyncio.run(
|
|
905
|
+
get_oauth_provider_for_name(provider, registry, container)
|
|
906
|
+
)
|
|
573
907
|
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
908
|
+
if not oauth_provider:
|
|
909
|
+
providers = asyncio.run(discover_oauth_providers(container))
|
|
910
|
+
available = ", ".join(providers.keys()) if providers else "none"
|
|
911
|
+
toolkit.print(
|
|
912
|
+
f"Provider '{provider}' not found. Available: {available}",
|
|
913
|
+
tag="error",
|
|
914
|
+
)
|
|
915
|
+
raise typer.Exit(1)
|
|
577
916
|
|
|
578
|
-
|
|
917
|
+
# Get CLI configuration from provider
|
|
918
|
+
cli_config = oauth_provider.cli
|
|
579
919
|
|
|
920
|
+
# Flow engine selection with fallback logic
|
|
921
|
+
flow_engine: ManualCodeFlow | DeviceCodeFlow | BrowserFlow
|
|
922
|
+
try:
|
|
923
|
+
with _temporary_disable_provider_storage(
|
|
924
|
+
oauth_provider, disable=custom_path is not None
|
|
925
|
+
):
|
|
926
|
+
if manual:
|
|
927
|
+
# Manual mode requested
|
|
928
|
+
if not cli_config.supports_manual_code:
|
|
929
|
+
raise AuthProviderError(
|
|
930
|
+
f"Provider '{provider}' doesn't support manual code entry"
|
|
931
|
+
)
|
|
932
|
+
flow_engine = ManualCodeFlow()
|
|
933
|
+
success = asyncio.run(
|
|
934
|
+
flow_engine.run(oauth_provider, save_path=custom_path_str)
|
|
935
|
+
)
|
|
580
936
|
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
937
|
+
elif (
|
|
938
|
+
cli_config.preferred_flow == FlowType.device
|
|
939
|
+
and cli_config.supports_device_flow
|
|
940
|
+
):
|
|
941
|
+
# Device flow preferred and supported
|
|
942
|
+
flow_engine = DeviceCodeFlow()
|
|
943
|
+
success = asyncio.run(
|
|
944
|
+
flow_engine.run(oauth_provider, save_path=custom_path_str)
|
|
945
|
+
)
|
|
584
946
|
|
|
585
|
-
|
|
586
|
-
|
|
947
|
+
else:
|
|
948
|
+
# Browser flow (default)
|
|
949
|
+
flow_engine = BrowserFlow()
|
|
950
|
+
success = asyncio.run(
|
|
951
|
+
flow_engine.run(
|
|
952
|
+
oauth_provider,
|
|
953
|
+
no_browser=no_browser,
|
|
954
|
+
save_path=custom_path_str,
|
|
955
|
+
)
|
|
956
|
+
)
|
|
587
957
|
|
|
958
|
+
except PortBindError as e:
|
|
959
|
+
# Port binding failed - offer manual fallback
|
|
960
|
+
if cli_config.supports_manual_code:
|
|
961
|
+
console.print(
|
|
962
|
+
"[yellow]Port binding failed. Falling back to manual mode.[/yellow]"
|
|
963
|
+
)
|
|
964
|
+
with _temporary_disable_provider_storage(
|
|
965
|
+
oauth_provider, disable=custom_path is not None
|
|
966
|
+
):
|
|
967
|
+
flow_engine = ManualCodeFlow()
|
|
968
|
+
success = asyncio.run(
|
|
969
|
+
flow_engine.run(oauth_provider, save_path=custom_path_str)
|
|
970
|
+
)
|
|
971
|
+
else:
|
|
972
|
+
console.print(
|
|
973
|
+
f"[red]Port {cli_config.callback_port} unavailable and manual mode not supported[/red]"
|
|
974
|
+
)
|
|
975
|
+
raise typer.Exit(1) from e
|
|
588
976
|
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
bool,
|
|
593
|
-
typer.Option(
|
|
594
|
-
"--no-browser",
|
|
595
|
-
help="Don't automatically open browser for authentication",
|
|
596
|
-
),
|
|
597
|
-
] = False,
|
|
598
|
-
) -> None:
|
|
599
|
-
"""Login to OpenAI using OAuth authentication.
|
|
977
|
+
except AuthTimedOutError:
|
|
978
|
+
console.print("[red]Authentication timed out[/red]")
|
|
979
|
+
raise typer.Exit(1)
|
|
600
980
|
|
|
601
|
-
|
|
602
|
-
|
|
981
|
+
except AuthUserAbortedError:
|
|
982
|
+
console.print("[yellow]Authentication cancelled by user[/yellow]")
|
|
983
|
+
raise typer.Exit(1)
|
|
603
984
|
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
"""
|
|
608
|
-
import asyncio
|
|
985
|
+
except AuthProviderError as e:
|
|
986
|
+
console.print(f"[red]Authentication failed: {e}[/red]")
|
|
987
|
+
raise typer.Exit(1) from e
|
|
609
988
|
|
|
610
|
-
|
|
989
|
+
except NetworkError as e:
|
|
990
|
+
console.print(f"[red]Network error: {e}[/red]")
|
|
991
|
+
raise typer.Exit(1) from e
|
|
611
992
|
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
993
|
+
if success:
|
|
994
|
+
console.print("[green]✓[/green] Authentication successful!")
|
|
995
|
+
if custom_path:
|
|
996
|
+
console.print(
|
|
997
|
+
f"[dim]Credentials saved to {custom_path}[/dim]",
|
|
998
|
+
)
|
|
999
|
+
else:
|
|
1000
|
+
console.print("[red]✗[/red] Authentication failed")
|
|
1001
|
+
raise typer.Exit(1)
|
|
615
1002
|
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
1003
|
+
except KeyboardInterrupt:
|
|
1004
|
+
console.print("\n[yellow]Login cancelled by user.[/yellow]")
|
|
1005
|
+
raise typer.Exit(2) from None
|
|
1006
|
+
except ImportError as e:
|
|
1007
|
+
toolkit.print(f"Plugin import error: {e}", tag="error")
|
|
1008
|
+
raise typer.Exit(1) from e
|
|
1009
|
+
except typer.Exit:
|
|
1010
|
+
# Re-raise typer exits
|
|
1011
|
+
raise
|
|
1012
|
+
except Exception as e:
|
|
1013
|
+
toolkit.print(f"Error during login: {e}", tag="error")
|
|
1014
|
+
logger.error("login_command_error", error=str(e), exc_info=e)
|
|
1015
|
+
raise typer.Exit(1) from e
|
|
619
1016
|
|
|
620
|
-
# Check if already logged in
|
|
621
|
-
token_manager = get_openai_token_manager()
|
|
622
|
-
existing_creds = asyncio.run(token_manager.load_credentials())
|
|
623
1017
|
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
"Use [cyan]ccproxy auth openai-info[/cyan] to view current credentials."
|
|
630
|
-
)
|
|
1018
|
+
def _refresh_provider_tokens(provider: str, custom_path: Path | None = None) -> None:
|
|
1019
|
+
"""Shared implementation for refresh/renew commands."""
|
|
1020
|
+
toolkit = get_rich_toolkit()
|
|
1021
|
+
provider_key = provider.strip().lower()
|
|
1022
|
+
display_name = provider_key.replace("_", "-").title()
|
|
631
1023
|
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
return
|
|
1024
|
+
toolkit.print(
|
|
1025
|
+
f"[bold cyan]{display_name} Token Refresh[/bold cyan]",
|
|
1026
|
+
centered=True,
|
|
1027
|
+
)
|
|
1028
|
+
toolkit.print_line()
|
|
638
1029
|
|
|
639
|
-
|
|
640
|
-
|
|
1030
|
+
credential_path = _normalize_credentials_file_option(
|
|
1031
|
+
toolkit, custom_path, require_exists=True
|
|
1032
|
+
)
|
|
1033
|
+
load_kwargs: dict[str, Any] = {}
|
|
1034
|
+
save_kwargs: dict[str, Any] = {}
|
|
1035
|
+
if credential_path is not None:
|
|
1036
|
+
load_kwargs["custom_path"] = credential_path
|
|
1037
|
+
save_kwargs["custom_path"] = credential_path
|
|
641
1038
|
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
1039
|
+
try:
|
|
1040
|
+
container = _get_service_container()
|
|
1041
|
+
registry = container.get_oauth_registry()
|
|
1042
|
+
oauth_provider = asyncio.run(
|
|
1043
|
+
get_oauth_provider_for_name(provider_key, registry, container)
|
|
645
1044
|
)
|
|
646
1045
|
|
|
647
|
-
if
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
credentials = asyncio.run(
|
|
654
|
-
oauth_client.authenticate(open_browser=not no_browser)
|
|
1046
|
+
if not oauth_provider:
|
|
1047
|
+
providers = asyncio.run(discover_oauth_providers(container))
|
|
1048
|
+
available = ", ".join(providers.keys()) if providers else "none"
|
|
1049
|
+
toolkit.print(
|
|
1050
|
+
f"Provider '{provider_key}' not found. Available: {available}",
|
|
1051
|
+
tag="error",
|
|
655
1052
|
)
|
|
1053
|
+
raise typer.Exit(1)
|
|
656
1054
|
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
console.print(f" Account ID: {credentials.account_id}")
|
|
662
|
-
console.print(
|
|
663
|
-
f" Expires: {credentials.expires_at.strftime('%Y-%m-%d %H:%M:%S UTC')}"
|
|
1055
|
+
if not bool(getattr(oauth_provider, "supports_refresh", False)):
|
|
1056
|
+
toolkit.print(
|
|
1057
|
+
f"Provider '{provider_key}' does not support token refresh.",
|
|
1058
|
+
tag="warning",
|
|
664
1059
|
)
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
except Exception as e:
|
|
668
|
-
logger.error(f"OpenAI login failed: {e}")
|
|
669
|
-
toolkit.print(f"Login failed: {e}", tag="error")
|
|
670
|
-
raise typer.Exit(1) from e
|
|
1060
|
+
raise typer.Exit(1)
|
|
671
1061
|
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
1062
|
+
credentials = asyncio.run(oauth_provider.load_credentials(**load_kwargs))
|
|
1063
|
+
if not credentials:
|
|
1064
|
+
toolkit.print(
|
|
1065
|
+
(
|
|
1066
|
+
f"No credentials found at '{credential_path}'."
|
|
1067
|
+
if credential_path
|
|
1068
|
+
else "No credentials found. Run 'ccproxy auth login' first."
|
|
1069
|
+
),
|
|
1070
|
+
tag="warning",
|
|
1071
|
+
)
|
|
1072
|
+
raise typer.Exit(1)
|
|
678
1073
|
|
|
1074
|
+
snapshot = _token_snapshot_from_credentials(credentials, provider_key)
|
|
679
1075
|
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
1076
|
+
manager = None
|
|
1077
|
+
if credential_path is None:
|
|
1078
|
+
manager = _resolve_token_manager(provider_key, oauth_provider, container)
|
|
683
1079
|
|
|
684
|
-
|
|
685
|
-
|
|
1080
|
+
refreshed_credentials: Any | None = None
|
|
1081
|
+
try:
|
|
1082
|
+
if (
|
|
1083
|
+
credential_path is None
|
|
1084
|
+
and manager
|
|
1085
|
+
and hasattr(manager, "refresh_token")
|
|
1086
|
+
):
|
|
1087
|
+
refreshed_credentials = asyncio.run(manager.refresh_token())
|
|
1088
|
+
else:
|
|
1089
|
+
refresh_token = snapshot.refresh_token if snapshot else None
|
|
1090
|
+
if not refresh_token:
|
|
1091
|
+
toolkit.print(
|
|
1092
|
+
"Stored credentials do not include a refresh token; "
|
|
1093
|
+
"re-authentication is required.",
|
|
1094
|
+
tag="warning",
|
|
1095
|
+
)
|
|
1096
|
+
raise typer.Exit(1)
|
|
686
1097
|
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
1098
|
+
with _temporary_disable_provider_storage(
|
|
1099
|
+
oauth_provider, disable=credential_path is not None
|
|
1100
|
+
):
|
|
1101
|
+
refreshed_credentials = asyncio.run(
|
|
1102
|
+
oauth_provider.refresh_access_token(refresh_token)
|
|
1103
|
+
)
|
|
1104
|
+
if credential_path and refreshed_credentials:
|
|
1105
|
+
saved = asyncio.run(
|
|
1106
|
+
oauth_provider.save_credentials(
|
|
1107
|
+
refreshed_credentials, **save_kwargs
|
|
1108
|
+
)
|
|
1109
|
+
)
|
|
1110
|
+
if not saved:
|
|
1111
|
+
toolkit.print(
|
|
1112
|
+
f"Refreshed credentials could not be saved to '{credential_path}'.",
|
|
1113
|
+
tag="warning",
|
|
1114
|
+
)
|
|
1115
|
+
except Exception as exc:
|
|
1116
|
+
toolkit.print(f"Token refresh failed: {exc}", tag="error")
|
|
1117
|
+
logger.error(
|
|
1118
|
+
"token_refresh_failed",
|
|
1119
|
+
provider=provider_key,
|
|
1120
|
+
error=str(exc),
|
|
1121
|
+
exc_info=exc,
|
|
1122
|
+
)
|
|
1123
|
+
raise typer.Exit(1) from exc
|
|
691
1124
|
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
1125
|
+
if refreshed_credentials is None:
|
|
1126
|
+
with contextlib.suppress(Exception):
|
|
1127
|
+
refreshed_credentials = asyncio.run(
|
|
1128
|
+
oauth_provider.load_credentials(**load_kwargs)
|
|
1129
|
+
)
|
|
1130
|
+
if (
|
|
1131
|
+
not refreshed_credentials
|
|
1132
|
+
and manager
|
|
1133
|
+
and hasattr(manager, "load_credentials")
|
|
1134
|
+
):
|
|
1135
|
+
with contextlib.suppress(Exception):
|
|
1136
|
+
refreshed_credentials = asyncio.run(manager.load_credentials())
|
|
1137
|
+
|
|
1138
|
+
refreshed_snapshot = None
|
|
1139
|
+
if refreshed_credentials:
|
|
1140
|
+
refreshed_snapshot = _token_snapshot_from_credentials(
|
|
1141
|
+
refreshed_credentials, provider_key
|
|
1142
|
+
)
|
|
695
1143
|
|
|
696
|
-
|
|
697
|
-
|
|
1144
|
+
if not refreshed_snapshot and snapshot:
|
|
1145
|
+
refreshed_snapshot = snapshot
|
|
698
1146
|
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
"
|
|
1147
|
+
if not refreshed_snapshot:
|
|
1148
|
+
toolkit.print(
|
|
1149
|
+
"Token refresh completed but updated credentials could not be loaded. "
|
|
1150
|
+
"Check logs for details.",
|
|
1151
|
+
tag="warning",
|
|
704
1152
|
)
|
|
705
1153
|
return
|
|
706
1154
|
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
1155
|
+
account_display = refreshed_snapshot.account_id or "—"
|
|
1156
|
+
expires_at = (
|
|
1157
|
+
refreshed_snapshot.expires_at.isoformat()
|
|
1158
|
+
if refreshed_snapshot.expires_at
|
|
1159
|
+
else "Unknown"
|
|
1160
|
+
)
|
|
1161
|
+
expires_in = _format_seconds(refreshed_snapshot.expires_in_seconds())
|
|
1162
|
+
access_preview = refreshed_snapshot.access_token_preview() or "(hidden)"
|
|
1163
|
+
refresh_preview = (
|
|
1164
|
+
refreshed_snapshot.refresh_token_preview()
|
|
1165
|
+
if refreshed_snapshot.refresh_token
|
|
1166
|
+
else None
|
|
710
1167
|
)
|
|
711
|
-
if not confirm:
|
|
712
|
-
console.print("Logout cancelled.")
|
|
713
|
-
return
|
|
714
|
-
|
|
715
|
-
# Delete credentials
|
|
716
|
-
success = asyncio.run(token_manager.delete_credentials())
|
|
717
1168
|
|
|
718
|
-
|
|
719
|
-
toolkit.print("Successfully logged out from OpenAI!", tag="success")
|
|
720
|
-
console.print("OpenAI credentials have been removed.")
|
|
721
|
-
else:
|
|
722
|
-
toolkit.print("Failed to remove OpenAI credentials", tag="error")
|
|
723
|
-
raise typer.Exit(1)
|
|
1169
|
+
toolkit.print("Tokens refreshed successfully", tag="success")
|
|
724
1170
|
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
1171
|
+
summary = Table(show_header=False, box=box.SIMPLE)
|
|
1172
|
+
summary.add_column("Field", style="bold")
|
|
1173
|
+
summary.add_column("Value")
|
|
1174
|
+
summary.add_row("Account", account_display)
|
|
1175
|
+
summary.add_row("Expires At", expires_at)
|
|
1176
|
+
summary.add_row("Expires In", expires_in)
|
|
1177
|
+
summary.add_row("Access Token", access_preview)
|
|
1178
|
+
if refresh_preview:
|
|
1179
|
+
summary.add_row("Refresh Token", refresh_preview)
|
|
1180
|
+
if refreshed_snapshot.scopes:
|
|
1181
|
+
summary.add_row("Scopes", ", ".join(refreshed_snapshot.scopes))
|
|
728
1182
|
|
|
1183
|
+
console.print(summary)
|
|
729
1184
|
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
1185
|
+
except typer.Exit:
|
|
1186
|
+
raise
|
|
1187
|
+
except Exception as exc:
|
|
1188
|
+
toolkit.print(f"Unexpected error during refresh: {exc}", tag="error")
|
|
1189
|
+
logger.error(
|
|
1190
|
+
"refresh_command_error", provider=provider_key, error=str(exc), exc_info=exc
|
|
1191
|
+
)
|
|
1192
|
+
raise typer.Exit(1) from exc
|
|
733
1193
|
|
|
734
|
-
Shows detailed information about the current OpenAI credentials including
|
|
735
|
-
account ID, token expiration, and storage location.
|
|
736
1194
|
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
1195
|
+
@app.command(name="refresh")
|
|
1196
|
+
def refresh_command(
|
|
1197
|
+
provider: Annotated[
|
|
1198
|
+
str,
|
|
1199
|
+
typer.Argument(help="Provider to refresh (claude-api, codex, copilot)"),
|
|
1200
|
+
],
|
|
1201
|
+
credential_file: Annotated[
|
|
1202
|
+
Path | None,
|
|
1203
|
+
typer.Option(
|
|
1204
|
+
"--file",
|
|
1205
|
+
help=(
|
|
1206
|
+
"Refresh credentials stored at this path instead of the default storage"
|
|
1207
|
+
),
|
|
1208
|
+
),
|
|
1209
|
+
] = None,
|
|
1210
|
+
) -> None:
|
|
1211
|
+
"""Refresh stored credentials using the provider's refresh token."""
|
|
1212
|
+
_ensure_logging_configured()
|
|
1213
|
+
_refresh_provider_tokens(provider, credential_file)
|
|
744
1214
|
|
|
745
|
-
from rich import box
|
|
746
|
-
from rich.table import Table
|
|
747
1215
|
|
|
1216
|
+
@app.command(name="renew")
|
|
1217
|
+
def renew_command(
|
|
1218
|
+
provider: Annotated[
|
|
1219
|
+
str,
|
|
1220
|
+
typer.Argument(help="Alias for refresh command"),
|
|
1221
|
+
],
|
|
1222
|
+
credential_file: Annotated[
|
|
1223
|
+
Path | None,
|
|
1224
|
+
typer.Option(
|
|
1225
|
+
"--file",
|
|
1226
|
+
help=(
|
|
1227
|
+
"Refresh credentials stored at this path instead of the default storage"
|
|
1228
|
+
),
|
|
1229
|
+
),
|
|
1230
|
+
] = None,
|
|
1231
|
+
) -> None:
|
|
1232
|
+
"""Alias for refresh."""
|
|
1233
|
+
_ensure_logging_configured()
|
|
1234
|
+
_refresh_provider_tokens(provider, credential_file)
|
|
1235
|
+
|
|
1236
|
+
|
|
1237
|
+
@app.command(name="status")
|
|
1238
|
+
def status_command(
|
|
1239
|
+
provider: Annotated[
|
|
1240
|
+
str,
|
|
1241
|
+
typer.Argument(help="Provider to check status (claude-api, codex)"),
|
|
1242
|
+
],
|
|
1243
|
+
detailed: Annotated[
|
|
1244
|
+
bool,
|
|
1245
|
+
typer.Option("--detailed", "-d", help="Show detailed credential information"),
|
|
1246
|
+
] = False,
|
|
1247
|
+
credential_file: Annotated[
|
|
1248
|
+
Path | None,
|
|
1249
|
+
typer.Option(
|
|
1250
|
+
"--file",
|
|
1251
|
+
help=("Read credentials from this path instead of the default storage"),
|
|
1252
|
+
),
|
|
1253
|
+
] = None,
|
|
1254
|
+
) -> None:
|
|
1255
|
+
"""Check authentication status and info for specified provider."""
|
|
1256
|
+
_ensure_logging_configured()
|
|
748
1257
|
toolkit = get_rich_toolkit()
|
|
749
|
-
|
|
1258
|
+
|
|
1259
|
+
credential_path = _normalize_credentials_file_option(
|
|
1260
|
+
toolkit, credential_file, require_exists=False
|
|
1261
|
+
)
|
|
1262
|
+
credential_missing = bool(credential_path and not credential_path.exists())
|
|
1263
|
+
load_kwargs: dict[str, Any] = {}
|
|
1264
|
+
if credential_path is not None:
|
|
1265
|
+
load_kwargs["custom_path"] = credential_path
|
|
1266
|
+
|
|
1267
|
+
provider = provider.strip().lower()
|
|
1268
|
+
display_name = provider.replace("_", "-").title()
|
|
1269
|
+
|
|
1270
|
+
toolkit.print(
|
|
1271
|
+
f"[bold cyan]{display_name} Authentication Status[/bold cyan]",
|
|
1272
|
+
centered=True,
|
|
1273
|
+
)
|
|
750
1274
|
toolkit.print_line()
|
|
751
1275
|
|
|
752
1276
|
try:
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
1277
|
+
container = _get_service_container()
|
|
1278
|
+
registry = container.get_oauth_registry()
|
|
1279
|
+
oauth_provider = asyncio.run(
|
|
1280
|
+
get_oauth_provider_for_name(provider, registry, container)
|
|
1281
|
+
)
|
|
1282
|
+
if not oauth_provider:
|
|
1283
|
+
providers = asyncio.run(discover_oauth_providers(container))
|
|
1284
|
+
available = ", ".join(providers.keys()) if providers else "none"
|
|
1285
|
+
expected = _expected_plugin_class_name(provider)
|
|
1286
|
+
toolkit.print(
|
|
1287
|
+
f"Provider '{provider}' not found. Available: {available}. Expected plugin class '{expected}'.",
|
|
1288
|
+
tag="error",
|
|
1289
|
+
)
|
|
763
1290
|
raise typer.Exit(1)
|
|
764
1291
|
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
if credentials.access_token:
|
|
769
|
-
try:
|
|
770
|
-
# Split JWT into parts
|
|
771
|
-
parts = credentials.access_token.split(".")
|
|
772
|
-
if len(parts) == 3:
|
|
773
|
-
# Decode header and payload (add padding if needed)
|
|
774
|
-
header_b64 = parts[0] + "=" * (4 - len(parts[0]) % 4)
|
|
775
|
-
payload_b64 = parts[1] + "=" * (4 - len(parts[1]) % 4)
|
|
776
|
-
|
|
777
|
-
jwt_header = json.loads(base64.urlsafe_b64decode(header_b64))
|
|
778
|
-
jwt_payload = json.loads(base64.urlsafe_b64decode(payload_b64))
|
|
779
|
-
except Exception as decode_error:
|
|
780
|
-
logger.debug(f"Failed to decode JWT token: {decode_error}")
|
|
781
|
-
|
|
782
|
-
# Display account section
|
|
783
|
-
console.print("\n[bold]OpenAI Account[/bold]")
|
|
784
|
-
console.print(f" L Account ID: {credentials.account_id}")
|
|
785
|
-
console.print(f" L Status: {'Active' if credentials.active else 'Inactive'}")
|
|
786
|
-
|
|
787
|
-
# Extract additional info from JWT payload
|
|
788
|
-
if jwt_payload:
|
|
789
|
-
# Get OpenAI auth info from the JWT
|
|
790
|
-
openai_auth = jwt_payload.get("https://api.openai.com/auth", {})
|
|
791
|
-
if openai_auth:
|
|
792
|
-
if "email" in jwt_payload:
|
|
793
|
-
console.print(f" L Email: {jwt_payload['email']}")
|
|
794
|
-
if jwt_payload.get("email_verified"):
|
|
795
|
-
console.print(" L Email Verified: Yes")
|
|
796
|
-
|
|
797
|
-
if openai_auth.get("chatgpt_plan_type"):
|
|
798
|
-
console.print(
|
|
799
|
-
f" L Plan Type: {openai_auth['chatgpt_plan_type'].upper()}"
|
|
800
|
-
)
|
|
1292
|
+
profile_info = None
|
|
1293
|
+
credentials = None
|
|
1294
|
+
snapshot: TokenSnapshot | None = None
|
|
801
1295
|
|
|
802
|
-
|
|
803
|
-
|
|
1296
|
+
if oauth_provider:
|
|
1297
|
+
try:
|
|
1298
|
+
# Delegate to provider; providers may internally use their managers
|
|
1299
|
+
credentials = asyncio.run(
|
|
1300
|
+
oauth_provider.load_credentials(**load_kwargs)
|
|
1301
|
+
)
|
|
804
1302
|
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
)
|
|
810
|
-
if openai_auth.get("chatgpt_subscription_active_until"):
|
|
811
|
-
console.print(
|
|
812
|
-
f" L Subscription Until: {openai_auth['chatgpt_subscription_active_until']}"
|
|
1303
|
+
if credential_missing and not credentials:
|
|
1304
|
+
toolkit.print(
|
|
1305
|
+
f"Credential file '{credential_path}' not found.",
|
|
1306
|
+
tag="warning",
|
|
813
1307
|
)
|
|
814
1308
|
|
|
815
|
-
#
|
|
816
|
-
|
|
817
|
-
if
|
|
818
|
-
|
|
819
|
-
if
|
|
820
|
-
|
|
821
|
-
|
|
1309
|
+
# Optionally obtain a token manager via provider API (if exposed)
|
|
1310
|
+
manager = None
|
|
1311
|
+
if credential_path is None:
|
|
1312
|
+
try:
|
|
1313
|
+
if hasattr(oauth_provider, "create_token_manager"):
|
|
1314
|
+
manager = asyncio.run(oauth_provider.create_token_manager())
|
|
1315
|
+
elif hasattr(oauth_provider, "get_token_manager"):
|
|
1316
|
+
mgr = oauth_provider.get_token_manager() # may be sync
|
|
1317
|
+
# If coroutine, run it; else use directly
|
|
1318
|
+
if hasattr(mgr, "__await__"):
|
|
1319
|
+
manager = asyncio.run(mgr)
|
|
1320
|
+
else:
|
|
1321
|
+
manager = mgr
|
|
1322
|
+
except Exception as e:
|
|
1323
|
+
logger.debug("token_manager_unavailable", error=str(e))
|
|
1324
|
+
|
|
1325
|
+
if manager and hasattr(manager, "get_token_snapshot"):
|
|
1326
|
+
with contextlib.suppress(Exception):
|
|
1327
|
+
result = manager.get_token_snapshot()
|
|
1328
|
+
if asyncio.iscoroutine(result):
|
|
1329
|
+
snapshot = asyncio.run(result)
|
|
1330
|
+
else:
|
|
1331
|
+
snapshot = cast(TokenSnapshot | None, result)
|
|
1332
|
+
|
|
1333
|
+
if not snapshot and credentials:
|
|
1334
|
+
snapshot = _token_snapshot_from_credentials(credentials, provider)
|
|
1335
|
+
|
|
1336
|
+
if credentials:
|
|
1337
|
+
if provider == "codex":
|
|
1338
|
+
standard_profile = None
|
|
1339
|
+
if hasattr(oauth_provider, "get_standard_profile"):
|
|
1340
|
+
with contextlib.suppress(Exception):
|
|
1341
|
+
standard_profile = asyncio.run(
|
|
1342
|
+
oauth_provider.get_standard_profile(credentials)
|
|
1343
|
+
)
|
|
1344
|
+
if not standard_profile and hasattr(
|
|
1345
|
+
oauth_provider,
|
|
1346
|
+
"_extract_standard_profile",
|
|
1347
|
+
):
|
|
1348
|
+
with contextlib.suppress(Exception):
|
|
1349
|
+
standard_profile = (
|
|
1350
|
+
oauth_provider._extract_standard_profile(
|
|
1351
|
+
credentials
|
|
1352
|
+
)
|
|
1353
|
+
)
|
|
1354
|
+
if standard_profile is not None:
|
|
1355
|
+
try:
|
|
1356
|
+
profile_info = standard_profile.model_dump(
|
|
1357
|
+
exclude={"raw_profile_data"}
|
|
1358
|
+
)
|
|
1359
|
+
except Exception:
|
|
1360
|
+
profile_info = {
|
|
1361
|
+
"provider": provider,
|
|
1362
|
+
"authenticated": True,
|
|
1363
|
+
}
|
|
1364
|
+
else:
|
|
1365
|
+
profile_info = {"provider": provider, "authenticated": True}
|
|
1366
|
+
else:
|
|
1367
|
+
quick = None
|
|
1368
|
+
# Prefer provider-supplied quick profile methods if available
|
|
1369
|
+
if credential_path is None and hasattr(
|
|
1370
|
+
oauth_provider, "get_unified_profile_quick"
|
|
1371
|
+
):
|
|
1372
|
+
with contextlib.suppress(Exception):
|
|
1373
|
+
quick = asyncio.run(
|
|
1374
|
+
oauth_provider.get_unified_profile_quick()
|
|
1375
|
+
)
|
|
1376
|
+
if (
|
|
1377
|
+
credential_path is None
|
|
1378
|
+
and (not quick or quick == {})
|
|
1379
|
+
and hasattr(oauth_provider, "get_unified_profile")
|
|
1380
|
+
):
|
|
1381
|
+
with contextlib.suppress(Exception):
|
|
1382
|
+
quick = asyncio.run(
|
|
1383
|
+
oauth_provider.get_unified_profile()
|
|
1384
|
+
)
|
|
1385
|
+
if quick and isinstance(quick, dict) and quick != {}:
|
|
1386
|
+
profile_info = quick
|
|
1387
|
+
try:
|
|
1388
|
+
prov = (
|
|
1389
|
+
profile_info.get("provider_type")
|
|
1390
|
+
or profile_info.get("provider")
|
|
1391
|
+
or ""
|
|
1392
|
+
).lower()
|
|
1393
|
+
extras = (
|
|
1394
|
+
profile_info.get("extras")
|
|
1395
|
+
if isinstance(profile_info.get("extras"), dict)
|
|
1396
|
+
else None
|
|
1397
|
+
)
|
|
1398
|
+
if (
|
|
1399
|
+
prov in {"claude-api", "claude_api", "claude"}
|
|
1400
|
+
and extras
|
|
1401
|
+
):
|
|
1402
|
+
account = (
|
|
1403
|
+
extras.get("account", {})
|
|
1404
|
+
if isinstance(extras.get("account"), dict)
|
|
1405
|
+
else {}
|
|
1406
|
+
)
|
|
1407
|
+
org = (
|
|
1408
|
+
extras.get("organization", {})
|
|
1409
|
+
if isinstance(extras.get("organization"), dict)
|
|
1410
|
+
else {}
|
|
1411
|
+
)
|
|
1412
|
+
if account.get("has_claude_max") is True:
|
|
1413
|
+
profile_info["subscription_type"] = "max"
|
|
1414
|
+
profile_info["subscription_status"] = "active"
|
|
1415
|
+
elif account.get("has_claude_pro") is True:
|
|
1416
|
+
profile_info["subscription_type"] = "pro"
|
|
1417
|
+
profile_info["subscription_status"] = "active"
|
|
1418
|
+
features = {}
|
|
1419
|
+
if isinstance(account.get("has_claude_max"), bool):
|
|
1420
|
+
features["claude_max"] = account.get(
|
|
1421
|
+
"has_claude_max"
|
|
1422
|
+
)
|
|
1423
|
+
if isinstance(account.get("has_claude_pro"), bool):
|
|
1424
|
+
features["claude_pro"] = account.get(
|
|
1425
|
+
"has_claude_pro"
|
|
1426
|
+
)
|
|
1427
|
+
if features:
|
|
1428
|
+
profile_info["features"] = {
|
|
1429
|
+
**features,
|
|
1430
|
+
**(profile_info.get("features") or {}),
|
|
1431
|
+
}
|
|
1432
|
+
if org.get("name") and not profile_info.get(
|
|
1433
|
+
"organization_name"
|
|
1434
|
+
):
|
|
1435
|
+
profile_info["organization_name"] = org.get(
|
|
1436
|
+
"name"
|
|
1437
|
+
)
|
|
1438
|
+
if not profile_info.get("organization_role"):
|
|
1439
|
+
profile_info["organization_role"] = "member"
|
|
1440
|
+
except Exception:
|
|
1441
|
+
pass
|
|
1442
|
+
else:
|
|
1443
|
+
standard_profile = None
|
|
1444
|
+
if hasattr(oauth_provider, "get_standard_profile"):
|
|
1445
|
+
with contextlib.suppress(Exception):
|
|
1446
|
+
standard_profile = asyncio.run(
|
|
1447
|
+
oauth_provider.get_standard_profile(credentials)
|
|
1448
|
+
)
|
|
1449
|
+
if standard_profile is not None:
|
|
1450
|
+
try:
|
|
1451
|
+
profile_info = standard_profile.model_dump(
|
|
1452
|
+
exclude={"raw_profile_data"}
|
|
1453
|
+
)
|
|
1454
|
+
except Exception:
|
|
1455
|
+
profile_info = {
|
|
1456
|
+
"provider": provider,
|
|
1457
|
+
"authenticated": True,
|
|
1458
|
+
}
|
|
1459
|
+
else:
|
|
1460
|
+
profile_info = {
|
|
1461
|
+
"provider": provider,
|
|
1462
|
+
"authenticated": True,
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
if profile_info is not None and "provider" not in profile_info:
|
|
1466
|
+
profile_info["provider"] = provider
|
|
1467
|
+
|
|
1468
|
+
try:
|
|
1469
|
+
prov_dbg = (
|
|
1470
|
+
profile_info.get("provider_type")
|
|
1471
|
+
or profile_info.get("provider")
|
|
1472
|
+
or ""
|
|
1473
|
+
).lower()
|
|
1474
|
+
missing = []
|
|
1475
|
+
for f in (
|
|
1476
|
+
"subscription_type",
|
|
1477
|
+
"organization_name",
|
|
1478
|
+
"display_name",
|
|
1479
|
+
):
|
|
1480
|
+
if not profile_info.get(f):
|
|
1481
|
+
missing.append(f)
|
|
1482
|
+
if missing:
|
|
1483
|
+
reasons: list[str] = []
|
|
1484
|
+
qextra = (
|
|
1485
|
+
quick.get("extras") if isinstance(quick, dict) else None
|
|
822
1486
|
)
|
|
823
|
-
|
|
1487
|
+
if prov_dbg in {"codex", "openai"}:
|
|
1488
|
+
auth_claims = None
|
|
1489
|
+
if isinstance(qextra, dict):
|
|
1490
|
+
auth_claims = qextra.get(
|
|
1491
|
+
"https://api.openai.com/auth"
|
|
1492
|
+
)
|
|
1493
|
+
if not auth_claims:
|
|
1494
|
+
reasons.append("missing_openai_auth_claims")
|
|
1495
|
+
else:
|
|
1496
|
+
if "chatgpt_plan_type" not in auth_claims:
|
|
1497
|
+
reasons.append("plan_type_not_in_claims")
|
|
1498
|
+
orgs = (
|
|
1499
|
+
auth_claims.get("organizations")
|
|
1500
|
+
if isinstance(auth_claims, dict)
|
|
1501
|
+
else None
|
|
1502
|
+
)
|
|
1503
|
+
if not orgs:
|
|
1504
|
+
reasons.append("no_organizations_in_claims")
|
|
1505
|
+
has_id_token = bool(
|
|
1506
|
+
snapshot and snapshot.extras.get("id_token_present")
|
|
1507
|
+
)
|
|
1508
|
+
if not has_id_token:
|
|
1509
|
+
reasons.append("no_id_token_available")
|
|
1510
|
+
elif prov_dbg in {"claude", "claude-api", "claude_api"}:
|
|
1511
|
+
if not (
|
|
1512
|
+
isinstance(qextra, dict) and qextra.get("account")
|
|
1513
|
+
):
|
|
1514
|
+
reasons.append("missing_claude_account_extras")
|
|
1515
|
+
if reasons:
|
|
1516
|
+
logger.debug(
|
|
1517
|
+
"profile_fields_missing",
|
|
1518
|
+
provider=prov_dbg,
|
|
1519
|
+
missing_fields=missing,
|
|
1520
|
+
reasons=reasons,
|
|
1521
|
+
)
|
|
1522
|
+
except Exception:
|
|
1523
|
+
pass
|
|
824
1524
|
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
table = Table(
|
|
828
|
-
show_header=True,
|
|
829
|
-
header_style="bold cyan",
|
|
830
|
-
box=box.ROUNDED,
|
|
831
|
-
title="Token Details",
|
|
832
|
-
title_style="bold white",
|
|
833
|
-
)
|
|
834
|
-
table.add_column("Property", style="cyan")
|
|
835
|
-
table.add_column("Value", style="white")
|
|
836
|
-
|
|
837
|
-
# File location
|
|
838
|
-
storage_location = token_manager.storage.get_location()
|
|
839
|
-
table.add_row("Storage Location", storage_location)
|
|
840
|
-
|
|
841
|
-
# Token algorithm and type from JWT header
|
|
842
|
-
if jwt_header:
|
|
843
|
-
table.add_row("Algorithm", jwt_header.get("alg", "Unknown"))
|
|
844
|
-
table.add_row("Token Type", jwt_header.get("typ", "Unknown"))
|
|
845
|
-
if jwt_header.get("kid"):
|
|
846
|
-
table.add_row("Key ID", jwt_header["kid"])
|
|
847
|
-
|
|
848
|
-
# Token status
|
|
849
|
-
table.add_row(
|
|
850
|
-
"Token Expired",
|
|
851
|
-
"[red]Yes[/red]" if credentials.is_expired() else "[green]No[/green]",
|
|
852
|
-
)
|
|
1525
|
+
except Exception as e:
|
|
1526
|
+
logger.debug(f"{provider}_status_error", error=str(e), exc_info=e)
|
|
853
1527
|
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
table.add_row("Issued At", iat_dt.strftime("%Y-%m-%d %H:%M:%S UTC"))
|
|
876
|
-
|
|
877
|
-
if "auth_time" in jwt_payload:
|
|
878
|
-
auth_dt = datetime.fromtimestamp(jwt_payload["auth_time"], tz=UTC)
|
|
879
|
-
table.add_row("Auth Time", auth_dt.strftime("%Y-%m-%d %H:%M:%S UTC"))
|
|
880
|
-
|
|
881
|
-
# JWT issuer and audience
|
|
882
|
-
if jwt_payload:
|
|
883
|
-
if "iss" in jwt_payload:
|
|
884
|
-
table.add_row("Issuer", jwt_payload["iss"])
|
|
885
|
-
if "aud" in jwt_payload:
|
|
886
|
-
audience = jwt_payload["aud"]
|
|
887
|
-
if isinstance(audience, list):
|
|
888
|
-
audience = ", ".join(audience)
|
|
889
|
-
table.add_row("Audience", audience)
|
|
890
|
-
if "jti" in jwt_payload:
|
|
891
|
-
table.add_row("JWT ID", jwt_payload["jti"])
|
|
892
|
-
if "sid" in jwt_payload:
|
|
893
|
-
table.add_row("Session ID", jwt_payload["sid"])
|
|
894
|
-
|
|
895
|
-
# Token preview (first and last 8 chars)
|
|
896
|
-
if credentials.access_token:
|
|
897
|
-
token_preview = (
|
|
898
|
-
f"{credentials.access_token[:12]}...{credentials.access_token[-8:]}"
|
|
1528
|
+
token_snapshot = snapshot
|
|
1529
|
+
if not token_snapshot and credentials:
|
|
1530
|
+
token_snapshot = _token_snapshot_from_credentials(credentials, provider)
|
|
1531
|
+
|
|
1532
|
+
if token_snapshot:
|
|
1533
|
+
# Ensure we surface token metadata in the rendered profile table
|
|
1534
|
+
if not profile_info:
|
|
1535
|
+
profile_info = {
|
|
1536
|
+
"provider_type": token_snapshot.provider or provider,
|
|
1537
|
+
"authenticated": True,
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
if token_snapshot.expires_at:
|
|
1541
|
+
profile_info["token_expires_at"] = token_snapshot.expires_at
|
|
1542
|
+
|
|
1543
|
+
profile_info["has_refresh_token"] = token_snapshot.has_refresh_token()
|
|
1544
|
+
profile_info["has_access_token"] = token_snapshot.has_access_token()
|
|
1545
|
+
|
|
1546
|
+
has_id_token = bool(
|
|
1547
|
+
token_snapshot.extras.get("id_token_present")
|
|
1548
|
+
or token_snapshot.extras.get("has_id_token")
|
|
899
1549
|
)
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
1550
|
+
if not has_id_token and credentials and hasattr(credentials, "id_token"):
|
|
1551
|
+
with contextlib.suppress(Exception):
|
|
1552
|
+
has_id_token = bool(credentials.id_token)
|
|
1553
|
+
profile_info["has_id_token"] = has_id_token
|
|
1554
|
+
|
|
1555
|
+
if token_snapshot.scopes and not profile_info.get("scopes"):
|
|
1556
|
+
profile_info["scopes"] = list(token_snapshot.scopes)
|
|
1557
|
+
|
|
1558
|
+
if profile_info:
|
|
1559
|
+
console.print("[green]✓[/green] Authenticated with valid credentials")
|
|
1560
|
+
|
|
1561
|
+
if "provider_type" not in profile_info and "provider" in profile_info:
|
|
1562
|
+
try:
|
|
1563
|
+
profile_info["provider_type"] = str(
|
|
1564
|
+
profile_info["provider"]
|
|
1565
|
+
).replace("_", "-")
|
|
1566
|
+
except Exception:
|
|
1567
|
+
profile_info["provider_type"] = (
|
|
1568
|
+
str(profile_info["provider"])
|
|
1569
|
+
if profile_info.get("provider")
|
|
1570
|
+
else None
|
|
1571
|
+
)
|
|
910
1572
|
|
|
911
|
-
|
|
1573
|
+
_render_profile_table(profile_info, title="Account Information")
|
|
1574
|
+
_render_profile_features(profile_info)
|
|
912
1575
|
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
1576
|
+
if detailed and token_snapshot:
|
|
1577
|
+
preview = token_snapshot.access_token_preview()
|
|
1578
|
+
if preview:
|
|
1579
|
+
console.print(f"\n Token: [dim]{preview}[/dim]")
|
|
1580
|
+
else:
|
|
1581
|
+
console.print("[red]✗[/red] Not authenticated or provider not found")
|
|
1582
|
+
console.print(f" Run 'ccproxy auth login {provider}' to authenticate")
|
|
917
1583
|
|
|
1584
|
+
except ImportError as e:
|
|
1585
|
+
console.print(f"[red]✗[/red] Failed to import required modules: {e}")
|
|
1586
|
+
raise typer.Exit(1) from e
|
|
1587
|
+
except AttributeError as e:
|
|
1588
|
+
console.print(f"[red]✗[/red] Configuration or plugin error: {e}")
|
|
1589
|
+
raise typer.Exit(1) from e
|
|
918
1590
|
except Exception as e:
|
|
919
|
-
|
|
1591
|
+
console.print(f"[red]✗[/red] Error checking status: {e}")
|
|
920
1592
|
raise typer.Exit(1) from e
|
|
921
1593
|
|
|
922
1594
|
|
|
923
|
-
@app.command(name="
|
|
924
|
-
def
|
|
925
|
-
|
|
1595
|
+
@app.command(name="logout")
|
|
1596
|
+
def logout_command(
|
|
1597
|
+
provider: Annotated[
|
|
1598
|
+
str, typer.Argument(help="Provider to logout from (claude-api, codex)")
|
|
1599
|
+
],
|
|
1600
|
+
) -> None:
|
|
1601
|
+
"""Logout and remove stored credentials for specified provider."""
|
|
1602
|
+
_ensure_logging_configured()
|
|
1603
|
+
toolkit = get_rich_toolkit()
|
|
926
1604
|
|
|
927
|
-
|
|
928
|
-
Useful for scripts and automation.
|
|
1605
|
+
provider = provider.strip().lower()
|
|
929
1606
|
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
"""
|
|
933
|
-
import asyncio
|
|
1607
|
+
toolkit.print(f"[bold cyan]{provider.title()} Logout[/bold cyan]", centered=True)
|
|
1608
|
+
toolkit.print_line()
|
|
934
1609
|
|
|
935
1610
|
try:
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
raise typer.Exit(1)
|
|
1611
|
+
container = _get_service_container()
|
|
1612
|
+
registry = container.get_oauth_registry()
|
|
1613
|
+
oauth_provider = asyncio.run(
|
|
1614
|
+
get_oauth_provider_for_name(provider, registry, container)
|
|
1615
|
+
)
|
|
942
1616
|
|
|
943
|
-
if
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
1617
|
+
if not oauth_provider:
|
|
1618
|
+
providers = asyncio.run(discover_oauth_providers(container))
|
|
1619
|
+
available = ", ".join(providers.keys()) if providers else "none"
|
|
1620
|
+
expected = _expected_plugin_class_name(provider)
|
|
1621
|
+
toolkit.print(
|
|
1622
|
+
f"Provider '{provider}' not found. Available: {available}. Expected plugin class '{expected}'.",
|
|
1623
|
+
tag="error",
|
|
947
1624
|
)
|
|
948
1625
|
raise typer.Exit(1)
|
|
949
1626
|
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
1627
|
+
existing_creds = None
|
|
1628
|
+
with contextlib.suppress(Exception):
|
|
1629
|
+
existing_creds = asyncio.run(oauth_provider.load_credentials())
|
|
1630
|
+
|
|
1631
|
+
if not existing_creds:
|
|
1632
|
+
console.print("[yellow]No credentials found. Already logged out.[/yellow]")
|
|
1633
|
+
return
|
|
1634
|
+
|
|
1635
|
+
confirm = typer.confirm(
|
|
1636
|
+
"Are you sure you want to logout and remove credentials?"
|
|
954
1637
|
)
|
|
1638
|
+
if not confirm:
|
|
1639
|
+
console.print("Logout cancelled.")
|
|
1640
|
+
return
|
|
955
1641
|
|
|
956
|
-
|
|
957
|
-
|
|
1642
|
+
success = False
|
|
1643
|
+
try:
|
|
1644
|
+
storage = oauth_provider.get_storage()
|
|
1645
|
+
if storage and hasattr(storage, "delete"):
|
|
1646
|
+
success = asyncio.run(storage.delete())
|
|
1647
|
+
elif storage and hasattr(storage, "clear"):
|
|
1648
|
+
success = asyncio.run(storage.clear())
|
|
1649
|
+
else:
|
|
1650
|
+
success = asyncio.run(oauth_provider.save_credentials(None))
|
|
1651
|
+
except Exception as e:
|
|
1652
|
+
logger.debug("logout_error", error=str(e), exc_info=e)
|
|
1653
|
+
|
|
1654
|
+
if success:
|
|
1655
|
+
toolkit.print(f"Successfully logged out from {provider}!", tag="success")
|
|
1656
|
+
console.print("Credentials have been removed.")
|
|
1657
|
+
else:
|
|
1658
|
+
toolkit.print("Failed to remove credentials", tag="error")
|
|
1659
|
+
raise typer.Exit(1)
|
|
1660
|
+
|
|
1661
|
+
except FileNotFoundError:
|
|
1662
|
+
toolkit.print("No credentials found to remove.", tag="warning")
|
|
1663
|
+
except OSError as e:
|
|
1664
|
+
toolkit.print(f"Failed to remove credential files: {e}", tag="error")
|
|
1665
|
+
raise typer.Exit(1) from e
|
|
1666
|
+
except ImportError as e:
|
|
1667
|
+
toolkit.print(f"Failed to import required modules: {e}", tag="error")
|
|
1668
|
+
raise typer.Exit(1) from e
|
|
958
1669
|
except Exception as e:
|
|
959
|
-
|
|
1670
|
+
toolkit.print(f"Error during logout: {e}", tag="error")
|
|
960
1671
|
raise typer.Exit(1) from e
|
|
961
1672
|
|
|
962
1673
|
|
|
963
|
-
|
|
964
|
-
|
|
1674
|
+
async def get_oauth_provider_for_name(
|
|
1675
|
+
provider: str,
|
|
1676
|
+
registry: OAuthRegistry,
|
|
1677
|
+
container: ServiceContainer,
|
|
1678
|
+
) -> Any:
|
|
1679
|
+
"""Get OAuth provider instance for the specified provider name."""
|
|
1680
|
+
existing = registry.get(provider)
|
|
1681
|
+
if existing:
|
|
1682
|
+
return existing
|
|
1683
|
+
|
|
1684
|
+
provider_instance = await _lazy_register_oauth_provider(
|
|
1685
|
+
provider, registry, container
|
|
1686
|
+
)
|
|
1687
|
+
if provider_instance:
|
|
1688
|
+
return provider_instance
|
|
1689
|
+
|
|
1690
|
+
return None
|