ccproxy-api 0.1.7__py3-none-any.whl → 0.2.0a4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ccproxy/api/__init__.py +1 -15
- ccproxy/api/app.py +434 -219
- ccproxy/api/bootstrap.py +30 -0
- ccproxy/api/decorators.py +85 -0
- ccproxy/api/dependencies.py +144 -168
- ccproxy/api/format_validation.py +54 -0
- ccproxy/api/middleware/cors.py +6 -3
- ccproxy/api/middleware/errors.py +388 -524
- ccproxy/api/middleware/hooks.py +563 -0
- ccproxy/api/middleware/normalize_headers.py +59 -0
- ccproxy/api/middleware/request_id.py +35 -16
- ccproxy/api/middleware/streaming_hooks.py +292 -0
- ccproxy/api/routes/__init__.py +5 -14
- ccproxy/api/routes/health.py +39 -672
- ccproxy/api/routes/plugins.py +277 -0
- ccproxy/auth/__init__.py +2 -19
- ccproxy/auth/bearer.py +25 -15
- ccproxy/auth/dependencies.py +123 -157
- ccproxy/auth/exceptions.py +0 -12
- ccproxy/auth/manager.py +35 -49
- ccproxy/auth/managers/__init__.py +10 -0
- ccproxy/auth/managers/base.py +523 -0
- ccproxy/auth/managers/base_enhanced.py +63 -0
- ccproxy/auth/managers/token_snapshot.py +77 -0
- ccproxy/auth/models/base.py +65 -0
- ccproxy/auth/models/credentials.py +40 -0
- ccproxy/auth/oauth/__init__.py +4 -18
- ccproxy/auth/oauth/base.py +533 -0
- ccproxy/auth/oauth/cli_errors.py +37 -0
- ccproxy/auth/oauth/flows.py +430 -0
- ccproxy/auth/oauth/protocol.py +366 -0
- ccproxy/auth/oauth/registry.py +408 -0
- ccproxy/auth/oauth/router.py +396 -0
- ccproxy/auth/oauth/routes.py +186 -113
- ccproxy/auth/oauth/session.py +151 -0
- ccproxy/auth/oauth/templates.py +342 -0
- ccproxy/auth/storage/__init__.py +2 -5
- ccproxy/auth/storage/base.py +279 -5
- ccproxy/auth/storage/generic.py +134 -0
- ccproxy/cli/__init__.py +1 -2
- ccproxy/cli/_settings_help.py +351 -0
- ccproxy/cli/commands/auth.py +1519 -793
- ccproxy/cli/commands/config/commands.py +209 -276
- ccproxy/cli/commands/plugins.py +669 -0
- ccproxy/cli/commands/serve.py +75 -810
- ccproxy/cli/commands/status.py +254 -0
- ccproxy/cli/decorators.py +83 -0
- ccproxy/cli/helpers.py +22 -60
- ccproxy/cli/main.py +359 -10
- ccproxy/cli/options/claude_options.py +0 -25
- ccproxy/config/__init__.py +7 -11
- ccproxy/config/core.py +227 -0
- ccproxy/config/env_generator.py +232 -0
- ccproxy/config/runtime.py +67 -0
- ccproxy/config/security.py +36 -3
- ccproxy/config/settings.py +382 -441
- ccproxy/config/toml_generator.py +299 -0
- ccproxy/config/utils.py +452 -0
- ccproxy/core/__init__.py +7 -271
- ccproxy/{_version.py → core/_version.py} +16 -3
- ccproxy/core/async_task_manager.py +516 -0
- ccproxy/core/async_utils.py +47 -14
- ccproxy/core/auth/__init__.py +6 -0
- ccproxy/core/constants.py +16 -50
- ccproxy/core/errors.py +53 -0
- ccproxy/core/id_utils.py +20 -0
- ccproxy/core/interfaces.py +16 -123
- ccproxy/core/logging.py +473 -18
- ccproxy/core/plugins/__init__.py +77 -0
- ccproxy/core/plugins/cli_discovery.py +211 -0
- ccproxy/core/plugins/declaration.py +455 -0
- ccproxy/core/plugins/discovery.py +604 -0
- ccproxy/core/plugins/factories.py +967 -0
- ccproxy/core/plugins/hooks/__init__.py +30 -0
- ccproxy/core/plugins/hooks/base.py +58 -0
- ccproxy/core/plugins/hooks/events.py +46 -0
- ccproxy/core/plugins/hooks/implementations/__init__.py +16 -0
- ccproxy/core/plugins/hooks/implementations/formatters/__init__.py +11 -0
- ccproxy/core/plugins/hooks/implementations/formatters/json.py +552 -0
- ccproxy/core/plugins/hooks/implementations/formatters/raw.py +370 -0
- ccproxy/core/plugins/hooks/implementations/http_tracer.py +431 -0
- ccproxy/core/plugins/hooks/layers.py +44 -0
- ccproxy/core/plugins/hooks/manager.py +186 -0
- ccproxy/core/plugins/hooks/registry.py +139 -0
- ccproxy/core/plugins/hooks/thread_manager.py +203 -0
- ccproxy/core/plugins/hooks/types.py +22 -0
- ccproxy/core/plugins/interfaces.py +416 -0
- ccproxy/core/plugins/loader.py +166 -0
- ccproxy/core/plugins/middleware.py +233 -0
- ccproxy/core/plugins/models.py +59 -0
- ccproxy/core/plugins/protocol.py +180 -0
- ccproxy/core/plugins/runtime.py +519 -0
- ccproxy/{observability/context.py → core/request_context.py} +137 -94
- ccproxy/core/status_report.py +211 -0
- ccproxy/core/transformers.py +13 -8
- ccproxy/data/claude_headers_fallback.json +540 -19
- ccproxy/data/codex_headers_fallback.json +114 -7
- ccproxy/http/__init__.py +30 -0
- ccproxy/http/base.py +95 -0
- ccproxy/http/client.py +323 -0
- ccproxy/http/hooks.py +642 -0
- ccproxy/http/pool.py +279 -0
- ccproxy/llms/formatters/__init__.py +7 -0
- ccproxy/llms/formatters/anthropic_to_openai/__init__.py +55 -0
- ccproxy/llms/formatters/anthropic_to_openai/errors.py +65 -0
- ccproxy/llms/formatters/anthropic_to_openai/requests.py +356 -0
- ccproxy/llms/formatters/anthropic_to_openai/responses.py +153 -0
- ccproxy/llms/formatters/anthropic_to_openai/streams.py +1546 -0
- ccproxy/llms/formatters/base.py +140 -0
- ccproxy/llms/formatters/base_model.py +33 -0
- ccproxy/llms/formatters/common/__init__.py +51 -0
- ccproxy/llms/formatters/common/identifiers.py +48 -0
- ccproxy/llms/formatters/common/streams.py +254 -0
- ccproxy/llms/formatters/common/thinking.py +74 -0
- ccproxy/llms/formatters/common/usage.py +135 -0
- ccproxy/llms/formatters/constants.py +55 -0
- ccproxy/llms/formatters/context.py +116 -0
- ccproxy/llms/formatters/mapping.py +33 -0
- ccproxy/llms/formatters/openai_to_anthropic/__init__.py +55 -0
- ccproxy/llms/formatters/openai_to_anthropic/_helpers.py +141 -0
- ccproxy/llms/formatters/openai_to_anthropic/errors.py +53 -0
- ccproxy/llms/formatters/openai_to_anthropic/requests.py +674 -0
- ccproxy/llms/formatters/openai_to_anthropic/responses.py +285 -0
- ccproxy/llms/formatters/openai_to_anthropic/streams.py +530 -0
- ccproxy/llms/formatters/openai_to_openai/__init__.py +53 -0
- ccproxy/llms/formatters/openai_to_openai/_helpers.py +325 -0
- ccproxy/llms/formatters/openai_to_openai/errors.py +6 -0
- ccproxy/llms/formatters/openai_to_openai/requests.py +388 -0
- ccproxy/llms/formatters/openai_to_openai/responses.py +594 -0
- ccproxy/llms/formatters/openai_to_openai/streams.py +1832 -0
- ccproxy/llms/formatters/utils.py +306 -0
- ccproxy/llms/models/__init__.py +9 -0
- ccproxy/llms/models/anthropic.py +619 -0
- ccproxy/llms/models/openai.py +844 -0
- ccproxy/llms/streaming/__init__.py +26 -0
- ccproxy/llms/streaming/accumulators.py +1074 -0
- ccproxy/llms/streaming/formatters.py +251 -0
- ccproxy/{adapters/openai/streaming.py → llms/streaming/processors.py} +193 -240
- ccproxy/models/__init__.py +8 -159
- ccproxy/models/detection.py +92 -193
- ccproxy/models/provider.py +75 -0
- ccproxy/plugins/access_log/README.md +32 -0
- ccproxy/plugins/access_log/__init__.py +20 -0
- ccproxy/plugins/access_log/config.py +33 -0
- ccproxy/plugins/access_log/formatter.py +126 -0
- ccproxy/plugins/access_log/hook.py +763 -0
- ccproxy/plugins/access_log/logger.py +254 -0
- ccproxy/plugins/access_log/plugin.py +137 -0
- ccproxy/plugins/access_log/writer.py +109 -0
- ccproxy/plugins/analytics/README.md +24 -0
- ccproxy/plugins/analytics/__init__.py +1 -0
- ccproxy/plugins/analytics/config.py +5 -0
- ccproxy/plugins/analytics/ingest.py +85 -0
- ccproxy/plugins/analytics/models.py +97 -0
- ccproxy/plugins/analytics/plugin.py +121 -0
- ccproxy/plugins/analytics/routes.py +163 -0
- ccproxy/plugins/analytics/service.py +284 -0
- ccproxy/plugins/claude_api/README.md +29 -0
- ccproxy/plugins/claude_api/__init__.py +10 -0
- ccproxy/plugins/claude_api/adapter.py +829 -0
- ccproxy/plugins/claude_api/config.py +52 -0
- ccproxy/plugins/claude_api/detection_service.py +461 -0
- ccproxy/plugins/claude_api/health.py +175 -0
- ccproxy/plugins/claude_api/hooks.py +284 -0
- ccproxy/plugins/claude_api/models.py +256 -0
- ccproxy/plugins/claude_api/plugin.py +298 -0
- ccproxy/plugins/claude_api/routes.py +118 -0
- ccproxy/plugins/claude_api/streaming_metrics.py +68 -0
- ccproxy/plugins/claude_api/tasks.py +84 -0
- ccproxy/plugins/claude_sdk/README.md +35 -0
- ccproxy/plugins/claude_sdk/__init__.py +80 -0
- ccproxy/plugins/claude_sdk/adapter.py +749 -0
- ccproxy/plugins/claude_sdk/auth.py +57 -0
- ccproxy/{claude_sdk → plugins/claude_sdk}/client.py +63 -39
- ccproxy/plugins/claude_sdk/config.py +210 -0
- ccproxy/{claude_sdk → plugins/claude_sdk}/converter.py +6 -6
- ccproxy/plugins/claude_sdk/detection_service.py +163 -0
- ccproxy/{services/claude_sdk_service.py → plugins/claude_sdk/handler.py} +123 -304
- ccproxy/plugins/claude_sdk/health.py +113 -0
- ccproxy/plugins/claude_sdk/hooks.py +115 -0
- ccproxy/{claude_sdk → plugins/claude_sdk}/manager.py +42 -32
- ccproxy/{claude_sdk → plugins/claude_sdk}/message_queue.py +8 -8
- ccproxy/{models/claude_sdk.py → plugins/claude_sdk/models.py} +64 -16
- ccproxy/plugins/claude_sdk/options.py +154 -0
- ccproxy/{claude_sdk → plugins/claude_sdk}/parser.py +23 -5
- ccproxy/plugins/claude_sdk/plugin.py +269 -0
- ccproxy/plugins/claude_sdk/routes.py +104 -0
- ccproxy/{claude_sdk → plugins/claude_sdk}/session_client.py +124 -12
- ccproxy/plugins/claude_sdk/session_pool.py +700 -0
- ccproxy/{claude_sdk → plugins/claude_sdk}/stream_handle.py +48 -43
- ccproxy/{claude_sdk → plugins/claude_sdk}/stream_worker.py +22 -18
- ccproxy/{claude_sdk → plugins/claude_sdk}/streaming.py +50 -16
- ccproxy/plugins/claude_sdk/tasks.py +97 -0
- ccproxy/plugins/claude_shared/README.md +18 -0
- ccproxy/plugins/claude_shared/__init__.py +12 -0
- ccproxy/plugins/claude_shared/model_defaults.py +171 -0
- ccproxy/plugins/codex/README.md +35 -0
- ccproxy/plugins/codex/__init__.py +6 -0
- ccproxy/plugins/codex/adapter.py +635 -0
- ccproxy/{config/codex.py → plugins/codex/config.py} +78 -12
- ccproxy/plugins/codex/detection_service.py +544 -0
- ccproxy/plugins/codex/health.py +162 -0
- ccproxy/plugins/codex/hooks.py +263 -0
- ccproxy/plugins/codex/model_defaults.py +39 -0
- ccproxy/plugins/codex/models.py +263 -0
- ccproxy/plugins/codex/plugin.py +275 -0
- ccproxy/plugins/codex/routes.py +129 -0
- ccproxy/plugins/codex/streaming_metrics.py +324 -0
- ccproxy/plugins/codex/tasks.py +106 -0
- ccproxy/plugins/codex/utils/__init__.py +1 -0
- ccproxy/plugins/codex/utils/sse_parser.py +106 -0
- ccproxy/plugins/command_replay/README.md +34 -0
- ccproxy/plugins/command_replay/__init__.py +17 -0
- ccproxy/plugins/command_replay/config.py +133 -0
- ccproxy/plugins/command_replay/formatter.py +432 -0
- ccproxy/plugins/command_replay/hook.py +294 -0
- ccproxy/plugins/command_replay/plugin.py +161 -0
- ccproxy/plugins/copilot/README.md +39 -0
- ccproxy/plugins/copilot/__init__.py +11 -0
- ccproxy/plugins/copilot/adapter.py +465 -0
- ccproxy/plugins/copilot/config.py +155 -0
- ccproxy/plugins/copilot/data/copilot_fallback.json +41 -0
- ccproxy/plugins/copilot/detection_service.py +255 -0
- ccproxy/plugins/copilot/manager.py +275 -0
- ccproxy/plugins/copilot/model_defaults.py +284 -0
- ccproxy/plugins/copilot/models.py +148 -0
- ccproxy/plugins/copilot/oauth/__init__.py +16 -0
- ccproxy/plugins/copilot/oauth/client.py +494 -0
- ccproxy/plugins/copilot/oauth/models.py +385 -0
- ccproxy/plugins/copilot/oauth/provider.py +602 -0
- ccproxy/plugins/copilot/oauth/storage.py +170 -0
- ccproxy/plugins/copilot/plugin.py +360 -0
- ccproxy/plugins/copilot/routes.py +294 -0
- ccproxy/plugins/credential_balancer/README.md +124 -0
- ccproxy/plugins/credential_balancer/__init__.py +6 -0
- ccproxy/plugins/credential_balancer/config.py +270 -0
- ccproxy/plugins/credential_balancer/factory.py +415 -0
- ccproxy/plugins/credential_balancer/hook.py +51 -0
- ccproxy/plugins/credential_balancer/manager.py +587 -0
- ccproxy/plugins/credential_balancer/plugin.py +146 -0
- ccproxy/plugins/dashboard/README.md +25 -0
- ccproxy/plugins/dashboard/__init__.py +1 -0
- ccproxy/plugins/dashboard/config.py +8 -0
- ccproxy/plugins/dashboard/plugin.py +71 -0
- ccproxy/plugins/dashboard/routes.py +67 -0
- ccproxy/plugins/docker/README.md +32 -0
- ccproxy/{docker → plugins/docker}/__init__.py +3 -0
- ccproxy/{docker → plugins/docker}/adapter.py +108 -10
- ccproxy/plugins/docker/config.py +82 -0
- ccproxy/{docker → plugins/docker}/docker_path.py +4 -3
- ccproxy/{docker → plugins/docker}/middleware.py +2 -2
- ccproxy/plugins/docker/plugin.py +198 -0
- ccproxy/{docker → plugins/docker}/stream_process.py +3 -3
- ccproxy/plugins/duckdb_storage/README.md +26 -0
- ccproxy/plugins/duckdb_storage/__init__.py +1 -0
- ccproxy/plugins/duckdb_storage/config.py +22 -0
- ccproxy/plugins/duckdb_storage/plugin.py +128 -0
- ccproxy/plugins/duckdb_storage/routes.py +51 -0
- ccproxy/plugins/duckdb_storage/storage.py +633 -0
- ccproxy/plugins/max_tokens/README.md +38 -0
- ccproxy/plugins/max_tokens/__init__.py +12 -0
- ccproxy/plugins/max_tokens/adapter.py +235 -0
- ccproxy/plugins/max_tokens/config.py +86 -0
- ccproxy/plugins/max_tokens/models.py +53 -0
- ccproxy/plugins/max_tokens/plugin.py +200 -0
- ccproxy/plugins/max_tokens/service.py +271 -0
- ccproxy/plugins/max_tokens/token_limits.json +54 -0
- ccproxy/plugins/metrics/README.md +35 -0
- ccproxy/plugins/metrics/__init__.py +10 -0
- ccproxy/{observability/metrics.py → plugins/metrics/collector.py} +20 -153
- ccproxy/plugins/metrics/config.py +85 -0
- ccproxy/plugins/metrics/grafana/dashboards/ccproxy-dashboard.json +1720 -0
- ccproxy/plugins/metrics/hook.py +403 -0
- ccproxy/plugins/metrics/plugin.py +268 -0
- ccproxy/{observability → plugins/metrics}/pushgateway.py +57 -59
- ccproxy/plugins/metrics/routes.py +107 -0
- ccproxy/plugins/metrics/tasks.py +117 -0
- ccproxy/plugins/oauth_claude/README.md +35 -0
- ccproxy/plugins/oauth_claude/__init__.py +14 -0
- ccproxy/plugins/oauth_claude/client.py +270 -0
- ccproxy/plugins/oauth_claude/config.py +84 -0
- ccproxy/plugins/oauth_claude/manager.py +482 -0
- ccproxy/plugins/oauth_claude/models.py +266 -0
- ccproxy/plugins/oauth_claude/plugin.py +149 -0
- ccproxy/plugins/oauth_claude/provider.py +571 -0
- ccproxy/plugins/oauth_claude/storage.py +212 -0
- ccproxy/plugins/oauth_codex/README.md +38 -0
- ccproxy/plugins/oauth_codex/__init__.py +14 -0
- ccproxy/plugins/oauth_codex/client.py +224 -0
- ccproxy/plugins/oauth_codex/config.py +95 -0
- ccproxy/plugins/oauth_codex/manager.py +256 -0
- ccproxy/plugins/oauth_codex/models.py +239 -0
- ccproxy/plugins/oauth_codex/plugin.py +146 -0
- ccproxy/plugins/oauth_codex/provider.py +574 -0
- ccproxy/plugins/oauth_codex/storage.py +92 -0
- ccproxy/plugins/permissions/README.md +28 -0
- ccproxy/plugins/permissions/__init__.py +22 -0
- ccproxy/plugins/permissions/config.py +28 -0
- ccproxy/{cli/commands/permission_handler.py → plugins/permissions/handlers/cli.py} +49 -25
- ccproxy/plugins/permissions/handlers/protocol.py +33 -0
- ccproxy/plugins/permissions/handlers/terminal.py +675 -0
- ccproxy/{api/routes → plugins/permissions}/mcp.py +34 -7
- ccproxy/{models/permissions.py → plugins/permissions/models.py} +65 -1
- ccproxy/plugins/permissions/plugin.py +153 -0
- ccproxy/{api/routes/permissions.py → plugins/permissions/routes.py} +20 -16
- ccproxy/{api/services/permission_service.py → plugins/permissions/service.py} +65 -11
- ccproxy/{api → plugins/permissions}/ui/permission_handler_protocol.py +1 -1
- ccproxy/{api → plugins/permissions}/ui/terminal_permission_handler.py +66 -10
- ccproxy/plugins/pricing/README.md +34 -0
- ccproxy/plugins/pricing/__init__.py +6 -0
- ccproxy/{pricing → plugins/pricing}/cache.py +7 -6
- ccproxy/{config/pricing.py → plugins/pricing/config.py} +32 -6
- ccproxy/plugins/pricing/exceptions.py +35 -0
- ccproxy/plugins/pricing/loader.py +440 -0
- ccproxy/{pricing → plugins/pricing}/models.py +13 -23
- ccproxy/plugins/pricing/plugin.py +169 -0
- ccproxy/plugins/pricing/service.py +191 -0
- ccproxy/plugins/pricing/tasks.py +300 -0
- ccproxy/{pricing → plugins/pricing}/updater.py +86 -72
- ccproxy/plugins/pricing/utils.py +99 -0
- ccproxy/plugins/request_tracer/README.md +40 -0
- ccproxy/plugins/request_tracer/__init__.py +7 -0
- ccproxy/plugins/request_tracer/config.py +120 -0
- ccproxy/plugins/request_tracer/hook.py +415 -0
- ccproxy/plugins/request_tracer/plugin.py +255 -0
- ccproxy/scheduler/__init__.py +2 -14
- ccproxy/scheduler/core.py +26 -41
- ccproxy/scheduler/manager.py +61 -105
- ccproxy/scheduler/registry.py +6 -32
- ccproxy/scheduler/tasks.py +268 -276
- ccproxy/services/__init__.py +0 -1
- ccproxy/services/adapters/__init__.py +11 -0
- ccproxy/services/adapters/base.py +123 -0
- ccproxy/services/adapters/chain_composer.py +88 -0
- ccproxy/services/adapters/chain_validation.py +44 -0
- ccproxy/services/adapters/chat_accumulator.py +200 -0
- ccproxy/services/adapters/delta_utils.py +142 -0
- ccproxy/services/adapters/format_adapter.py +136 -0
- ccproxy/services/adapters/format_context.py +11 -0
- ccproxy/services/adapters/format_registry.py +158 -0
- ccproxy/services/adapters/http_adapter.py +1045 -0
- ccproxy/services/adapters/mock_adapter.py +118 -0
- ccproxy/services/adapters/protocols.py +35 -0
- ccproxy/services/adapters/simple_converters.py +571 -0
- ccproxy/services/auth_registry.py +180 -0
- ccproxy/services/cache/__init__.py +6 -0
- ccproxy/services/cache/response_cache.py +261 -0
- ccproxy/services/cli_detection.py +437 -0
- ccproxy/services/config/__init__.py +6 -0
- ccproxy/services/config/proxy_configuration.py +111 -0
- ccproxy/services/container.py +256 -0
- ccproxy/services/factories.py +380 -0
- ccproxy/services/handler_config.py +76 -0
- ccproxy/services/interfaces.py +298 -0
- ccproxy/services/mocking/__init__.py +6 -0
- ccproxy/services/mocking/mock_handler.py +291 -0
- ccproxy/services/tracing/__init__.py +7 -0
- ccproxy/services/tracing/interfaces.py +61 -0
- ccproxy/services/tracing/null_tracer.py +57 -0
- ccproxy/streaming/__init__.py +23 -0
- ccproxy/streaming/buffer.py +1056 -0
- ccproxy/streaming/deferred.py +897 -0
- ccproxy/streaming/handler.py +117 -0
- ccproxy/streaming/interfaces.py +77 -0
- ccproxy/streaming/simple_adapter.py +39 -0
- ccproxy/streaming/sse.py +109 -0
- ccproxy/streaming/sse_parser.py +127 -0
- ccproxy/templates/__init__.py +6 -0
- ccproxy/templates/plugin_scaffold.py +695 -0
- ccproxy/testing/endpoints/__init__.py +33 -0
- ccproxy/testing/endpoints/cli.py +215 -0
- ccproxy/testing/endpoints/config.py +874 -0
- ccproxy/testing/endpoints/console.py +57 -0
- ccproxy/testing/endpoints/models.py +100 -0
- ccproxy/testing/endpoints/runner.py +1903 -0
- ccproxy/testing/endpoints/tools.py +308 -0
- ccproxy/testing/mock_responses.py +70 -1
- ccproxy/testing/response_handlers.py +20 -0
- ccproxy/utils/__init__.py +0 -6
- ccproxy/utils/binary_resolver.py +476 -0
- ccproxy/utils/caching.py +327 -0
- ccproxy/utils/cli_logging.py +101 -0
- ccproxy/utils/command_line.py +251 -0
- ccproxy/utils/headers.py +228 -0
- ccproxy/utils/model_mapper.py +120 -0
- ccproxy/utils/startup_helpers.py +68 -446
- ccproxy/utils/version_checker.py +273 -6
- ccproxy_api-0.2.0a4.dist-info/METADATA +212 -0
- ccproxy_api-0.2.0a4.dist-info/RECORD +417 -0
- {ccproxy_api-0.1.7.dist-info → ccproxy_api-0.2.0a4.dist-info}/WHEEL +1 -1
- ccproxy_api-0.2.0a4.dist-info/entry_points.txt +24 -0
- ccproxy/__init__.py +0 -4
- ccproxy/adapters/__init__.py +0 -11
- ccproxy/adapters/base.py +0 -80
- ccproxy/adapters/codex/__init__.py +0 -11
- ccproxy/adapters/openai/__init__.py +0 -42
- ccproxy/adapters/openai/adapter.py +0 -953
- ccproxy/adapters/openai/models.py +0 -412
- ccproxy/adapters/openai/response_adapter.py +0 -355
- ccproxy/adapters/openai/response_models.py +0 -178
- ccproxy/api/middleware/headers.py +0 -49
- ccproxy/api/middleware/logging.py +0 -180
- ccproxy/api/middleware/request_content_logging.py +0 -297
- ccproxy/api/middleware/server_header.py +0 -58
- ccproxy/api/responses.py +0 -89
- ccproxy/api/routes/claude.py +0 -371
- ccproxy/api/routes/codex.py +0 -1251
- ccproxy/api/routes/metrics.py +0 -1029
- ccproxy/api/routes/proxy.py +0 -211
- ccproxy/api/services/__init__.py +0 -6
- ccproxy/auth/conditional.py +0 -84
- ccproxy/auth/credentials_adapter.py +0 -93
- ccproxy/auth/models.py +0 -118
- ccproxy/auth/oauth/models.py +0 -48
- ccproxy/auth/openai/__init__.py +0 -13
- ccproxy/auth/openai/credentials.py +0 -166
- ccproxy/auth/openai/oauth_client.py +0 -334
- ccproxy/auth/openai/storage.py +0 -184
- ccproxy/auth/storage/json_file.py +0 -158
- ccproxy/auth/storage/keyring.py +0 -189
- ccproxy/claude_sdk/__init__.py +0 -18
- ccproxy/claude_sdk/options.py +0 -194
- ccproxy/claude_sdk/session_pool.py +0 -550
- ccproxy/cli/docker/__init__.py +0 -34
- ccproxy/cli/docker/adapter_factory.py +0 -157
- ccproxy/cli/docker/params.py +0 -274
- ccproxy/config/auth.py +0 -153
- ccproxy/config/claude.py +0 -348
- ccproxy/config/cors.py +0 -79
- ccproxy/config/discovery.py +0 -95
- ccproxy/config/docker_settings.py +0 -264
- ccproxy/config/observability.py +0 -158
- ccproxy/config/reverse_proxy.py +0 -31
- ccproxy/config/scheduler.py +0 -108
- ccproxy/config/server.py +0 -86
- ccproxy/config/validators.py +0 -231
- ccproxy/core/codex_transformers.py +0 -389
- ccproxy/core/http.py +0 -328
- ccproxy/core/http_transformers.py +0 -812
- ccproxy/core/proxy.py +0 -143
- ccproxy/core/validators.py +0 -288
- ccproxy/models/errors.py +0 -42
- ccproxy/models/messages.py +0 -269
- ccproxy/models/requests.py +0 -107
- ccproxy/models/responses.py +0 -270
- ccproxy/models/types.py +0 -102
- ccproxy/observability/__init__.py +0 -51
- ccproxy/observability/access_logger.py +0 -457
- ccproxy/observability/sse_events.py +0 -303
- ccproxy/observability/stats_printer.py +0 -753
- ccproxy/observability/storage/__init__.py +0 -1
- ccproxy/observability/storage/duckdb_simple.py +0 -677
- ccproxy/observability/storage/models.py +0 -70
- ccproxy/observability/streaming_response.py +0 -107
- ccproxy/pricing/__init__.py +0 -19
- ccproxy/pricing/loader.py +0 -251
- ccproxy/services/claude_detection_service.py +0 -243
- ccproxy/services/codex_detection_service.py +0 -252
- ccproxy/services/credentials/__init__.py +0 -55
- ccproxy/services/credentials/config.py +0 -105
- ccproxy/services/credentials/manager.py +0 -561
- ccproxy/services/credentials/oauth_client.py +0 -481
- ccproxy/services/proxy_service.py +0 -1827
- ccproxy/static/.keep +0 -0
- ccproxy/utils/cost_calculator.py +0 -210
- ccproxy/utils/disconnection_monitor.py +0 -83
- ccproxy/utils/model_mapping.py +0 -199
- ccproxy/utils/models_provider.py +0 -150
- ccproxy/utils/simple_request_logger.py +0 -284
- ccproxy/utils/streaming_metrics.py +0 -199
- ccproxy_api-0.1.7.dist-info/METADATA +0 -615
- ccproxy_api-0.1.7.dist-info/RECORD +0 -191
- ccproxy_api-0.1.7.dist-info/entry_points.txt +0 -4
- /ccproxy/{api/middleware/auth.py → auth/models/__init__.py} +0 -0
- /ccproxy/{claude_sdk → plugins/claude_sdk}/exceptions.py +0 -0
- /ccproxy/{docker → plugins/docker}/models.py +0 -0
- /ccproxy/{docker → plugins/docker}/protocol.py +0 -0
- /ccproxy/{docker → plugins/docker}/validators.py +0 -0
- /ccproxy/{auth/oauth/storage.py → plugins/permissions/handlers/__init__.py} +0 -0
- /ccproxy/{api → plugins/permissions}/ui/__init__.py +0 -0
- {ccproxy_api-0.1.7.dist-info → ccproxy_api-0.2.0a4.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,675 @@
|
|
|
1
|
+
"""Terminal UI handler for confirmation requests using Textual with request stacking support."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import contextlib
|
|
7
|
+
import time
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
from ccproxy.core.async_task_manager import (
|
|
12
|
+
create_fire_and_forget_task,
|
|
13
|
+
create_managed_task,
|
|
14
|
+
)
|
|
15
|
+
from ccproxy.core.logging import get_plugin_logger
|
|
16
|
+
|
|
17
|
+
from ..models import PermissionRequest
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# During type checking, import real Textual types; at runtime, provide fallbacks if absent.
|
|
21
|
+
TEXTUAL_AVAILABLE: bool
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from textual.app import App, ComposeResult
|
|
24
|
+
from textual.containers import Container, Vertical
|
|
25
|
+
from textual.events import Key
|
|
26
|
+
from textual.reactive import reactive
|
|
27
|
+
from textual.screen import ModalScreen
|
|
28
|
+
from textual.timer import Timer
|
|
29
|
+
from textual.widgets import Label, Static
|
|
30
|
+
|
|
31
|
+
TEXTUAL_AVAILABLE = True
|
|
32
|
+
else: # pragma: no cover - optional dependency
|
|
33
|
+
try:
|
|
34
|
+
from textual.app import App, ComposeResult
|
|
35
|
+
from textual.containers import Container, Vertical
|
|
36
|
+
from textual.events import Key
|
|
37
|
+
from textual.reactive import reactive
|
|
38
|
+
from textual.screen import ModalScreen
|
|
39
|
+
from textual.timer import Timer
|
|
40
|
+
from textual.widgets import Label, Static
|
|
41
|
+
|
|
42
|
+
TEXTUAL_AVAILABLE = True
|
|
43
|
+
except ImportError:
|
|
44
|
+
TEXTUAL_AVAILABLE = False
|
|
45
|
+
|
|
46
|
+
# Minimal runtime stubs to avoid crashes when Textual is not installed
|
|
47
|
+
class App: # type: ignore[no-redef]
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
class Container: # type: ignore[no-redef]
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
class Vertical: # type: ignore[no-redef]
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
class ModalScreen: # type: ignore[no-redef]
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
class Label: # type: ignore[no-redef]
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
class Static: # type: ignore[no-redef]
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
def reactive(x: float) -> float: # type: ignore[no-redef]
|
|
66
|
+
return x
|
|
67
|
+
|
|
68
|
+
class Timer: # type: ignore[no-redef]
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
logger = get_plugin_logger()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class PendingRequest:
|
|
77
|
+
"""Represents a pending confirmation request with its response future."""
|
|
78
|
+
|
|
79
|
+
request: PermissionRequest
|
|
80
|
+
future: asyncio.Future[bool]
|
|
81
|
+
cancelled: bool = False
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class ConfirmationScreen(ModalScreen[bool]):
|
|
85
|
+
"""Modal screen for displaying a single confirmation request."""
|
|
86
|
+
|
|
87
|
+
BINDINGS = [
|
|
88
|
+
("y", "confirm", "Yes"),
|
|
89
|
+
("n", "deny", "No"),
|
|
90
|
+
("enter", "confirm", "Confirm"),
|
|
91
|
+
("escape", "deny", "Cancel"),
|
|
92
|
+
("ctrl+c", "cancel", "Cancel"),
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
def __init__(self, request: PermissionRequest) -> None:
|
|
96
|
+
super().__init__()
|
|
97
|
+
self.request = request
|
|
98
|
+
self.start_time = time.time()
|
|
99
|
+
self.countdown_timer: Timer | None = None
|
|
100
|
+
|
|
101
|
+
time_remaining = reactive(0.0)
|
|
102
|
+
|
|
103
|
+
def compose(self) -> ComposeResult:
|
|
104
|
+
"""Compose the confirmation dialog."""
|
|
105
|
+
with Container(id="confirmation-dialog"):
|
|
106
|
+
yield Vertical(
|
|
107
|
+
Label("[bold red]Permission Request[/bold red]", id="title"),
|
|
108
|
+
self._create_info_display(),
|
|
109
|
+
Label("Calculating timeout...", id="countdown", classes="countdown"),
|
|
110
|
+
Label(
|
|
111
|
+
"[bold white]Allow this operation? (y/N):[/bold white]",
|
|
112
|
+
id="question",
|
|
113
|
+
),
|
|
114
|
+
id="content",
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
def _create_info_display(self) -> Static:
|
|
118
|
+
"""Create the information display widget."""
|
|
119
|
+
info_lines = [
|
|
120
|
+
f"[bold cyan]Tool:[/bold cyan] {self.request.tool_name}",
|
|
121
|
+
f"[bold cyan]Request ID:[/bold cyan] {self.request.id[:8]}...",
|
|
122
|
+
]
|
|
123
|
+
|
|
124
|
+
# Add input parameters
|
|
125
|
+
for key, value in self.request.input.items():
|
|
126
|
+
display_value = value if len(value) <= 50 else f"{value[:47]}..."
|
|
127
|
+
info_lines.append(f"[bold cyan]{key}:[/bold cyan] {display_value}")
|
|
128
|
+
|
|
129
|
+
return Static("\n".join(info_lines), id="info")
|
|
130
|
+
|
|
131
|
+
def on_mount(self) -> None:
|
|
132
|
+
"""Start the countdown timer when mounted."""
|
|
133
|
+
self.update_countdown()
|
|
134
|
+
self.countdown_timer = self.set_interval(0.1, self.update_countdown)
|
|
135
|
+
|
|
136
|
+
def update_countdown(self) -> None:
|
|
137
|
+
"""Update the countdown display."""
|
|
138
|
+
elapsed = time.time() - self.start_time
|
|
139
|
+
remaining = max(0, self.request.time_remaining() - elapsed)
|
|
140
|
+
self.time_remaining = remaining
|
|
141
|
+
|
|
142
|
+
if remaining <= 0:
|
|
143
|
+
self._timeout()
|
|
144
|
+
else:
|
|
145
|
+
countdown_widget = self.query_one("#countdown", Label)
|
|
146
|
+
if remaining > 10:
|
|
147
|
+
style = "yellow"
|
|
148
|
+
elif remaining > 5:
|
|
149
|
+
style = "orange1"
|
|
150
|
+
else:
|
|
151
|
+
style = "red"
|
|
152
|
+
countdown_widget.update(f"[{style}]Timeout in {remaining:.1f}s[/{style}]")
|
|
153
|
+
|
|
154
|
+
def _timeout(self) -> None:
|
|
155
|
+
"""Handle timeout."""
|
|
156
|
+
if self.countdown_timer:
|
|
157
|
+
self.countdown_timer.stop()
|
|
158
|
+
self.countdown_timer = None
|
|
159
|
+
# Schedule the async result display
|
|
160
|
+
self.call_later(self._show_result, False, "TIMEOUT - DENIED")
|
|
161
|
+
|
|
162
|
+
async def _show_result(self, allowed: bool, message: str) -> None:
|
|
163
|
+
"""Show the result with visual feedback before dismissing.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
allowed: Whether the request was allowed
|
|
167
|
+
message: Message to display
|
|
168
|
+
"""
|
|
169
|
+
# Update the question to show the result
|
|
170
|
+
question_widget = self.query_one("#question", Label)
|
|
171
|
+
if allowed:
|
|
172
|
+
question_widget.update(f"[bold green]✓ {message}[/bold green]")
|
|
173
|
+
else:
|
|
174
|
+
question_widget.update(f"[bold red]✗ {message}[/bold red]")
|
|
175
|
+
|
|
176
|
+
# Update the dialog border color
|
|
177
|
+
dialog = self.query_one("#confirmation-dialog", Container)
|
|
178
|
+
if allowed:
|
|
179
|
+
dialog.styles.border = ("solid", "green")
|
|
180
|
+
else:
|
|
181
|
+
dialog.styles.border = ("solid", "red")
|
|
182
|
+
|
|
183
|
+
# Give user time to see the result
|
|
184
|
+
await asyncio.sleep(1.5)
|
|
185
|
+
self.dismiss(allowed)
|
|
186
|
+
|
|
187
|
+
def action_confirm(self) -> None:
|
|
188
|
+
"""Confirm the request."""
|
|
189
|
+
if self.countdown_timer:
|
|
190
|
+
self.countdown_timer.stop()
|
|
191
|
+
self.countdown_timer = None
|
|
192
|
+
self.call_later(self._show_result, True, "ALLOWED")
|
|
193
|
+
|
|
194
|
+
def action_deny(self) -> None:
|
|
195
|
+
"""Deny the request."""
|
|
196
|
+
if self.countdown_timer:
|
|
197
|
+
self.countdown_timer.stop()
|
|
198
|
+
self.countdown_timer = None
|
|
199
|
+
self.call_later(self._show_result, False, "DENIED")
|
|
200
|
+
|
|
201
|
+
def action_cancel(self) -> None:
|
|
202
|
+
"""Cancel the request (Ctrl+C)."""
|
|
203
|
+
if self.countdown_timer:
|
|
204
|
+
self.countdown_timer.stop()
|
|
205
|
+
self.countdown_timer = None
|
|
206
|
+
self.call_later(self._show_result, False, "CANCELLED")
|
|
207
|
+
# Raise KeyboardInterrupt to forward it up
|
|
208
|
+
raise KeyboardInterrupt("User cancelled confirmation")
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class ConfirmationApp(App[bool]):
|
|
212
|
+
"""Simple Textual app for a single confirmation request."""
|
|
213
|
+
|
|
214
|
+
CSS = """
|
|
215
|
+
|
|
216
|
+
Screen {
|
|
217
|
+
border: none;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
Static {
|
|
221
|
+
background: $surface;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
#confirmation-dialog {
|
|
225
|
+
width: 60;
|
|
226
|
+
height: 18;
|
|
227
|
+
border: round solid $accent;
|
|
228
|
+
background: $surface;
|
|
229
|
+
padding: 1;
|
|
230
|
+
box-sizing: border-box;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
#title {
|
|
234
|
+
text-align: center;
|
|
235
|
+
margin-bottom: 1;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
#info {
|
|
239
|
+
border: solid $primary;
|
|
240
|
+
margin: 1;
|
|
241
|
+
padding: 1;
|
|
242
|
+
background: $surface;
|
|
243
|
+
height: auto;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
#countdown {
|
|
247
|
+
text-align: center;
|
|
248
|
+
margin: 1;
|
|
249
|
+
background: $surface;
|
|
250
|
+
text-style: bold;
|
|
251
|
+
height: 1;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
#question {
|
|
255
|
+
text-align: center;
|
|
256
|
+
margin: 1;
|
|
257
|
+
background: $surface;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
.countdown {
|
|
262
|
+
text-style: bold;
|
|
263
|
+
}
|
|
264
|
+
"""
|
|
265
|
+
|
|
266
|
+
BINDINGS = [
|
|
267
|
+
("y", "confirm", "Yes"),
|
|
268
|
+
("n", "deny", "No"),
|
|
269
|
+
("enter", "confirm", "Confirm"),
|
|
270
|
+
("escape", "deny", "Cancel"),
|
|
271
|
+
("ctrl+c", "cancel", "Cancel"),
|
|
272
|
+
]
|
|
273
|
+
|
|
274
|
+
def __init__(self, request: PermissionRequest) -> None:
|
|
275
|
+
super().__init__()
|
|
276
|
+
self.theme = "textual-ansi"
|
|
277
|
+
self.request = request
|
|
278
|
+
self.result = False
|
|
279
|
+
self.start_time = time.time()
|
|
280
|
+
self.countdown_timer: Timer | None = None
|
|
281
|
+
|
|
282
|
+
time_remaining = reactive(0.0)
|
|
283
|
+
|
|
284
|
+
def compose(self) -> ComposeResult:
|
|
285
|
+
"""Compose the confirmation dialog directly."""
|
|
286
|
+
with Container(id="confirmation-dialog"):
|
|
287
|
+
yield Vertical(
|
|
288
|
+
Label("[bold red]Permission Request[/bold red]", id="title"),
|
|
289
|
+
self._create_info_display(),
|
|
290
|
+
Label("Calculating timeout...", id="countdown", classes="countdown"),
|
|
291
|
+
Label(
|
|
292
|
+
"[bold white]Allow this operation? (y/N):[/bold white]",
|
|
293
|
+
id="question",
|
|
294
|
+
),
|
|
295
|
+
id="content",
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
def _create_info_display(self) -> Static:
|
|
299
|
+
"""Create the information display widget."""
|
|
300
|
+
info_lines = [
|
|
301
|
+
f"[bold cyan]Tool:[/bold cyan] {self.request.tool_name}",
|
|
302
|
+
f"[bold cyan]Request ID:[/bold cyan] {self.request.id[:8]}...",
|
|
303
|
+
]
|
|
304
|
+
|
|
305
|
+
# Add input parameters
|
|
306
|
+
for key, value in self.request.input.items():
|
|
307
|
+
display_value = value if len(value) <= 50 else f"{value[:47]}..."
|
|
308
|
+
info_lines.append(f"[bold cyan]{key}:[/bold cyan] {display_value}")
|
|
309
|
+
|
|
310
|
+
return Static("\n".join(info_lines), id="info")
|
|
311
|
+
|
|
312
|
+
def on_mount(self) -> None:
|
|
313
|
+
"""Start the countdown timer when mounted."""
|
|
314
|
+
self.update_countdown()
|
|
315
|
+
self.countdown_timer = self.set_interval(0.1, self.update_countdown)
|
|
316
|
+
|
|
317
|
+
def update_countdown(self) -> None:
|
|
318
|
+
"""Update the countdown display."""
|
|
319
|
+
elapsed = time.time() - self.start_time
|
|
320
|
+
remaining = max(0, self.request.time_remaining() - elapsed)
|
|
321
|
+
self.time_remaining = remaining
|
|
322
|
+
|
|
323
|
+
if remaining <= 0:
|
|
324
|
+
self._timeout()
|
|
325
|
+
else:
|
|
326
|
+
countdown_widget = self.query_one("#countdown", Label)
|
|
327
|
+
if remaining > 10:
|
|
328
|
+
style = "yellow"
|
|
329
|
+
elif remaining > 5:
|
|
330
|
+
style = "orange1"
|
|
331
|
+
else:
|
|
332
|
+
style = "red"
|
|
333
|
+
countdown_widget.update(f"[{style}]Timeout in {remaining:.1f}s[/{style}]")
|
|
334
|
+
|
|
335
|
+
def _timeout(self) -> None:
|
|
336
|
+
"""Handle timeout."""
|
|
337
|
+
if self.countdown_timer:
|
|
338
|
+
self.countdown_timer.stop()
|
|
339
|
+
self.countdown_timer = None
|
|
340
|
+
# Schedule the async result display
|
|
341
|
+
self.call_later(self._show_result, False, "TIMEOUT - DENIED")
|
|
342
|
+
|
|
343
|
+
async def _show_result(self, allowed: bool, message: str) -> None:
|
|
344
|
+
"""Show the result with visual feedback before exiting.
|
|
345
|
+
|
|
346
|
+
Args:
|
|
347
|
+
allowed: Whether the request was allowed
|
|
348
|
+
message: Message to display
|
|
349
|
+
"""
|
|
350
|
+
# Update the question to show the result
|
|
351
|
+
question_widget = self.query_one("#question", Label)
|
|
352
|
+
if allowed:
|
|
353
|
+
question_widget.update(f"[bold green]✓ {message}[/bold green]")
|
|
354
|
+
else:
|
|
355
|
+
question_widget.update(f"[bold red]✗ {message}[/bold red]")
|
|
356
|
+
|
|
357
|
+
# Update the dialog border color
|
|
358
|
+
dialog = self.query_one("#confirmation-dialog", Container)
|
|
359
|
+
if allowed:
|
|
360
|
+
dialog.styles.border = ("solid", "green")
|
|
361
|
+
else:
|
|
362
|
+
dialog.styles.border = ("solid", "red")
|
|
363
|
+
|
|
364
|
+
# Give user time to see the result
|
|
365
|
+
await asyncio.sleep(1.5)
|
|
366
|
+
self.exit(allowed)
|
|
367
|
+
|
|
368
|
+
def action_confirm(self) -> None:
|
|
369
|
+
"""Confirm the request."""
|
|
370
|
+
if self.countdown_timer:
|
|
371
|
+
self.countdown_timer.stop()
|
|
372
|
+
self.countdown_timer = None
|
|
373
|
+
self.call_later(self._show_result, True, "ALLOWED")
|
|
374
|
+
|
|
375
|
+
def action_deny(self) -> None:
|
|
376
|
+
"""Deny the request."""
|
|
377
|
+
if self.countdown_timer:
|
|
378
|
+
self.countdown_timer.stop()
|
|
379
|
+
self.countdown_timer = None
|
|
380
|
+
self.call_later(self._show_result, False, "DENIED")
|
|
381
|
+
|
|
382
|
+
def action_cancel(self) -> None:
|
|
383
|
+
"""Cancel the request (Ctrl+C)."""
|
|
384
|
+
if self.countdown_timer:
|
|
385
|
+
self.countdown_timer.stop()
|
|
386
|
+
self.countdown_timer = None
|
|
387
|
+
self.call_later(self._show_result, False, "CANCELLED")
|
|
388
|
+
# Raise KeyboardInterrupt to forward it up
|
|
389
|
+
raise KeyboardInterrupt("User cancelled confirmation")
|
|
390
|
+
|
|
391
|
+
async def on_key(self, event: Key) -> None:
|
|
392
|
+
"""Handle global key events, especially Ctrl+C."""
|
|
393
|
+
if event.key == "ctrl+c":
|
|
394
|
+
# Forward the KeyboardInterrupt
|
|
395
|
+
self.exit(False)
|
|
396
|
+
raise KeyboardInterrupt("User cancelled confirmation")
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
class TerminalPermissionHandler:
|
|
400
|
+
"""Handles confirmation requests in the terminal using Textual with request stacking.
|
|
401
|
+
|
|
402
|
+
Implements ConfirmationHandlerProtocol for type safety and interoperability.
|
|
403
|
+
"""
|
|
404
|
+
|
|
405
|
+
def __init__(self) -> None:
|
|
406
|
+
"""Initialize the terminal confirmation handler."""
|
|
407
|
+
self._request_queue: (
|
|
408
|
+
asyncio.Queue[tuple[PermissionRequest, asyncio.Future[bool]]] | None
|
|
409
|
+
) = None
|
|
410
|
+
self._cancelled_requests: set[str] = set()
|
|
411
|
+
self._processing_task: asyncio.Task[None] | None = None
|
|
412
|
+
self._active_apps: dict[str, ConfirmationApp] = {}
|
|
413
|
+
|
|
414
|
+
def _get_request_queue(
|
|
415
|
+
self,
|
|
416
|
+
) -> asyncio.Queue[tuple[PermissionRequest, asyncio.Future[bool]]]:
|
|
417
|
+
"""Lazily initialize and return the request queue."""
|
|
418
|
+
if self._request_queue is None:
|
|
419
|
+
self._request_queue = asyncio.Queue()
|
|
420
|
+
return self._request_queue
|
|
421
|
+
|
|
422
|
+
def _safe_set_future_result(
|
|
423
|
+
self, future: asyncio.Future[bool], result: bool
|
|
424
|
+
) -> bool:
|
|
425
|
+
"""Safely set a future result, handling already cancelled futures.
|
|
426
|
+
|
|
427
|
+
Args:
|
|
428
|
+
future: The future to set the result on
|
|
429
|
+
result: The result to set
|
|
430
|
+
|
|
431
|
+
Returns:
|
|
432
|
+
bool: True if result was set successfully, False if future was cancelled
|
|
433
|
+
"""
|
|
434
|
+
if future.cancelled():
|
|
435
|
+
return False
|
|
436
|
+
try:
|
|
437
|
+
future.set_result(result)
|
|
438
|
+
return True
|
|
439
|
+
except asyncio.InvalidStateError:
|
|
440
|
+
# Future was already resolved or cancelled
|
|
441
|
+
return False
|
|
442
|
+
|
|
443
|
+
def _safe_set_future_exception(
|
|
444
|
+
self, future: asyncio.Future[bool], exception: BaseException
|
|
445
|
+
) -> bool:
|
|
446
|
+
"""Safely set a future exception, handling already cancelled futures.
|
|
447
|
+
|
|
448
|
+
Args:
|
|
449
|
+
future: The future to set the exception on
|
|
450
|
+
exception: The exception to set
|
|
451
|
+
|
|
452
|
+
Returns:
|
|
453
|
+
bool: True if exception was set successfully, False if future was cancelled
|
|
454
|
+
"""
|
|
455
|
+
if future.cancelled():
|
|
456
|
+
return False
|
|
457
|
+
try:
|
|
458
|
+
future.set_exception(exception)
|
|
459
|
+
return True
|
|
460
|
+
except asyncio.InvalidStateError:
|
|
461
|
+
# Future was already resolved or cancelled
|
|
462
|
+
return False
|
|
463
|
+
|
|
464
|
+
async def _process_queue(self) -> None:
|
|
465
|
+
"""Process requests from the queue one by one."""
|
|
466
|
+
while True:
|
|
467
|
+
try:
|
|
468
|
+
request, future = await self._get_request_queue().get()
|
|
469
|
+
|
|
470
|
+
# Check if request is valid for processing
|
|
471
|
+
if not self._is_request_processable(request, future):
|
|
472
|
+
continue
|
|
473
|
+
|
|
474
|
+
# Process the request
|
|
475
|
+
await self._process_single_request(request, future)
|
|
476
|
+
|
|
477
|
+
except asyncio.CancelledError:
|
|
478
|
+
break
|
|
479
|
+
except Exception as e:
|
|
480
|
+
logger.error("queue_processing_error", error=str(e), exc_info=e)
|
|
481
|
+
|
|
482
|
+
def _is_request_processable(
|
|
483
|
+
self, request: PermissionRequest, future: asyncio.Future[bool]
|
|
484
|
+
) -> bool:
|
|
485
|
+
"""Check if a request can be processed."""
|
|
486
|
+
# Check if cancelled before processing
|
|
487
|
+
if request.id in self._cancelled_requests:
|
|
488
|
+
self._safe_set_future_result(future, False)
|
|
489
|
+
self._cancelled_requests.discard(request.id)
|
|
490
|
+
return False
|
|
491
|
+
|
|
492
|
+
# Check if expired
|
|
493
|
+
if request.time_remaining() <= 0:
|
|
494
|
+
self._safe_set_future_result(future, False)
|
|
495
|
+
return False
|
|
496
|
+
|
|
497
|
+
return True
|
|
498
|
+
|
|
499
|
+
async def _process_single_request(
|
|
500
|
+
self, request: PermissionRequest, future: asyncio.Future[bool]
|
|
501
|
+
) -> None:
|
|
502
|
+
"""Process a single permission request."""
|
|
503
|
+
app = None
|
|
504
|
+
try:
|
|
505
|
+
# Create and run a simple app for this request
|
|
506
|
+
app = ConfirmationApp(request)
|
|
507
|
+
self._active_apps[request.id] = app
|
|
508
|
+
|
|
509
|
+
app_result = await app.run_async(inline=True, inline_no_clear=True)
|
|
510
|
+
result = bool(app_result) if app_result is not None else False
|
|
511
|
+
|
|
512
|
+
# Apply cancellation if it occurred during processing
|
|
513
|
+
if request.id in self._cancelled_requests:
|
|
514
|
+
result = False
|
|
515
|
+
self._cancelled_requests.discard(request.id)
|
|
516
|
+
|
|
517
|
+
self._safe_set_future_result(future, result)
|
|
518
|
+
|
|
519
|
+
except KeyboardInterrupt:
|
|
520
|
+
self._safe_set_future_exception(
|
|
521
|
+
future, KeyboardInterrupt("User cancelled confirmation")
|
|
522
|
+
)
|
|
523
|
+
except Exception as e:
|
|
524
|
+
logger.error(
|
|
525
|
+
"confirmation_app_error",
|
|
526
|
+
request_id=request.id,
|
|
527
|
+
error=str(e),
|
|
528
|
+
exc_info=e,
|
|
529
|
+
)
|
|
530
|
+
self._safe_set_future_result(future, False)
|
|
531
|
+
finally:
|
|
532
|
+
# Always cleanup app reference
|
|
533
|
+
if app:
|
|
534
|
+
self._active_apps.pop(request.id, None)
|
|
535
|
+
|
|
536
|
+
def _ensure_processing_task_running(self) -> None:
|
|
537
|
+
"""Ensure the processing task is running."""
|
|
538
|
+
if self._processing_task is None or self._processing_task.done():
|
|
539
|
+
# Use fire-and-forget since this is called from sync context
|
|
540
|
+
create_fire_and_forget_task(
|
|
541
|
+
self._create_processing_task(),
|
|
542
|
+
name="terminal_handler_processing",
|
|
543
|
+
creator="TerminalHandler",
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
async def _ensure_processing_task_running_async(self) -> None:
|
|
547
|
+
"""Ensure the processing task is running (async version for tests)."""
|
|
548
|
+
if self._processing_task is None or self._processing_task.done():
|
|
549
|
+
await self._create_processing_task()
|
|
550
|
+
|
|
551
|
+
async def _create_processing_task(self) -> None:
|
|
552
|
+
"""Create the processing task in async context."""
|
|
553
|
+
self._processing_task = await create_managed_task(
|
|
554
|
+
self._process_queue(),
|
|
555
|
+
name="terminal_handler_queue_processor",
|
|
556
|
+
creator="TerminalHandler",
|
|
557
|
+
)
|
|
558
|
+
|
|
559
|
+
async def _queue_and_wait_for_result(self, request: PermissionRequest) -> bool:
|
|
560
|
+
"""Queue a request and wait for its result."""
|
|
561
|
+
future: asyncio.Future[bool] = asyncio.Future()
|
|
562
|
+
await self._get_request_queue().put((request, future))
|
|
563
|
+
return await future
|
|
564
|
+
|
|
565
|
+
async def handle_permission(self, request: PermissionRequest) -> bool:
|
|
566
|
+
"""Handle a permission request.
|
|
567
|
+
|
|
568
|
+
Args:
|
|
569
|
+
request: The permission request to handle
|
|
570
|
+
|
|
571
|
+
Returns:
|
|
572
|
+
bool: True if the user confirmed, False otherwise
|
|
573
|
+
"""
|
|
574
|
+
if not TEXTUAL_AVAILABLE:
|
|
575
|
+
logger.warning(
|
|
576
|
+
"textual_not_available_denying_request",
|
|
577
|
+
request_id=request.id,
|
|
578
|
+
tool_name=request.tool_name,
|
|
579
|
+
)
|
|
580
|
+
return False
|
|
581
|
+
|
|
582
|
+
try:
|
|
583
|
+
logger.info(
|
|
584
|
+
"handling_confirmation_request",
|
|
585
|
+
request_id=request.id,
|
|
586
|
+
tool_name=request.tool_name,
|
|
587
|
+
time_remaining=request.time_remaining(),
|
|
588
|
+
)
|
|
589
|
+
|
|
590
|
+
# Check if request has already expired
|
|
591
|
+
if request.time_remaining() <= 0:
|
|
592
|
+
logger.info("confirmation_request_expired", request_id=request.id)
|
|
593
|
+
return False
|
|
594
|
+
|
|
595
|
+
# Ensure processing task is running
|
|
596
|
+
self._ensure_processing_task_running()
|
|
597
|
+
|
|
598
|
+
# Queue request and wait for result
|
|
599
|
+
result = await self._queue_and_wait_for_result(request)
|
|
600
|
+
|
|
601
|
+
logger.info(
|
|
602
|
+
"confirmation_request_completed", request_id=request.id, result=result
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
return result
|
|
606
|
+
|
|
607
|
+
except Exception as e:
|
|
608
|
+
logger.error(
|
|
609
|
+
"confirmation_handling_error",
|
|
610
|
+
request_id=request.id,
|
|
611
|
+
error=str(e),
|
|
612
|
+
exc_info=e,
|
|
613
|
+
)
|
|
614
|
+
return False
|
|
615
|
+
|
|
616
|
+
def cancel_confirmation(self, request_id: str, reason: str = "cancelled") -> None:
|
|
617
|
+
"""Cancel an ongoing confirmation request.
|
|
618
|
+
|
|
619
|
+
Args:
|
|
620
|
+
request_id: The ID of the request to cancel
|
|
621
|
+
reason: The reason for cancellation
|
|
622
|
+
"""
|
|
623
|
+
logger.info("cancelling_confirmation", request_id=request_id, reason=reason)
|
|
624
|
+
self._cancelled_requests.add(request_id)
|
|
625
|
+
|
|
626
|
+
# If there's an active dialog for this request, close it immediately
|
|
627
|
+
if request_id in self._active_apps:
|
|
628
|
+
app = self._active_apps[request_id]
|
|
629
|
+
# Schedule the cancellation feedback asynchronously
|
|
630
|
+
create_fire_and_forget_task(
|
|
631
|
+
self._cancel_active_dialog(app, reason),
|
|
632
|
+
name="terminal_dialog_cancel",
|
|
633
|
+
creator="TerminalHandler",
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
async def _cancel_active_dialog(self, app: ConfirmationApp, reason: str) -> None:
|
|
637
|
+
"""Cancel an active dialog with visual feedback.
|
|
638
|
+
|
|
639
|
+
Args:
|
|
640
|
+
app: The active ConfirmationApp to cancel
|
|
641
|
+
reason: The reason for cancellation
|
|
642
|
+
"""
|
|
643
|
+
try:
|
|
644
|
+
# Determine the message and result based on reason
|
|
645
|
+
if "approved by another handler" in reason.lower():
|
|
646
|
+
message = "APPROVED BY ANOTHER HANDLER"
|
|
647
|
+
allowed = True
|
|
648
|
+
elif "denied by another handler" in reason.lower():
|
|
649
|
+
message = "DENIED BY ANOTHER HANDLER"
|
|
650
|
+
allowed = False
|
|
651
|
+
else:
|
|
652
|
+
message = f"CANCELLED - {reason.upper()}"
|
|
653
|
+
allowed = False
|
|
654
|
+
|
|
655
|
+
# Show visual feedback through the app's _show_result method
|
|
656
|
+
await app._show_result(allowed, message)
|
|
657
|
+
|
|
658
|
+
except Exception as e:
|
|
659
|
+
logger.error(
|
|
660
|
+
"cancel_dialog_error",
|
|
661
|
+
error=str(e),
|
|
662
|
+
exc_info=e,
|
|
663
|
+
)
|
|
664
|
+
# Fallback: just exit the app without feedback
|
|
665
|
+
with contextlib.suppress(Exception):
|
|
666
|
+
app.exit(False)
|
|
667
|
+
|
|
668
|
+
async def shutdown(self) -> None:
|
|
669
|
+
"""Shutdown the handler and cleanup resources."""
|
|
670
|
+
if self._processing_task and not self._processing_task.done():
|
|
671
|
+
self._processing_task.cancel()
|
|
672
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
673
|
+
await self._processing_task
|
|
674
|
+
|
|
675
|
+
self._processing_task = None
|