ccproxy-api 0.1.6__py3-none-any.whl → 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ccproxy/api/__init__.py +1 -15
- ccproxy/api/app.py +439 -212
- ccproxy/api/bootstrap.py +30 -0
- ccproxy/api/decorators.py +85 -0
- ccproxy/api/dependencies.py +145 -176
- ccproxy/api/format_validation.py +54 -0
- ccproxy/api/middleware/cors.py +6 -3
- ccproxy/api/middleware/errors.py +402 -530
- ccproxy/api/middleware/hooks.py +563 -0
- ccproxy/api/middleware/normalize_headers.py +59 -0
- ccproxy/api/middleware/request_id.py +35 -16
- ccproxy/api/middleware/streaming_hooks.py +292 -0
- ccproxy/api/routes/__init__.py +5 -14
- ccproxy/api/routes/health.py +39 -672
- ccproxy/api/routes/plugins.py +277 -0
- ccproxy/auth/__init__.py +2 -19
- ccproxy/auth/bearer.py +25 -15
- ccproxy/auth/dependencies.py +123 -157
- ccproxy/auth/exceptions.py +0 -12
- ccproxy/auth/manager.py +35 -49
- ccproxy/auth/managers/__init__.py +10 -0
- ccproxy/auth/managers/base.py +523 -0
- ccproxy/auth/managers/base_enhanced.py +63 -0
- ccproxy/auth/managers/token_snapshot.py +77 -0
- ccproxy/auth/models/base.py +65 -0
- ccproxy/auth/models/credentials.py +40 -0
- ccproxy/auth/oauth/__init__.py +4 -18
- ccproxy/auth/oauth/base.py +533 -0
- ccproxy/auth/oauth/cli_errors.py +37 -0
- ccproxy/auth/oauth/flows.py +430 -0
- ccproxy/auth/oauth/protocol.py +366 -0
- ccproxy/auth/oauth/registry.py +408 -0
- ccproxy/auth/oauth/router.py +396 -0
- ccproxy/auth/oauth/routes.py +186 -113
- ccproxy/auth/oauth/session.py +151 -0
- ccproxy/auth/oauth/templates.py +342 -0
- ccproxy/auth/storage/__init__.py +2 -5
- ccproxy/auth/storage/base.py +279 -5
- ccproxy/auth/storage/generic.py +134 -0
- ccproxy/cli/__init__.py +1 -2
- ccproxy/cli/_settings_help.py +351 -0
- ccproxy/cli/commands/auth.py +1519 -793
- ccproxy/cli/commands/config/commands.py +209 -276
- ccproxy/cli/commands/plugins.py +669 -0
- ccproxy/cli/commands/serve.py +75 -810
- ccproxy/cli/commands/status.py +254 -0
- ccproxy/cli/decorators.py +83 -0
- ccproxy/cli/helpers.py +22 -60
- ccproxy/cli/main.py +359 -10
- ccproxy/cli/options/claude_options.py +0 -25
- ccproxy/config/__init__.py +7 -11
- ccproxy/config/core.py +227 -0
- ccproxy/config/env_generator.py +232 -0
- ccproxy/config/runtime.py +67 -0
- ccproxy/config/security.py +36 -3
- ccproxy/config/settings.py +382 -441
- ccproxy/config/toml_generator.py +299 -0
- ccproxy/config/utils.py +452 -0
- ccproxy/core/__init__.py +7 -271
- ccproxy/{_version.py → core/_version.py} +16 -3
- ccproxy/core/async_task_manager.py +516 -0
- ccproxy/core/async_utils.py +47 -14
- ccproxy/core/auth/__init__.py +6 -0
- ccproxy/core/constants.py +16 -50
- ccproxy/core/errors.py +53 -0
- ccproxy/core/id_utils.py +20 -0
- ccproxy/core/interfaces.py +16 -123
- ccproxy/core/logging.py +473 -18
- ccproxy/core/plugins/__init__.py +77 -0
- ccproxy/core/plugins/cli_discovery.py +211 -0
- ccproxy/core/plugins/declaration.py +455 -0
- ccproxy/core/plugins/discovery.py +604 -0
- ccproxy/core/plugins/factories.py +967 -0
- ccproxy/core/plugins/hooks/__init__.py +30 -0
- ccproxy/core/plugins/hooks/base.py +58 -0
- ccproxy/core/plugins/hooks/events.py +46 -0
- ccproxy/core/plugins/hooks/implementations/__init__.py +16 -0
- ccproxy/core/plugins/hooks/implementations/formatters/__init__.py +11 -0
- ccproxy/core/plugins/hooks/implementations/formatters/json.py +552 -0
- ccproxy/core/plugins/hooks/implementations/formatters/raw.py +370 -0
- ccproxy/core/plugins/hooks/implementations/http_tracer.py +431 -0
- ccproxy/core/plugins/hooks/layers.py +44 -0
- ccproxy/core/plugins/hooks/manager.py +186 -0
- ccproxy/core/plugins/hooks/registry.py +139 -0
- ccproxy/core/plugins/hooks/thread_manager.py +203 -0
- ccproxy/core/plugins/hooks/types.py +22 -0
- ccproxy/core/plugins/interfaces.py +416 -0
- ccproxy/core/plugins/loader.py +166 -0
- ccproxy/core/plugins/middleware.py +233 -0
- ccproxy/core/plugins/models.py +59 -0
- ccproxy/core/plugins/protocol.py +180 -0
- ccproxy/core/plugins/runtime.py +519 -0
- ccproxy/{observability/context.py → core/request_context.py} +137 -94
- ccproxy/core/status_report.py +211 -0
- ccproxy/core/transformers.py +13 -8
- ccproxy/data/claude_headers_fallback.json +558 -0
- ccproxy/data/codex_headers_fallback.json +121 -0
- ccproxy/http/__init__.py +30 -0
- ccproxy/http/base.py +95 -0
- ccproxy/http/client.py +323 -0
- ccproxy/http/hooks.py +642 -0
- ccproxy/http/pool.py +279 -0
- ccproxy/llms/formatters/__init__.py +7 -0
- ccproxy/llms/formatters/anthropic_to_openai/__init__.py +55 -0
- ccproxy/llms/formatters/anthropic_to_openai/errors.py +65 -0
- ccproxy/llms/formatters/anthropic_to_openai/requests.py +356 -0
- ccproxy/llms/formatters/anthropic_to_openai/responses.py +153 -0
- ccproxy/llms/formatters/anthropic_to_openai/streams.py +1546 -0
- ccproxy/llms/formatters/base.py +140 -0
- ccproxy/llms/formatters/base_model.py +33 -0
- ccproxy/llms/formatters/common/__init__.py +51 -0
- ccproxy/llms/formatters/common/identifiers.py +48 -0
- ccproxy/llms/formatters/common/streams.py +254 -0
- ccproxy/llms/formatters/common/thinking.py +74 -0
- ccproxy/llms/formatters/common/usage.py +135 -0
- ccproxy/llms/formatters/constants.py +55 -0
- ccproxy/llms/formatters/context.py +116 -0
- ccproxy/llms/formatters/mapping.py +33 -0
- ccproxy/llms/formatters/openai_to_anthropic/__init__.py +55 -0
- ccproxy/llms/formatters/openai_to_anthropic/_helpers.py +141 -0
- ccproxy/llms/formatters/openai_to_anthropic/errors.py +53 -0
- ccproxy/llms/formatters/openai_to_anthropic/requests.py +674 -0
- ccproxy/llms/formatters/openai_to_anthropic/responses.py +285 -0
- ccproxy/llms/formatters/openai_to_anthropic/streams.py +530 -0
- ccproxy/llms/formatters/openai_to_openai/__init__.py +53 -0
- ccproxy/llms/formatters/openai_to_openai/_helpers.py +325 -0
- ccproxy/llms/formatters/openai_to_openai/errors.py +6 -0
- ccproxy/llms/formatters/openai_to_openai/requests.py +388 -0
- ccproxy/llms/formatters/openai_to_openai/responses.py +594 -0
- ccproxy/llms/formatters/openai_to_openai/streams.py +1832 -0
- ccproxy/llms/formatters/utils.py +306 -0
- ccproxy/llms/models/__init__.py +9 -0
- ccproxy/llms/models/anthropic.py +619 -0
- ccproxy/llms/models/openai.py +844 -0
- ccproxy/llms/streaming/__init__.py +26 -0
- ccproxy/llms/streaming/accumulators.py +1074 -0
- ccproxy/llms/streaming/formatters.py +251 -0
- ccproxy/{adapters/openai/streaming.py → llms/streaming/processors.py} +193 -240
- ccproxy/models/__init__.py +8 -159
- ccproxy/models/detection.py +92 -193
- ccproxy/models/provider.py +75 -0
- ccproxy/plugins/access_log/README.md +32 -0
- ccproxy/plugins/access_log/__init__.py +20 -0
- ccproxy/plugins/access_log/config.py +33 -0
- ccproxy/plugins/access_log/formatter.py +126 -0
- ccproxy/plugins/access_log/hook.py +763 -0
- ccproxy/plugins/access_log/logger.py +254 -0
- ccproxy/plugins/access_log/plugin.py +137 -0
- ccproxy/plugins/access_log/writer.py +109 -0
- ccproxy/plugins/analytics/README.md +24 -0
- ccproxy/plugins/analytics/__init__.py +1 -0
- ccproxy/plugins/analytics/config.py +5 -0
- ccproxy/plugins/analytics/ingest.py +85 -0
- ccproxy/plugins/analytics/models.py +97 -0
- ccproxy/plugins/analytics/plugin.py +121 -0
- ccproxy/plugins/analytics/routes.py +163 -0
- ccproxy/plugins/analytics/service.py +284 -0
- ccproxy/plugins/claude_api/README.md +29 -0
- ccproxy/plugins/claude_api/__init__.py +10 -0
- ccproxy/plugins/claude_api/adapter.py +829 -0
- ccproxy/plugins/claude_api/config.py +52 -0
- ccproxy/plugins/claude_api/detection_service.py +461 -0
- ccproxy/plugins/claude_api/health.py +175 -0
- ccproxy/plugins/claude_api/hooks.py +284 -0
- ccproxy/plugins/claude_api/models.py +256 -0
- ccproxy/plugins/claude_api/plugin.py +298 -0
- ccproxy/plugins/claude_api/routes.py +118 -0
- ccproxy/plugins/claude_api/streaming_metrics.py +68 -0
- ccproxy/plugins/claude_api/tasks.py +84 -0
- ccproxy/plugins/claude_sdk/README.md +35 -0
- ccproxy/plugins/claude_sdk/__init__.py +80 -0
- ccproxy/plugins/claude_sdk/adapter.py +749 -0
- ccproxy/plugins/claude_sdk/auth.py +57 -0
- ccproxy/{claude_sdk → plugins/claude_sdk}/client.py +63 -39
- ccproxy/plugins/claude_sdk/config.py +210 -0
- ccproxy/{claude_sdk → plugins/claude_sdk}/converter.py +6 -6
- ccproxy/plugins/claude_sdk/detection_service.py +163 -0
- ccproxy/{services/claude_sdk_service.py → plugins/claude_sdk/handler.py} +123 -304
- ccproxy/plugins/claude_sdk/health.py +113 -0
- ccproxy/plugins/claude_sdk/hooks.py +115 -0
- ccproxy/{claude_sdk → plugins/claude_sdk}/manager.py +42 -32
- ccproxy/{claude_sdk → plugins/claude_sdk}/message_queue.py +8 -8
- ccproxy/{models/claude_sdk.py → plugins/claude_sdk/models.py} +64 -16
- ccproxy/plugins/claude_sdk/options.py +154 -0
- ccproxy/{claude_sdk → plugins/claude_sdk}/parser.py +23 -5
- ccproxy/plugins/claude_sdk/plugin.py +269 -0
- ccproxy/plugins/claude_sdk/routes.py +104 -0
- ccproxy/{claude_sdk → plugins/claude_sdk}/session_client.py +124 -12
- ccproxy/plugins/claude_sdk/session_pool.py +700 -0
- ccproxy/{claude_sdk → plugins/claude_sdk}/stream_handle.py +48 -43
- ccproxy/{claude_sdk → plugins/claude_sdk}/stream_worker.py +22 -18
- ccproxy/{claude_sdk → plugins/claude_sdk}/streaming.py +50 -16
- ccproxy/plugins/claude_sdk/tasks.py +97 -0
- ccproxy/plugins/claude_shared/README.md +18 -0
- ccproxy/plugins/claude_shared/__init__.py +12 -0
- ccproxy/plugins/claude_shared/model_defaults.py +171 -0
- ccproxy/plugins/codex/README.md +35 -0
- ccproxy/plugins/codex/__init__.py +6 -0
- ccproxy/plugins/codex/adapter.py +635 -0
- ccproxy/{config/codex.py → plugins/codex/config.py} +78 -12
- ccproxy/plugins/codex/detection_service.py +544 -0
- ccproxy/plugins/codex/health.py +162 -0
- ccproxy/plugins/codex/hooks.py +263 -0
- ccproxy/plugins/codex/model_defaults.py +39 -0
- ccproxy/plugins/codex/models.py +263 -0
- ccproxy/plugins/codex/plugin.py +275 -0
- ccproxy/plugins/codex/routes.py +129 -0
- ccproxy/plugins/codex/streaming_metrics.py +324 -0
- ccproxy/plugins/codex/tasks.py +106 -0
- ccproxy/plugins/codex/utils/__init__.py +1 -0
- ccproxy/plugins/codex/utils/sse_parser.py +106 -0
- ccproxy/plugins/command_replay/README.md +34 -0
- ccproxy/plugins/command_replay/__init__.py +17 -0
- ccproxy/plugins/command_replay/config.py +133 -0
- ccproxy/plugins/command_replay/formatter.py +432 -0
- ccproxy/plugins/command_replay/hook.py +294 -0
- ccproxy/plugins/command_replay/plugin.py +161 -0
- ccproxy/plugins/copilot/README.md +39 -0
- ccproxy/plugins/copilot/__init__.py +11 -0
- ccproxy/plugins/copilot/adapter.py +465 -0
- ccproxy/plugins/copilot/config.py +155 -0
- ccproxy/plugins/copilot/data/copilot_fallback.json +41 -0
- ccproxy/plugins/copilot/detection_service.py +255 -0
- ccproxy/plugins/copilot/manager.py +275 -0
- ccproxy/plugins/copilot/model_defaults.py +284 -0
- ccproxy/plugins/copilot/models.py +148 -0
- ccproxy/plugins/copilot/oauth/__init__.py +16 -0
- ccproxy/plugins/copilot/oauth/client.py +494 -0
- ccproxy/plugins/copilot/oauth/models.py +385 -0
- ccproxy/plugins/copilot/oauth/provider.py +602 -0
- ccproxy/plugins/copilot/oauth/storage.py +170 -0
- ccproxy/plugins/copilot/plugin.py +360 -0
- ccproxy/plugins/copilot/routes.py +294 -0
- ccproxy/plugins/credential_balancer/README.md +124 -0
- ccproxy/plugins/credential_balancer/__init__.py +6 -0
- ccproxy/plugins/credential_balancer/config.py +270 -0
- ccproxy/plugins/credential_balancer/factory.py +415 -0
- ccproxy/plugins/credential_balancer/hook.py +51 -0
- ccproxy/plugins/credential_balancer/manager.py +587 -0
- ccproxy/plugins/credential_balancer/plugin.py +146 -0
- ccproxy/plugins/dashboard/README.md +25 -0
- ccproxy/plugins/dashboard/__init__.py +1 -0
- ccproxy/plugins/dashboard/config.py +8 -0
- ccproxy/plugins/dashboard/plugin.py +71 -0
- ccproxy/plugins/dashboard/routes.py +67 -0
- ccproxy/plugins/docker/README.md +32 -0
- ccproxy/{docker → plugins/docker}/__init__.py +3 -0
- ccproxy/{docker → plugins/docker}/adapter.py +108 -10
- ccproxy/plugins/docker/config.py +82 -0
- ccproxy/{docker → plugins/docker}/docker_path.py +4 -3
- ccproxy/{docker → plugins/docker}/middleware.py +2 -2
- ccproxy/plugins/docker/plugin.py +198 -0
- ccproxy/{docker → plugins/docker}/stream_process.py +3 -3
- ccproxy/plugins/duckdb_storage/README.md +26 -0
- ccproxy/plugins/duckdb_storage/__init__.py +1 -0
- ccproxy/plugins/duckdb_storage/config.py +22 -0
- ccproxy/plugins/duckdb_storage/plugin.py +128 -0
- ccproxy/plugins/duckdb_storage/routes.py +51 -0
- ccproxy/plugins/duckdb_storage/storage.py +633 -0
- ccproxy/plugins/max_tokens/README.md +38 -0
- ccproxy/plugins/max_tokens/__init__.py +12 -0
- ccproxy/plugins/max_tokens/adapter.py +235 -0
- ccproxy/plugins/max_tokens/config.py +86 -0
- ccproxy/plugins/max_tokens/models.py +53 -0
- ccproxy/plugins/max_tokens/plugin.py +200 -0
- ccproxy/plugins/max_tokens/service.py +271 -0
- ccproxy/plugins/max_tokens/token_limits.json +54 -0
- ccproxy/plugins/metrics/README.md +35 -0
- ccproxy/plugins/metrics/__init__.py +10 -0
- ccproxy/{observability/metrics.py → plugins/metrics/collector.py} +20 -153
- ccproxy/plugins/metrics/config.py +85 -0
- ccproxy/plugins/metrics/grafana/dashboards/ccproxy-dashboard.json +1720 -0
- ccproxy/plugins/metrics/hook.py +403 -0
- ccproxy/plugins/metrics/plugin.py +268 -0
- ccproxy/{observability → plugins/metrics}/pushgateway.py +57 -59
- ccproxy/plugins/metrics/routes.py +107 -0
- ccproxy/plugins/metrics/tasks.py +117 -0
- ccproxy/plugins/oauth_claude/README.md +35 -0
- ccproxy/plugins/oauth_claude/__init__.py +14 -0
- ccproxy/plugins/oauth_claude/client.py +270 -0
- ccproxy/plugins/oauth_claude/config.py +84 -0
- ccproxy/plugins/oauth_claude/manager.py +482 -0
- ccproxy/plugins/oauth_claude/models.py +266 -0
- ccproxy/plugins/oauth_claude/plugin.py +149 -0
- ccproxy/plugins/oauth_claude/provider.py +571 -0
- ccproxy/plugins/oauth_claude/storage.py +212 -0
- ccproxy/plugins/oauth_codex/README.md +38 -0
- ccproxy/plugins/oauth_codex/__init__.py +14 -0
- ccproxy/plugins/oauth_codex/client.py +224 -0
- ccproxy/plugins/oauth_codex/config.py +95 -0
- ccproxy/plugins/oauth_codex/manager.py +256 -0
- ccproxy/plugins/oauth_codex/models.py +239 -0
- ccproxy/plugins/oauth_codex/plugin.py +146 -0
- ccproxy/plugins/oauth_codex/provider.py +574 -0
- ccproxy/plugins/oauth_codex/storage.py +92 -0
- ccproxy/plugins/permissions/README.md +28 -0
- ccproxy/plugins/permissions/__init__.py +22 -0
- ccproxy/plugins/permissions/config.py +28 -0
- ccproxy/{cli/commands/permission_handler.py → plugins/permissions/handlers/cli.py} +49 -25
- ccproxy/plugins/permissions/handlers/protocol.py +33 -0
- ccproxy/plugins/permissions/handlers/terminal.py +675 -0
- ccproxy/{api/routes → plugins/permissions}/mcp.py +34 -7
- ccproxy/{models/permissions.py → plugins/permissions/models.py} +65 -1
- ccproxy/plugins/permissions/plugin.py +153 -0
- ccproxy/{api/routes/permissions.py → plugins/permissions/routes.py} +20 -16
- ccproxy/{api/services/permission_service.py → plugins/permissions/service.py} +65 -11
- ccproxy/{api → plugins/permissions}/ui/permission_handler_protocol.py +1 -1
- ccproxy/{api → plugins/permissions}/ui/terminal_permission_handler.py +66 -10
- ccproxy/plugins/pricing/README.md +34 -0
- ccproxy/plugins/pricing/__init__.py +6 -0
- ccproxy/{pricing → plugins/pricing}/cache.py +7 -6
- ccproxy/{config/pricing.py → plugins/pricing/config.py} +32 -6
- ccproxy/plugins/pricing/exceptions.py +35 -0
- ccproxy/plugins/pricing/loader.py +440 -0
- ccproxy/{pricing → plugins/pricing}/models.py +13 -23
- ccproxy/plugins/pricing/plugin.py +169 -0
- ccproxy/plugins/pricing/service.py +191 -0
- ccproxy/plugins/pricing/tasks.py +300 -0
- ccproxy/{pricing → plugins/pricing}/updater.py +86 -72
- ccproxy/plugins/pricing/utils.py +99 -0
- ccproxy/plugins/request_tracer/README.md +40 -0
- ccproxy/plugins/request_tracer/__init__.py +7 -0
- ccproxy/plugins/request_tracer/config.py +120 -0
- ccproxy/plugins/request_tracer/hook.py +415 -0
- ccproxy/plugins/request_tracer/plugin.py +255 -0
- ccproxy/scheduler/__init__.py +2 -14
- ccproxy/scheduler/core.py +26 -41
- ccproxy/scheduler/manager.py +63 -107
- ccproxy/scheduler/registry.py +6 -32
- ccproxy/scheduler/tasks.py +346 -314
- ccproxy/services/__init__.py +0 -1
- ccproxy/services/adapters/__init__.py +11 -0
- ccproxy/services/adapters/base.py +123 -0
- ccproxy/services/adapters/chain_composer.py +88 -0
- ccproxy/services/adapters/chain_validation.py +44 -0
- ccproxy/services/adapters/chat_accumulator.py +200 -0
- ccproxy/services/adapters/delta_utils.py +142 -0
- ccproxy/services/adapters/format_adapter.py +136 -0
- ccproxy/services/adapters/format_context.py +11 -0
- ccproxy/services/adapters/format_registry.py +158 -0
- ccproxy/services/adapters/http_adapter.py +1045 -0
- ccproxy/services/adapters/mock_adapter.py +118 -0
- ccproxy/services/adapters/protocols.py +35 -0
- ccproxy/services/adapters/simple_converters.py +571 -0
- ccproxy/services/auth_registry.py +180 -0
- ccproxy/services/cache/__init__.py +6 -0
- ccproxy/services/cache/response_cache.py +261 -0
- ccproxy/services/cli_detection.py +437 -0
- ccproxy/services/config/__init__.py +6 -0
- ccproxy/services/config/proxy_configuration.py +111 -0
- ccproxy/services/container.py +256 -0
- ccproxy/services/factories.py +380 -0
- ccproxy/services/handler_config.py +76 -0
- ccproxy/services/interfaces.py +298 -0
- ccproxy/services/mocking/__init__.py +6 -0
- ccproxy/services/mocking/mock_handler.py +291 -0
- ccproxy/services/tracing/__init__.py +7 -0
- ccproxy/services/tracing/interfaces.py +61 -0
- ccproxy/services/tracing/null_tracer.py +57 -0
- ccproxy/streaming/__init__.py +23 -0
- ccproxy/streaming/buffer.py +1056 -0
- ccproxy/streaming/deferred.py +897 -0
- ccproxy/streaming/handler.py +117 -0
- ccproxy/streaming/interfaces.py +77 -0
- ccproxy/streaming/simple_adapter.py +39 -0
- ccproxy/streaming/sse.py +109 -0
- ccproxy/streaming/sse_parser.py +127 -0
- ccproxy/templates/__init__.py +6 -0
- ccproxy/templates/plugin_scaffold.py +695 -0
- ccproxy/testing/endpoints/__init__.py +33 -0
- ccproxy/testing/endpoints/cli.py +215 -0
- ccproxy/testing/endpoints/config.py +874 -0
- ccproxy/testing/endpoints/console.py +57 -0
- ccproxy/testing/endpoints/models.py +100 -0
- ccproxy/testing/endpoints/runner.py +1903 -0
- ccproxy/testing/endpoints/tools.py +308 -0
- ccproxy/testing/mock_responses.py +70 -1
- ccproxy/testing/response_handlers.py +20 -0
- ccproxy/utils/__init__.py +0 -6
- ccproxy/utils/binary_resolver.py +476 -0
- ccproxy/utils/caching.py +327 -0
- ccproxy/utils/cli_logging.py +101 -0
- ccproxy/utils/command_line.py +251 -0
- ccproxy/utils/headers.py +228 -0
- ccproxy/utils/model_mapper.py +120 -0
- ccproxy/utils/startup_helpers.py +95 -342
- ccproxy/utils/version_checker.py +279 -6
- ccproxy_api-0.2.0.dist-info/METADATA +212 -0
- ccproxy_api-0.2.0.dist-info/RECORD +417 -0
- {ccproxy_api-0.1.6.dist-info → ccproxy_api-0.2.0.dist-info}/WHEEL +1 -1
- ccproxy_api-0.2.0.dist-info/entry_points.txt +24 -0
- ccproxy/__init__.py +0 -4
- ccproxy/adapters/__init__.py +0 -11
- ccproxy/adapters/base.py +0 -80
- ccproxy/adapters/codex/__init__.py +0 -11
- ccproxy/adapters/openai/__init__.py +0 -42
- ccproxy/adapters/openai/adapter.py +0 -953
- ccproxy/adapters/openai/models.py +0 -412
- ccproxy/adapters/openai/response_adapter.py +0 -355
- ccproxy/adapters/openai/response_models.py +0 -178
- ccproxy/api/middleware/headers.py +0 -49
- ccproxy/api/middleware/logging.py +0 -180
- ccproxy/api/middleware/request_content_logging.py +0 -297
- ccproxy/api/middleware/server_header.py +0 -58
- ccproxy/api/responses.py +0 -89
- ccproxy/api/routes/claude.py +0 -371
- ccproxy/api/routes/codex.py +0 -1231
- ccproxy/api/routes/metrics.py +0 -1029
- ccproxy/api/routes/proxy.py +0 -211
- ccproxy/api/services/__init__.py +0 -6
- ccproxy/auth/conditional.py +0 -84
- ccproxy/auth/credentials_adapter.py +0 -93
- ccproxy/auth/models.py +0 -118
- ccproxy/auth/oauth/models.py +0 -48
- ccproxy/auth/openai/__init__.py +0 -13
- ccproxy/auth/openai/credentials.py +0 -166
- ccproxy/auth/openai/oauth_client.py +0 -334
- ccproxy/auth/openai/storage.py +0 -184
- ccproxy/auth/storage/json_file.py +0 -158
- ccproxy/auth/storage/keyring.py +0 -189
- ccproxy/claude_sdk/__init__.py +0 -18
- ccproxy/claude_sdk/options.py +0 -194
- ccproxy/claude_sdk/session_pool.py +0 -550
- ccproxy/cli/docker/__init__.py +0 -34
- ccproxy/cli/docker/adapter_factory.py +0 -157
- ccproxy/cli/docker/params.py +0 -274
- ccproxy/config/auth.py +0 -153
- ccproxy/config/claude.py +0 -348
- ccproxy/config/cors.py +0 -79
- ccproxy/config/discovery.py +0 -95
- ccproxy/config/docker_settings.py +0 -264
- ccproxy/config/observability.py +0 -158
- ccproxy/config/reverse_proxy.py +0 -31
- ccproxy/config/scheduler.py +0 -108
- ccproxy/config/server.py +0 -86
- ccproxy/config/validators.py +0 -231
- ccproxy/core/codex_transformers.py +0 -389
- ccproxy/core/http.py +0 -328
- ccproxy/core/http_transformers.py +0 -812
- ccproxy/core/proxy.py +0 -143
- ccproxy/core/validators.py +0 -288
- ccproxy/models/errors.py +0 -42
- ccproxy/models/messages.py +0 -269
- ccproxy/models/requests.py +0 -107
- ccproxy/models/responses.py +0 -270
- ccproxy/models/types.py +0 -102
- ccproxy/observability/__init__.py +0 -51
- ccproxy/observability/access_logger.py +0 -457
- ccproxy/observability/sse_events.py +0 -303
- ccproxy/observability/stats_printer.py +0 -753
- ccproxy/observability/storage/__init__.py +0 -1
- ccproxy/observability/storage/duckdb_simple.py +0 -677
- ccproxy/observability/storage/models.py +0 -70
- ccproxy/observability/streaming_response.py +0 -107
- ccproxy/pricing/__init__.py +0 -19
- ccproxy/pricing/loader.py +0 -251
- ccproxy/services/claude_detection_service.py +0 -269
- ccproxy/services/codex_detection_service.py +0 -263
- ccproxy/services/credentials/__init__.py +0 -55
- ccproxy/services/credentials/config.py +0 -105
- ccproxy/services/credentials/manager.py +0 -561
- ccproxy/services/credentials/oauth_client.py +0 -481
- ccproxy/services/proxy_service.py +0 -1827
- ccproxy/static/.keep +0 -0
- ccproxy/utils/cost_calculator.py +0 -210
- ccproxy/utils/disconnection_monitor.py +0 -83
- ccproxy/utils/model_mapping.py +0 -199
- ccproxy/utils/models_provider.py +0 -150
- ccproxy/utils/simple_request_logger.py +0 -284
- ccproxy/utils/streaming_metrics.py +0 -199
- ccproxy_api-0.1.6.dist-info/METADATA +0 -615
- ccproxy_api-0.1.6.dist-info/RECORD +0 -189
- ccproxy_api-0.1.6.dist-info/entry_points.txt +0 -4
- /ccproxy/{api/middleware/auth.py → auth/models/__init__.py} +0 -0
- /ccproxy/{claude_sdk → plugins/claude_sdk}/exceptions.py +0 -0
- /ccproxy/{docker → plugins/docker}/models.py +0 -0
- /ccproxy/{docker → plugins/docker}/protocol.py +0 -0
- /ccproxy/{docker → plugins/docker}/validators.py +0 -0
- /ccproxy/{auth/oauth/storage.py → plugins/permissions/handlers/__init__.py} +0 -0
- /ccproxy/{api → plugins/permissions}/ui/__init__.py +0 -0
- {ccproxy_api-0.1.6.dist-info → ccproxy_api-0.2.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,700 @@
|
|
|
1
|
+
"""Session-aware connection pool for persistent Claude SDK connections."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import contextlib
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
|
+
|
|
9
|
+
from claude_agent_sdk import ClaudeAgentOptions
|
|
10
|
+
|
|
11
|
+
from ccproxy.core.async_task_manager import create_managed_task
|
|
12
|
+
from ccproxy.core.errors import ClaudeProxyError, ServiceUnavailableError
|
|
13
|
+
from ccproxy.core.logging import get_plugin_logger
|
|
14
|
+
|
|
15
|
+
from .config import SessionPoolSettings
|
|
16
|
+
from .session_client import SessionClient, SessionStatus
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
logger = get_plugin_logger()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _trace(message: str, **kwargs: Any) -> None:
|
|
27
|
+
"""Trace-level logger helper with debug fallback.
|
|
28
|
+
|
|
29
|
+
Some environments/tests may not configure a TRACE level; in that case
|
|
30
|
+
fall back to debug to avoid AttributeError on logger.trace.
|
|
31
|
+
"""
|
|
32
|
+
if hasattr(logger, "trace"):
|
|
33
|
+
logger.trace(message, **kwargs)
|
|
34
|
+
else:
|
|
35
|
+
logger.debug(message, **kwargs)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class SessionPool:
|
|
39
|
+
"""Manages persistent Claude SDK connections by session."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, config: SessionPoolSettings | None = None):
|
|
42
|
+
self.config = config or SessionPoolSettings()
|
|
43
|
+
self.sessions: dict[str, SessionClient] = {}
|
|
44
|
+
self.cleanup_task: asyncio.Task[None] | None = None
|
|
45
|
+
self._shutdown = False
|
|
46
|
+
self._lock = asyncio.Lock()
|
|
47
|
+
|
|
48
|
+
async def start(self) -> None:
|
|
49
|
+
"""Start the session pool and cleanup task."""
|
|
50
|
+
if not self.config.enabled:
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
logger.debug(
|
|
54
|
+
"session_pool_starting",
|
|
55
|
+
max_sessions=self.config.max_sessions,
|
|
56
|
+
ttl=self.config.session_ttl,
|
|
57
|
+
cleanup_interval=self.config.cleanup_interval,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
self.cleanup_task = await create_managed_task(
|
|
61
|
+
self._cleanup_loop(),
|
|
62
|
+
name="session_pool_cleanup",
|
|
63
|
+
creator="SessionPool",
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
async def stop(self) -> None:
|
|
67
|
+
"""Stop the session pool and cleanup all sessions."""
|
|
68
|
+
self._shutdown = True
|
|
69
|
+
|
|
70
|
+
if self.cleanup_task:
|
|
71
|
+
self.cleanup_task.cancel()
|
|
72
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
73
|
+
await self.cleanup_task
|
|
74
|
+
|
|
75
|
+
# Disconnect all active sessions
|
|
76
|
+
async with self._lock:
|
|
77
|
+
disconnect_tasks = [
|
|
78
|
+
session_client.disconnect() for session_client in self.sessions.values()
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
if disconnect_tasks:
|
|
82
|
+
await asyncio.gather(*disconnect_tasks, return_exceptions=True)
|
|
83
|
+
|
|
84
|
+
self.sessions.clear()
|
|
85
|
+
|
|
86
|
+
logger.debug("session_pool_stopped")
|
|
87
|
+
|
|
88
|
+
async def get_session_client(
|
|
89
|
+
self, session_id: str, options: ClaudeAgentOptions
|
|
90
|
+
) -> SessionClient:
|
|
91
|
+
"""Get or create a session context for the given session_id."""
|
|
92
|
+
logger.debug(
|
|
93
|
+
"session_pool_get_client_start",
|
|
94
|
+
session_id=session_id,
|
|
95
|
+
pool_enabled=self.config.enabled,
|
|
96
|
+
current_sessions=len(self.sessions),
|
|
97
|
+
max_sessions=self.config.max_sessions,
|
|
98
|
+
session_exists=session_id in self.sessions,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# Validate pool is enabled
|
|
102
|
+
self._validate_pool_enabled(session_id)
|
|
103
|
+
|
|
104
|
+
# Get or create session with proper locking
|
|
105
|
+
async with self._lock:
|
|
106
|
+
session_client = await self._get_or_create_session(session_id, options)
|
|
107
|
+
|
|
108
|
+
# Ensure connected before returning
|
|
109
|
+
await self._ensure_session_connected(session_client, session_id)
|
|
110
|
+
|
|
111
|
+
logger.debug(
|
|
112
|
+
"session_pool_get_client_complete",
|
|
113
|
+
session_id=session_id,
|
|
114
|
+
client_id=session_client.client_id,
|
|
115
|
+
session_status=session_client.status,
|
|
116
|
+
session_age_seconds=session_client.metrics.age_seconds,
|
|
117
|
+
session_message_count=session_client.metrics.message_count,
|
|
118
|
+
)
|
|
119
|
+
return session_client
|
|
120
|
+
|
|
121
|
+
def _validate_pool_enabled(self, session_id: str) -> None:
|
|
122
|
+
"""Validate that the session pool is enabled."""
|
|
123
|
+
if not self.config.enabled:
|
|
124
|
+
logger.error("session_pool_disabled", session_id=session_id)
|
|
125
|
+
raise ClaudeProxyError(
|
|
126
|
+
message="Session pool is disabled",
|
|
127
|
+
error_type="configuration_error",
|
|
128
|
+
status_code=500,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
async def _get_or_create_session(
|
|
132
|
+
self, session_id: str, options: ClaudeAgentOptions
|
|
133
|
+
) -> SessionClient:
|
|
134
|
+
"""Get existing session or create new one (requires lock)."""
|
|
135
|
+
# Check capacity limits for new sessions
|
|
136
|
+
if (
|
|
137
|
+
session_id not in self.sessions
|
|
138
|
+
and len(self.sessions) >= self.config.max_sessions
|
|
139
|
+
):
|
|
140
|
+
logger.error(
|
|
141
|
+
"session_pool_at_capacity",
|
|
142
|
+
session_id=session_id,
|
|
143
|
+
current_sessions=len(self.sessions),
|
|
144
|
+
max_sessions=self.config.max_sessions,
|
|
145
|
+
)
|
|
146
|
+
raise ServiceUnavailableError(
|
|
147
|
+
f"Session pool at capacity: {self.config.max_sessions}"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
options.continue_conversation = True
|
|
151
|
+
|
|
152
|
+
# Route to existing or new session
|
|
153
|
+
if session_id in self.sessions:
|
|
154
|
+
return await self._handle_existing_session(session_id, options)
|
|
155
|
+
else:
|
|
156
|
+
logger.debug("session_pool_creating_new_session", session_id=session_id)
|
|
157
|
+
return await self._create_session_unlocked(session_id, options)
|
|
158
|
+
|
|
159
|
+
async def _handle_existing_session(
|
|
160
|
+
self, session_id: str, options: ClaudeAgentOptions
|
|
161
|
+
) -> SessionClient:
|
|
162
|
+
"""Handle an existing session based on its state (requires lock)."""
|
|
163
|
+
session_client = self.sessions[session_id]
|
|
164
|
+
logger.debug(
|
|
165
|
+
"session_pool_existing_session_found",
|
|
166
|
+
session_id=session_id,
|
|
167
|
+
client_id=session_client.client_id,
|
|
168
|
+
session_status=session_client.status.value,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
# Handle interrupting sessions
|
|
172
|
+
if session_client.status.value == "interrupting":
|
|
173
|
+
return await self._handle_interrupting_session(
|
|
174
|
+
session_id, session_client, options
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
# Handle active streams
|
|
178
|
+
if session_client.has_active_stream or session_client.active_stream_handle:
|
|
179
|
+
return await self._handle_active_stream(session_id, session_client, options)
|
|
180
|
+
|
|
181
|
+
# Handle expired or unhealthy sessions
|
|
182
|
+
return await self._handle_expired_or_unhealthy(
|
|
183
|
+
session_id, session_client, options
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
async def _handle_interrupting_session(
|
|
187
|
+
self,
|
|
188
|
+
session_id: str,
|
|
189
|
+
session_client: SessionClient,
|
|
190
|
+
options: ClaudeAgentOptions,
|
|
191
|
+
) -> SessionClient:
|
|
192
|
+
"""Handle a session that is currently being interrupted (requires lock)."""
|
|
193
|
+
logger.warning(
|
|
194
|
+
"session_pool_interrupting_session",
|
|
195
|
+
session_id=session_id,
|
|
196
|
+
client_id=session_client.client_id,
|
|
197
|
+
message="Session is currently being interrupted, waiting for completion then creating new session",
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
# Wait for the interrupt process to complete
|
|
201
|
+
interrupt_completed = await session_client.wait_for_interrupt_complete(
|
|
202
|
+
timeout=5.0
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
if interrupt_completed:
|
|
206
|
+
logger.debug(
|
|
207
|
+
"session_pool_interrupt_completed",
|
|
208
|
+
session_id=session_id,
|
|
209
|
+
client_id=session_client.client_id,
|
|
210
|
+
message="Interrupt completed successfully, proceeding with session replacement",
|
|
211
|
+
)
|
|
212
|
+
else:
|
|
213
|
+
logger.warning(
|
|
214
|
+
"session_pool_interrupt_timeout",
|
|
215
|
+
session_id=session_id,
|
|
216
|
+
client_id=session_client.client_id,
|
|
217
|
+
message="Interrupt did not complete within 5 seconds, proceeding anyway",
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
# Don't try to reuse a session that was being interrupted
|
|
221
|
+
await self._remove_session_unlocked(session_id)
|
|
222
|
+
return await self._create_session_unlocked(session_id, options)
|
|
223
|
+
|
|
224
|
+
async def _handle_active_stream(
|
|
225
|
+
self,
|
|
226
|
+
session_id: str,
|
|
227
|
+
session_client: SessionClient,
|
|
228
|
+
options: ClaudeAgentOptions,
|
|
229
|
+
) -> SessionClient:
|
|
230
|
+
"""Handle a session with an active stream (requires lock)."""
|
|
231
|
+
logger.debug(
|
|
232
|
+
"session_pool_active_stream_detected",
|
|
233
|
+
session_id=session_id,
|
|
234
|
+
client_id=session_client.client_id,
|
|
235
|
+
has_stream=session_client.has_active_stream,
|
|
236
|
+
has_handle=bool(session_client.active_stream_handle),
|
|
237
|
+
idle_seconds=session_client.metrics.idle_seconds,
|
|
238
|
+
message="Session has active stream/handle, checking if cleanup needed",
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
# Check for stream timeouts
|
|
242
|
+
is_first_chunk_timeout, is_ongoing_timeout = self._check_stream_timeouts(
|
|
243
|
+
session_client
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
if session_client.active_stream_handle and (
|
|
247
|
+
is_first_chunk_timeout or is_ongoing_timeout
|
|
248
|
+
):
|
|
249
|
+
if is_first_chunk_timeout:
|
|
250
|
+
return await self._handle_first_chunk_timeout(
|
|
251
|
+
session_id, session_client, options
|
|
252
|
+
)
|
|
253
|
+
elif is_ongoing_timeout:
|
|
254
|
+
await self._handle_ongoing_timeout(session_id, session_client)
|
|
255
|
+
# Session continues after stream interrupt
|
|
256
|
+
elif session_client.active_stream_handle:
|
|
257
|
+
# Stream is recent, clear without interrupting
|
|
258
|
+
self._clear_recent_stream(session_id, session_client)
|
|
259
|
+
else:
|
|
260
|
+
# No handle but flag is set, just clear the flag
|
|
261
|
+
session_client.has_active_stream = False
|
|
262
|
+
|
|
263
|
+
logger.debug(
|
|
264
|
+
"session_pool_stream_cleared",
|
|
265
|
+
session_id=session_id,
|
|
266
|
+
client_id=session_client.client_id,
|
|
267
|
+
was_interrupted=(is_first_chunk_timeout or is_ongoing_timeout),
|
|
268
|
+
was_recent=not (is_first_chunk_timeout or is_ongoing_timeout),
|
|
269
|
+
was_first_chunk_timeout=is_first_chunk_timeout,
|
|
270
|
+
was_ongoing_timeout=is_ongoing_timeout,
|
|
271
|
+
message="Stream state cleared, session ready for reuse",
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
# After clearing stream, continue with normal session handling
|
|
275
|
+
return await self._handle_expired_or_unhealthy(
|
|
276
|
+
session_id, session_client, options
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
def _check_stream_timeouts(
|
|
280
|
+
self, session_client: SessionClient
|
|
281
|
+
) -> tuple[bool, bool]:
|
|
282
|
+
"""Check for stream timeout conditions."""
|
|
283
|
+
handle = session_client.active_stream_handle
|
|
284
|
+
if handle is not None:
|
|
285
|
+
is_first_chunk_timeout = handle.is_first_chunk_timeout()
|
|
286
|
+
is_ongoing_timeout = handle.is_ongoing_timeout()
|
|
287
|
+
else:
|
|
288
|
+
# Handle was cleared by another thread
|
|
289
|
+
is_first_chunk_timeout = False
|
|
290
|
+
is_ongoing_timeout = False
|
|
291
|
+
|
|
292
|
+
return is_first_chunk_timeout, is_ongoing_timeout
|
|
293
|
+
|
|
294
|
+
async def _handle_first_chunk_timeout(
|
|
295
|
+
self,
|
|
296
|
+
session_id: str,
|
|
297
|
+
session_client: SessionClient,
|
|
298
|
+
options: ClaudeAgentOptions,
|
|
299
|
+
) -> SessionClient:
|
|
300
|
+
"""Handle first chunk timeout - terminate and recreate session (requires lock)."""
|
|
301
|
+
old_handle_id = session_client.active_stream_handle.handle_id
|
|
302
|
+
|
|
303
|
+
logger.warning(
|
|
304
|
+
"session_pool_first_chunk_timeout",
|
|
305
|
+
session_id=session_id,
|
|
306
|
+
old_handle_id=old_handle_id,
|
|
307
|
+
idle_seconds=session_client.active_stream_handle.idle_seconds,
|
|
308
|
+
detail=f"No first chunk received within {self.config.stream_first_chunk_timeout} seconds, terminating session client",
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
# Remove the entire session - connection is likely broken
|
|
312
|
+
await self._remove_session_unlocked(session_id)
|
|
313
|
+
return await self._create_session_unlocked(session_id, options)
|
|
314
|
+
|
|
315
|
+
async def _handle_ongoing_timeout(
|
|
316
|
+
self, session_id: str, session_client: SessionClient
|
|
317
|
+
) -> None:
|
|
318
|
+
"""Handle ongoing stream timeout - interrupt stream but keep session (requires lock)."""
|
|
319
|
+
old_handle_id = session_client.active_stream_handle.handle_id
|
|
320
|
+
|
|
321
|
+
_trace(
|
|
322
|
+
"session_pool_interrupting_ongoing_timeout",
|
|
323
|
+
session_id=session_id,
|
|
324
|
+
old_handle_id=old_handle_id,
|
|
325
|
+
idle_seconds=session_client.active_stream_handle.idle_seconds,
|
|
326
|
+
has_first_chunk=session_client.active_stream_handle.has_first_chunk,
|
|
327
|
+
is_completed=session_client.active_stream_handle.is_completed,
|
|
328
|
+
note=f"Stream idle for {self.config.stream_ongoing_timeout}+ seconds, interrupting stream but keeping session",
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
try:
|
|
332
|
+
# Interrupt the old stream handle
|
|
333
|
+
interrupted = await session_client.active_stream_handle.interrupt()
|
|
334
|
+
if interrupted:
|
|
335
|
+
_trace(
|
|
336
|
+
"session_pool_interrupted_ongoing_timeout",
|
|
337
|
+
session_id=session_id,
|
|
338
|
+
old_handle_id=old_handle_id,
|
|
339
|
+
note="Successfully interrupted ongoing timeout stream",
|
|
340
|
+
)
|
|
341
|
+
else:
|
|
342
|
+
logger.debug(
|
|
343
|
+
"session_pool_interrupt_ongoing_not_needed",
|
|
344
|
+
session_id=session_id,
|
|
345
|
+
old_handle_id=old_handle_id,
|
|
346
|
+
note="Ongoing timeout stream was already completed",
|
|
347
|
+
)
|
|
348
|
+
except asyncio.CancelledError as e:
|
|
349
|
+
logger.warning(
|
|
350
|
+
"session_pool_interrupt_ongoing_cancelled",
|
|
351
|
+
session_id=session_id,
|
|
352
|
+
old_handle_id=old_handle_id,
|
|
353
|
+
error=str(e),
|
|
354
|
+
exc_info=e,
|
|
355
|
+
note="Interrupt cancelled during ongoing timeout stream cleanup",
|
|
356
|
+
)
|
|
357
|
+
except TimeoutError as e:
|
|
358
|
+
logger.warning(
|
|
359
|
+
"session_pool_interrupt_ongoing_timeout",
|
|
360
|
+
session_id=session_id,
|
|
361
|
+
old_handle_id=old_handle_id,
|
|
362
|
+
error=str(e),
|
|
363
|
+
exc_info=e,
|
|
364
|
+
note="Interrupt timed out during ongoing timeout stream cleanup",
|
|
365
|
+
)
|
|
366
|
+
except Exception as e:
|
|
367
|
+
logger.warning(
|
|
368
|
+
"session_pool_interrupt_ongoing_failed",
|
|
369
|
+
session_id=session_id,
|
|
370
|
+
old_handle_id=old_handle_id,
|
|
371
|
+
error=str(e),
|
|
372
|
+
exc_info=e,
|
|
373
|
+
message="Failed to interrupt ongoing timeout stream, clearing anyway",
|
|
374
|
+
)
|
|
375
|
+
finally:
|
|
376
|
+
# Always clear the handle after interrupt attempt
|
|
377
|
+
session_client.active_stream_handle = None
|
|
378
|
+
session_client.has_active_stream = False
|
|
379
|
+
|
|
380
|
+
def _clear_recent_stream(
|
|
381
|
+
self, session_id: str, session_client: SessionClient
|
|
382
|
+
) -> None:
|
|
383
|
+
"""Clear a recent stream handle without interrupting."""
|
|
384
|
+
logger.debug(
|
|
385
|
+
"session_pool_clearing_recent_stream",
|
|
386
|
+
session_id=session_id,
|
|
387
|
+
old_handle_id=session_client.active_stream_handle.handle_id,
|
|
388
|
+
idle_seconds=session_client.active_stream_handle.idle_seconds,
|
|
389
|
+
has_first_chunk=session_client.active_stream_handle.has_first_chunk,
|
|
390
|
+
is_completed=session_client.active_stream_handle.is_completed,
|
|
391
|
+
message="Clearing recent stream handle for immediate reuse",
|
|
392
|
+
)
|
|
393
|
+
session_client.active_stream_handle = None
|
|
394
|
+
session_client.has_active_stream = False
|
|
395
|
+
|
|
396
|
+
async def _handle_expired_or_unhealthy(
|
|
397
|
+
self,
|
|
398
|
+
session_id: str,
|
|
399
|
+
session_client: SessionClient,
|
|
400
|
+
options: ClaudeAgentOptions,
|
|
401
|
+
) -> SessionClient:
|
|
402
|
+
"""Handle expired or unhealthy sessions (requires lock)."""
|
|
403
|
+
# Check if session is expired
|
|
404
|
+
if session_client.is_expired():
|
|
405
|
+
logger.debug("session_expired", session_id=session_id)
|
|
406
|
+
await self._remove_session_unlocked(session_id)
|
|
407
|
+
return await self._create_session_unlocked(session_id, options)
|
|
408
|
+
|
|
409
|
+
# Check if session needs recovery
|
|
410
|
+
if not await session_client.is_healthy() and self.config.connection_recovery:
|
|
411
|
+
logger.debug("session_unhealthy_recovering", session_id=session_id)
|
|
412
|
+
await session_client.connect()
|
|
413
|
+
session_client.mark_as_reused()
|
|
414
|
+
return session_client
|
|
415
|
+
|
|
416
|
+
# Session is healthy and ready for reuse
|
|
417
|
+
logger.debug(
|
|
418
|
+
"session_pool_reusing_healthy_session",
|
|
419
|
+
session_id=session_id,
|
|
420
|
+
client_id=session_client.client_id,
|
|
421
|
+
)
|
|
422
|
+
session_client.mark_as_reused()
|
|
423
|
+
return session_client
|
|
424
|
+
|
|
425
|
+
async def _ensure_session_connected(
|
|
426
|
+
self, session_client: SessionClient, session_id: str
|
|
427
|
+
) -> None:
|
|
428
|
+
"""Ensure session is connected before returning (requires lock)."""
|
|
429
|
+
if not await session_client.ensure_connected():
|
|
430
|
+
logger.error(
|
|
431
|
+
"session_pool_connection_failed",
|
|
432
|
+
session_id=session_id,
|
|
433
|
+
)
|
|
434
|
+
raise ServiceUnavailableError(
|
|
435
|
+
f"Failed to establish session connection: {session_id}"
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
async def _create_session(
|
|
439
|
+
self, session_id: str, options: ClaudeAgentOptions
|
|
440
|
+
) -> SessionClient:
|
|
441
|
+
"""Create a new session context (acquires lock)."""
|
|
442
|
+
async with self._lock:
|
|
443
|
+
return await self._create_session_unlocked(session_id, options)
|
|
444
|
+
|
|
445
|
+
async def _create_session_unlocked(
|
|
446
|
+
self, session_id: str, options: ClaudeAgentOptions
|
|
447
|
+
) -> SessionClient:
|
|
448
|
+
"""Create a new session context (requires lock to be held)."""
|
|
449
|
+
session_client = SessionClient(
|
|
450
|
+
session_id=session_id, options=options, ttl_seconds=self.config.session_ttl
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
# Start connection in background
|
|
454
|
+
connection_task = await session_client.connect_background()
|
|
455
|
+
|
|
456
|
+
# Add to sessions immediately (will connect in background)
|
|
457
|
+
self.sessions[session_id] = session_client
|
|
458
|
+
|
|
459
|
+
# Optionally wait for connection to verify it works
|
|
460
|
+
# For now, we'll let it connect in background and check on first use
|
|
461
|
+
logger.debug(
|
|
462
|
+
"session_connecting_background",
|
|
463
|
+
session_id=session_id,
|
|
464
|
+
client_id=session_client.client_id,
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
logger.debug(
|
|
468
|
+
"session_created",
|
|
469
|
+
session_id=session_id,
|
|
470
|
+
client_id=session_client.client_id,
|
|
471
|
+
total_sessions=len(self.sessions),
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
return session_client
|
|
475
|
+
|
|
476
|
+
async def _remove_session(self, session_id: str) -> None:
|
|
477
|
+
"""Remove and cleanup a session (acquires lock)."""
|
|
478
|
+
async with self._lock:
|
|
479
|
+
await self._remove_session_unlocked(session_id)
|
|
480
|
+
|
|
481
|
+
async def _remove_session_unlocked(self, session_id: str) -> None:
|
|
482
|
+
"""Remove and cleanup a session (requires lock to be held)."""
|
|
483
|
+
if session_id not in self.sessions:
|
|
484
|
+
return
|
|
485
|
+
|
|
486
|
+
session_client = self.sessions.pop(session_id)
|
|
487
|
+
await session_client.disconnect()
|
|
488
|
+
|
|
489
|
+
logger.debug(
|
|
490
|
+
"session_removed",
|
|
491
|
+
session_id=session_id,
|
|
492
|
+
total_sessions=len(self.sessions),
|
|
493
|
+
age_seconds=session_client.metrics.age_seconds,
|
|
494
|
+
message_count=session_client.metrics.message_count,
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
async def _cleanup_loop(self) -> None:
|
|
498
|
+
"""Background task to cleanup expired sessions."""
|
|
499
|
+
while not self._shutdown:
|
|
500
|
+
try:
|
|
501
|
+
await asyncio.sleep(self.config.cleanup_interval)
|
|
502
|
+
await self._cleanup_sessions()
|
|
503
|
+
except asyncio.CancelledError:
|
|
504
|
+
break
|
|
505
|
+
except Exception as e:
|
|
506
|
+
logger.error("session_cleanup_error", error=str(e), exc_info=e)
|
|
507
|
+
|
|
508
|
+
async def _cleanup_sessions(self) -> None:
|
|
509
|
+
"""Remove expired, idle, and stuck sessions."""
|
|
510
|
+
sessions_to_remove = []
|
|
511
|
+
stuck_sessions = []
|
|
512
|
+
|
|
513
|
+
# Get a snapshot of sessions to check
|
|
514
|
+
async with self._lock:
|
|
515
|
+
sessions_snapshot = list(self.sessions.items())
|
|
516
|
+
|
|
517
|
+
# Check sessions outside the lock to avoid holding it too long
|
|
518
|
+
for session_id, session_client in sessions_snapshot:
|
|
519
|
+
# Check if session is potentially stuck (active too long)
|
|
520
|
+
is_stuck = (
|
|
521
|
+
session_client.status.value == "active"
|
|
522
|
+
and session_client.metrics.idle_seconds < 10
|
|
523
|
+
and session_client.metrics.age_seconds > 900 # 15 minutes
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
if is_stuck:
|
|
527
|
+
stuck_sessions.append(session_id)
|
|
528
|
+
logger.warning(
|
|
529
|
+
"session_stuck_detected",
|
|
530
|
+
session_id=session_id,
|
|
531
|
+
age_seconds=session_client.metrics.age_seconds,
|
|
532
|
+
idle_seconds=session_client.metrics.idle_seconds,
|
|
533
|
+
message_count=session_client.metrics.message_count,
|
|
534
|
+
message="Session appears stuck, will interrupt and cleanup",
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
# Try to interrupt stuck session before cleanup
|
|
538
|
+
try:
|
|
539
|
+
await session_client.interrupt()
|
|
540
|
+
except asyncio.CancelledError as e:
|
|
541
|
+
logger.warning(
|
|
542
|
+
"session_stuck_interrupt_cancelled",
|
|
543
|
+
session_id=session_id,
|
|
544
|
+
error=str(e),
|
|
545
|
+
exc_info=e,
|
|
546
|
+
)
|
|
547
|
+
except TimeoutError as e:
|
|
548
|
+
logger.warning(
|
|
549
|
+
"session_stuck_interrupt_timeout",
|
|
550
|
+
session_id=session_id,
|
|
551
|
+
error=str(e),
|
|
552
|
+
exc_info=e,
|
|
553
|
+
)
|
|
554
|
+
except Exception as e:
|
|
555
|
+
logger.warning(
|
|
556
|
+
"session_stuck_interrupt_failed",
|
|
557
|
+
session_id=session_id,
|
|
558
|
+
error=str(e),
|
|
559
|
+
exc_info=e,
|
|
560
|
+
)
|
|
561
|
+
|
|
562
|
+
# Check normal cleanup criteria (including stuck sessions)
|
|
563
|
+
if session_client.should_cleanup(
|
|
564
|
+
self.config.idle_threshold, stuck_threshold=900
|
|
565
|
+
):
|
|
566
|
+
sessions_to_remove.append(session_id)
|
|
567
|
+
|
|
568
|
+
if sessions_to_remove:
|
|
569
|
+
logger.debug(
|
|
570
|
+
"session_cleanup_starting",
|
|
571
|
+
sessions_to_remove=len(sessions_to_remove),
|
|
572
|
+
stuck_sessions=len(stuck_sessions),
|
|
573
|
+
total_sessions=len(self.sessions),
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
for session_id in sessions_to_remove:
|
|
577
|
+
await self._remove_session(session_id)
|
|
578
|
+
|
|
579
|
+
async def interrupt_session(self, session_id: str) -> bool:
|
|
580
|
+
"""Interrupt a specific session due to client disconnection.
|
|
581
|
+
|
|
582
|
+
Args:
|
|
583
|
+
session_id: The session ID to interrupt
|
|
584
|
+
|
|
585
|
+
Returns:
|
|
586
|
+
True if session was found and interrupted, False otherwise
|
|
587
|
+
"""
|
|
588
|
+
async with self._lock:
|
|
589
|
+
if session_id not in self.sessions:
|
|
590
|
+
logger.warning("session_not_found", session_id=session_id)
|
|
591
|
+
return False
|
|
592
|
+
|
|
593
|
+
session_client = self.sessions[session_id]
|
|
594
|
+
|
|
595
|
+
try:
|
|
596
|
+
# Interrupt the session with 30-second timeout (allows for longer SDK response times)
|
|
597
|
+
await asyncio.wait_for(session_client.interrupt(), timeout=30.0)
|
|
598
|
+
logger.debug("session_interrupted", session_id=session_id)
|
|
599
|
+
|
|
600
|
+
# Remove the session to prevent reuse
|
|
601
|
+
await self._remove_session(session_id)
|
|
602
|
+
return True
|
|
603
|
+
|
|
604
|
+
except (TimeoutError, Exception) as e:
|
|
605
|
+
logger.error(
|
|
606
|
+
"session_interrupt_failed",
|
|
607
|
+
session_id=session_id,
|
|
608
|
+
error=str(e)
|
|
609
|
+
if not isinstance(e, TimeoutError)
|
|
610
|
+
else "Timeout after 30s",
|
|
611
|
+
)
|
|
612
|
+
# Always remove the session on failure
|
|
613
|
+
with contextlib.suppress(Exception):
|
|
614
|
+
await self._remove_session(session_id)
|
|
615
|
+
return False
|
|
616
|
+
|
|
617
|
+
async def interrupt_all_sessions(self) -> int:
|
|
618
|
+
"""Interrupt all active sessions (stops ongoing operations).
|
|
619
|
+
|
|
620
|
+
Returns:
|
|
621
|
+
Number of sessions that were interrupted
|
|
622
|
+
"""
|
|
623
|
+
# Get snapshot of all sessions
|
|
624
|
+
async with self._lock:
|
|
625
|
+
session_items = list(self.sessions.items())
|
|
626
|
+
|
|
627
|
+
interrupted_count = 0
|
|
628
|
+
|
|
629
|
+
logger.debug(
|
|
630
|
+
"session_interrupt_all_requested",
|
|
631
|
+
total_sessions=len(session_items),
|
|
632
|
+
)
|
|
633
|
+
|
|
634
|
+
for session_id, session_client in session_items:
|
|
635
|
+
try:
|
|
636
|
+
await session_client.interrupt()
|
|
637
|
+
interrupted_count += 1
|
|
638
|
+
except asyncio.CancelledError as e:
|
|
639
|
+
logger.warning(
|
|
640
|
+
"session_interrupt_cancelled_during_all",
|
|
641
|
+
session_id=session_id,
|
|
642
|
+
error=str(e),
|
|
643
|
+
exc_info=e,
|
|
644
|
+
)
|
|
645
|
+
except TimeoutError as e:
|
|
646
|
+
logger.error(
|
|
647
|
+
"session_interrupt_timeout_during_all",
|
|
648
|
+
session_id=session_id,
|
|
649
|
+
error=str(e),
|
|
650
|
+
exc_info=e,
|
|
651
|
+
)
|
|
652
|
+
except Exception as e:
|
|
653
|
+
logger.error(
|
|
654
|
+
"session_interrupt_failed_during_all",
|
|
655
|
+
session_id=session_id,
|
|
656
|
+
error=str(e),
|
|
657
|
+
exc_info=e,
|
|
658
|
+
)
|
|
659
|
+
|
|
660
|
+
logger.debug(
|
|
661
|
+
"session_interrupt_all_completed",
|
|
662
|
+
interrupted_count=interrupted_count,
|
|
663
|
+
total_requested=len(session_items),
|
|
664
|
+
)
|
|
665
|
+
|
|
666
|
+
return interrupted_count
|
|
667
|
+
|
|
668
|
+
async def has_session(self, session_id: str) -> bool:
|
|
669
|
+
"""Check if a session exists in the pool.
|
|
670
|
+
|
|
671
|
+
Args:
|
|
672
|
+
session_id: The session ID to check
|
|
673
|
+
|
|
674
|
+
Returns:
|
|
675
|
+
True if session exists, False otherwise
|
|
676
|
+
"""
|
|
677
|
+
async with self._lock:
|
|
678
|
+
return session_id in self.sessions
|
|
679
|
+
|
|
680
|
+
async def get_stats(self) -> dict[str, Any]:
|
|
681
|
+
"""Get session pool statistics."""
|
|
682
|
+
async with self._lock:
|
|
683
|
+
sessions_list = list(self.sessions.values())
|
|
684
|
+
total_sessions = len(self.sessions)
|
|
685
|
+
|
|
686
|
+
active_sessions = sum(
|
|
687
|
+
1 for s in sessions_list if s.status == SessionStatus.ACTIVE
|
|
688
|
+
)
|
|
689
|
+
|
|
690
|
+
total_messages = sum(s.metrics.message_count for s in sessions_list)
|
|
691
|
+
|
|
692
|
+
return {
|
|
693
|
+
"enabled": self.config.enabled,
|
|
694
|
+
"total_sessions": total_sessions,
|
|
695
|
+
"active_sessions": active_sessions,
|
|
696
|
+
"max_sessions": self.config.max_sessions,
|
|
697
|
+
"total_messages": total_messages,
|
|
698
|
+
"session_ttl": self.config.session_ttl,
|
|
699
|
+
"cleanup_interval": self.config.cleanup_interval,
|
|
700
|
+
}
|