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.
Files changed (481) hide show
  1. ccproxy/api/__init__.py +1 -15
  2. ccproxy/api/app.py +434 -219
  3. ccproxy/api/bootstrap.py +30 -0
  4. ccproxy/api/decorators.py +85 -0
  5. ccproxy/api/dependencies.py +144 -168
  6. ccproxy/api/format_validation.py +54 -0
  7. ccproxy/api/middleware/cors.py +6 -3
  8. ccproxy/api/middleware/errors.py +388 -524
  9. ccproxy/api/middleware/hooks.py +563 -0
  10. ccproxy/api/middleware/normalize_headers.py +59 -0
  11. ccproxy/api/middleware/request_id.py +35 -16
  12. ccproxy/api/middleware/streaming_hooks.py +292 -0
  13. ccproxy/api/routes/__init__.py +5 -14
  14. ccproxy/api/routes/health.py +39 -672
  15. ccproxy/api/routes/plugins.py +277 -0
  16. ccproxy/auth/__init__.py +2 -19
  17. ccproxy/auth/bearer.py +25 -15
  18. ccproxy/auth/dependencies.py +123 -157
  19. ccproxy/auth/exceptions.py +0 -12
  20. ccproxy/auth/manager.py +35 -49
  21. ccproxy/auth/managers/__init__.py +10 -0
  22. ccproxy/auth/managers/base.py +523 -0
  23. ccproxy/auth/managers/base_enhanced.py +63 -0
  24. ccproxy/auth/managers/token_snapshot.py +77 -0
  25. ccproxy/auth/models/base.py +65 -0
  26. ccproxy/auth/models/credentials.py +40 -0
  27. ccproxy/auth/oauth/__init__.py +4 -18
  28. ccproxy/auth/oauth/base.py +533 -0
  29. ccproxy/auth/oauth/cli_errors.py +37 -0
  30. ccproxy/auth/oauth/flows.py +430 -0
  31. ccproxy/auth/oauth/protocol.py +366 -0
  32. ccproxy/auth/oauth/registry.py +408 -0
  33. ccproxy/auth/oauth/router.py +396 -0
  34. ccproxy/auth/oauth/routes.py +186 -113
  35. ccproxy/auth/oauth/session.py +151 -0
  36. ccproxy/auth/oauth/templates.py +342 -0
  37. ccproxy/auth/storage/__init__.py +2 -5
  38. ccproxy/auth/storage/base.py +279 -5
  39. ccproxy/auth/storage/generic.py +134 -0
  40. ccproxy/cli/__init__.py +1 -2
  41. ccproxy/cli/_settings_help.py +351 -0
  42. ccproxy/cli/commands/auth.py +1519 -793
  43. ccproxy/cli/commands/config/commands.py +209 -276
  44. ccproxy/cli/commands/plugins.py +669 -0
  45. ccproxy/cli/commands/serve.py +75 -810
  46. ccproxy/cli/commands/status.py +254 -0
  47. ccproxy/cli/decorators.py +83 -0
  48. ccproxy/cli/helpers.py +22 -60
  49. ccproxy/cli/main.py +359 -10
  50. ccproxy/cli/options/claude_options.py +0 -25
  51. ccproxy/config/__init__.py +7 -11
  52. ccproxy/config/core.py +227 -0
  53. ccproxy/config/env_generator.py +232 -0
  54. ccproxy/config/runtime.py +67 -0
  55. ccproxy/config/security.py +36 -3
  56. ccproxy/config/settings.py +382 -441
  57. ccproxy/config/toml_generator.py +299 -0
  58. ccproxy/config/utils.py +452 -0
  59. ccproxy/core/__init__.py +7 -271
  60. ccproxy/{_version.py → core/_version.py} +16 -3
  61. ccproxy/core/async_task_manager.py +516 -0
  62. ccproxy/core/async_utils.py +47 -14
  63. ccproxy/core/auth/__init__.py +6 -0
  64. ccproxy/core/constants.py +16 -50
  65. ccproxy/core/errors.py +53 -0
  66. ccproxy/core/id_utils.py +20 -0
  67. ccproxy/core/interfaces.py +16 -123
  68. ccproxy/core/logging.py +473 -18
  69. ccproxy/core/plugins/__init__.py +77 -0
  70. ccproxy/core/plugins/cli_discovery.py +211 -0
  71. ccproxy/core/plugins/declaration.py +455 -0
  72. ccproxy/core/plugins/discovery.py +604 -0
  73. ccproxy/core/plugins/factories.py +967 -0
  74. ccproxy/core/plugins/hooks/__init__.py +30 -0
  75. ccproxy/core/plugins/hooks/base.py +58 -0
  76. ccproxy/core/plugins/hooks/events.py +46 -0
  77. ccproxy/core/plugins/hooks/implementations/__init__.py +16 -0
  78. ccproxy/core/plugins/hooks/implementations/formatters/__init__.py +11 -0
  79. ccproxy/core/plugins/hooks/implementations/formatters/json.py +552 -0
  80. ccproxy/core/plugins/hooks/implementations/formatters/raw.py +370 -0
  81. ccproxy/core/plugins/hooks/implementations/http_tracer.py +431 -0
  82. ccproxy/core/plugins/hooks/layers.py +44 -0
  83. ccproxy/core/plugins/hooks/manager.py +186 -0
  84. ccproxy/core/plugins/hooks/registry.py +139 -0
  85. ccproxy/core/plugins/hooks/thread_manager.py +203 -0
  86. ccproxy/core/plugins/hooks/types.py +22 -0
  87. ccproxy/core/plugins/interfaces.py +416 -0
  88. ccproxy/core/plugins/loader.py +166 -0
  89. ccproxy/core/plugins/middleware.py +233 -0
  90. ccproxy/core/plugins/models.py +59 -0
  91. ccproxy/core/plugins/protocol.py +180 -0
  92. ccproxy/core/plugins/runtime.py +519 -0
  93. ccproxy/{observability/context.py → core/request_context.py} +137 -94
  94. ccproxy/core/status_report.py +211 -0
  95. ccproxy/core/transformers.py +13 -8
  96. ccproxy/data/claude_headers_fallback.json +540 -19
  97. ccproxy/data/codex_headers_fallback.json +114 -7
  98. ccproxy/http/__init__.py +30 -0
  99. ccproxy/http/base.py +95 -0
  100. ccproxy/http/client.py +323 -0
  101. ccproxy/http/hooks.py +642 -0
  102. ccproxy/http/pool.py +279 -0
  103. ccproxy/llms/formatters/__init__.py +7 -0
  104. ccproxy/llms/formatters/anthropic_to_openai/__init__.py +55 -0
  105. ccproxy/llms/formatters/anthropic_to_openai/errors.py +65 -0
  106. ccproxy/llms/formatters/anthropic_to_openai/requests.py +356 -0
  107. ccproxy/llms/formatters/anthropic_to_openai/responses.py +153 -0
  108. ccproxy/llms/formatters/anthropic_to_openai/streams.py +1546 -0
  109. ccproxy/llms/formatters/base.py +140 -0
  110. ccproxy/llms/formatters/base_model.py +33 -0
  111. ccproxy/llms/formatters/common/__init__.py +51 -0
  112. ccproxy/llms/formatters/common/identifiers.py +48 -0
  113. ccproxy/llms/formatters/common/streams.py +254 -0
  114. ccproxy/llms/formatters/common/thinking.py +74 -0
  115. ccproxy/llms/formatters/common/usage.py +135 -0
  116. ccproxy/llms/formatters/constants.py +55 -0
  117. ccproxy/llms/formatters/context.py +116 -0
  118. ccproxy/llms/formatters/mapping.py +33 -0
  119. ccproxy/llms/formatters/openai_to_anthropic/__init__.py +55 -0
  120. ccproxy/llms/formatters/openai_to_anthropic/_helpers.py +141 -0
  121. ccproxy/llms/formatters/openai_to_anthropic/errors.py +53 -0
  122. ccproxy/llms/formatters/openai_to_anthropic/requests.py +674 -0
  123. ccproxy/llms/formatters/openai_to_anthropic/responses.py +285 -0
  124. ccproxy/llms/formatters/openai_to_anthropic/streams.py +530 -0
  125. ccproxy/llms/formatters/openai_to_openai/__init__.py +53 -0
  126. ccproxy/llms/formatters/openai_to_openai/_helpers.py +325 -0
  127. ccproxy/llms/formatters/openai_to_openai/errors.py +6 -0
  128. ccproxy/llms/formatters/openai_to_openai/requests.py +388 -0
  129. ccproxy/llms/formatters/openai_to_openai/responses.py +594 -0
  130. ccproxy/llms/formatters/openai_to_openai/streams.py +1832 -0
  131. ccproxy/llms/formatters/utils.py +306 -0
  132. ccproxy/llms/models/__init__.py +9 -0
  133. ccproxy/llms/models/anthropic.py +619 -0
  134. ccproxy/llms/models/openai.py +844 -0
  135. ccproxy/llms/streaming/__init__.py +26 -0
  136. ccproxy/llms/streaming/accumulators.py +1074 -0
  137. ccproxy/llms/streaming/formatters.py +251 -0
  138. ccproxy/{adapters/openai/streaming.py → llms/streaming/processors.py} +193 -240
  139. ccproxy/models/__init__.py +8 -159
  140. ccproxy/models/detection.py +92 -193
  141. ccproxy/models/provider.py +75 -0
  142. ccproxy/plugins/access_log/README.md +32 -0
  143. ccproxy/plugins/access_log/__init__.py +20 -0
  144. ccproxy/plugins/access_log/config.py +33 -0
  145. ccproxy/plugins/access_log/formatter.py +126 -0
  146. ccproxy/plugins/access_log/hook.py +763 -0
  147. ccproxy/plugins/access_log/logger.py +254 -0
  148. ccproxy/plugins/access_log/plugin.py +137 -0
  149. ccproxy/plugins/access_log/writer.py +109 -0
  150. ccproxy/plugins/analytics/README.md +24 -0
  151. ccproxy/plugins/analytics/__init__.py +1 -0
  152. ccproxy/plugins/analytics/config.py +5 -0
  153. ccproxy/plugins/analytics/ingest.py +85 -0
  154. ccproxy/plugins/analytics/models.py +97 -0
  155. ccproxy/plugins/analytics/plugin.py +121 -0
  156. ccproxy/plugins/analytics/routes.py +163 -0
  157. ccproxy/plugins/analytics/service.py +284 -0
  158. ccproxy/plugins/claude_api/README.md +29 -0
  159. ccproxy/plugins/claude_api/__init__.py +10 -0
  160. ccproxy/plugins/claude_api/adapter.py +829 -0
  161. ccproxy/plugins/claude_api/config.py +52 -0
  162. ccproxy/plugins/claude_api/detection_service.py +461 -0
  163. ccproxy/plugins/claude_api/health.py +175 -0
  164. ccproxy/plugins/claude_api/hooks.py +284 -0
  165. ccproxy/plugins/claude_api/models.py +256 -0
  166. ccproxy/plugins/claude_api/plugin.py +298 -0
  167. ccproxy/plugins/claude_api/routes.py +118 -0
  168. ccproxy/plugins/claude_api/streaming_metrics.py +68 -0
  169. ccproxy/plugins/claude_api/tasks.py +84 -0
  170. ccproxy/plugins/claude_sdk/README.md +35 -0
  171. ccproxy/plugins/claude_sdk/__init__.py +80 -0
  172. ccproxy/plugins/claude_sdk/adapter.py +749 -0
  173. ccproxy/plugins/claude_sdk/auth.py +57 -0
  174. ccproxy/{claude_sdk → plugins/claude_sdk}/client.py +63 -39
  175. ccproxy/plugins/claude_sdk/config.py +210 -0
  176. ccproxy/{claude_sdk → plugins/claude_sdk}/converter.py +6 -6
  177. ccproxy/plugins/claude_sdk/detection_service.py +163 -0
  178. ccproxy/{services/claude_sdk_service.py → plugins/claude_sdk/handler.py} +123 -304
  179. ccproxy/plugins/claude_sdk/health.py +113 -0
  180. ccproxy/plugins/claude_sdk/hooks.py +115 -0
  181. ccproxy/{claude_sdk → plugins/claude_sdk}/manager.py +42 -32
  182. ccproxy/{claude_sdk → plugins/claude_sdk}/message_queue.py +8 -8
  183. ccproxy/{models/claude_sdk.py → plugins/claude_sdk/models.py} +64 -16
  184. ccproxy/plugins/claude_sdk/options.py +154 -0
  185. ccproxy/{claude_sdk → plugins/claude_sdk}/parser.py +23 -5
  186. ccproxy/plugins/claude_sdk/plugin.py +269 -0
  187. ccproxy/plugins/claude_sdk/routes.py +104 -0
  188. ccproxy/{claude_sdk → plugins/claude_sdk}/session_client.py +124 -12
  189. ccproxy/plugins/claude_sdk/session_pool.py +700 -0
  190. ccproxy/{claude_sdk → plugins/claude_sdk}/stream_handle.py +48 -43
  191. ccproxy/{claude_sdk → plugins/claude_sdk}/stream_worker.py +22 -18
  192. ccproxy/{claude_sdk → plugins/claude_sdk}/streaming.py +50 -16
  193. ccproxy/plugins/claude_sdk/tasks.py +97 -0
  194. ccproxy/plugins/claude_shared/README.md +18 -0
  195. ccproxy/plugins/claude_shared/__init__.py +12 -0
  196. ccproxy/plugins/claude_shared/model_defaults.py +171 -0
  197. ccproxy/plugins/codex/README.md +35 -0
  198. ccproxy/plugins/codex/__init__.py +6 -0
  199. ccproxy/plugins/codex/adapter.py +635 -0
  200. ccproxy/{config/codex.py → plugins/codex/config.py} +78 -12
  201. ccproxy/plugins/codex/detection_service.py +544 -0
  202. ccproxy/plugins/codex/health.py +162 -0
  203. ccproxy/plugins/codex/hooks.py +263 -0
  204. ccproxy/plugins/codex/model_defaults.py +39 -0
  205. ccproxy/plugins/codex/models.py +263 -0
  206. ccproxy/plugins/codex/plugin.py +275 -0
  207. ccproxy/plugins/codex/routes.py +129 -0
  208. ccproxy/plugins/codex/streaming_metrics.py +324 -0
  209. ccproxy/plugins/codex/tasks.py +106 -0
  210. ccproxy/plugins/codex/utils/__init__.py +1 -0
  211. ccproxy/plugins/codex/utils/sse_parser.py +106 -0
  212. ccproxy/plugins/command_replay/README.md +34 -0
  213. ccproxy/plugins/command_replay/__init__.py +17 -0
  214. ccproxy/plugins/command_replay/config.py +133 -0
  215. ccproxy/plugins/command_replay/formatter.py +432 -0
  216. ccproxy/plugins/command_replay/hook.py +294 -0
  217. ccproxy/plugins/command_replay/plugin.py +161 -0
  218. ccproxy/plugins/copilot/README.md +39 -0
  219. ccproxy/plugins/copilot/__init__.py +11 -0
  220. ccproxy/plugins/copilot/adapter.py +465 -0
  221. ccproxy/plugins/copilot/config.py +155 -0
  222. ccproxy/plugins/copilot/data/copilot_fallback.json +41 -0
  223. ccproxy/plugins/copilot/detection_service.py +255 -0
  224. ccproxy/plugins/copilot/manager.py +275 -0
  225. ccproxy/plugins/copilot/model_defaults.py +284 -0
  226. ccproxy/plugins/copilot/models.py +148 -0
  227. ccproxy/plugins/copilot/oauth/__init__.py +16 -0
  228. ccproxy/plugins/copilot/oauth/client.py +494 -0
  229. ccproxy/plugins/copilot/oauth/models.py +385 -0
  230. ccproxy/plugins/copilot/oauth/provider.py +602 -0
  231. ccproxy/plugins/copilot/oauth/storage.py +170 -0
  232. ccproxy/plugins/copilot/plugin.py +360 -0
  233. ccproxy/plugins/copilot/routes.py +294 -0
  234. ccproxy/plugins/credential_balancer/README.md +124 -0
  235. ccproxy/plugins/credential_balancer/__init__.py +6 -0
  236. ccproxy/plugins/credential_balancer/config.py +270 -0
  237. ccproxy/plugins/credential_balancer/factory.py +415 -0
  238. ccproxy/plugins/credential_balancer/hook.py +51 -0
  239. ccproxy/plugins/credential_balancer/manager.py +587 -0
  240. ccproxy/plugins/credential_balancer/plugin.py +146 -0
  241. ccproxy/plugins/dashboard/README.md +25 -0
  242. ccproxy/plugins/dashboard/__init__.py +1 -0
  243. ccproxy/plugins/dashboard/config.py +8 -0
  244. ccproxy/plugins/dashboard/plugin.py +71 -0
  245. ccproxy/plugins/dashboard/routes.py +67 -0
  246. ccproxy/plugins/docker/README.md +32 -0
  247. ccproxy/{docker → plugins/docker}/__init__.py +3 -0
  248. ccproxy/{docker → plugins/docker}/adapter.py +108 -10
  249. ccproxy/plugins/docker/config.py +82 -0
  250. ccproxy/{docker → plugins/docker}/docker_path.py +4 -3
  251. ccproxy/{docker → plugins/docker}/middleware.py +2 -2
  252. ccproxy/plugins/docker/plugin.py +198 -0
  253. ccproxy/{docker → plugins/docker}/stream_process.py +3 -3
  254. ccproxy/plugins/duckdb_storage/README.md +26 -0
  255. ccproxy/plugins/duckdb_storage/__init__.py +1 -0
  256. ccproxy/plugins/duckdb_storage/config.py +22 -0
  257. ccproxy/plugins/duckdb_storage/plugin.py +128 -0
  258. ccproxy/plugins/duckdb_storage/routes.py +51 -0
  259. ccproxy/plugins/duckdb_storage/storage.py +633 -0
  260. ccproxy/plugins/max_tokens/README.md +38 -0
  261. ccproxy/plugins/max_tokens/__init__.py +12 -0
  262. ccproxy/plugins/max_tokens/adapter.py +235 -0
  263. ccproxy/plugins/max_tokens/config.py +86 -0
  264. ccproxy/plugins/max_tokens/models.py +53 -0
  265. ccproxy/plugins/max_tokens/plugin.py +200 -0
  266. ccproxy/plugins/max_tokens/service.py +271 -0
  267. ccproxy/plugins/max_tokens/token_limits.json +54 -0
  268. ccproxy/plugins/metrics/README.md +35 -0
  269. ccproxy/plugins/metrics/__init__.py +10 -0
  270. ccproxy/{observability/metrics.py → plugins/metrics/collector.py} +20 -153
  271. ccproxy/plugins/metrics/config.py +85 -0
  272. ccproxy/plugins/metrics/grafana/dashboards/ccproxy-dashboard.json +1720 -0
  273. ccproxy/plugins/metrics/hook.py +403 -0
  274. ccproxy/plugins/metrics/plugin.py +268 -0
  275. ccproxy/{observability → plugins/metrics}/pushgateway.py +57 -59
  276. ccproxy/plugins/metrics/routes.py +107 -0
  277. ccproxy/plugins/metrics/tasks.py +117 -0
  278. ccproxy/plugins/oauth_claude/README.md +35 -0
  279. ccproxy/plugins/oauth_claude/__init__.py +14 -0
  280. ccproxy/plugins/oauth_claude/client.py +270 -0
  281. ccproxy/plugins/oauth_claude/config.py +84 -0
  282. ccproxy/plugins/oauth_claude/manager.py +482 -0
  283. ccproxy/plugins/oauth_claude/models.py +266 -0
  284. ccproxy/plugins/oauth_claude/plugin.py +149 -0
  285. ccproxy/plugins/oauth_claude/provider.py +571 -0
  286. ccproxy/plugins/oauth_claude/storage.py +212 -0
  287. ccproxy/plugins/oauth_codex/README.md +38 -0
  288. ccproxy/plugins/oauth_codex/__init__.py +14 -0
  289. ccproxy/plugins/oauth_codex/client.py +224 -0
  290. ccproxy/plugins/oauth_codex/config.py +95 -0
  291. ccproxy/plugins/oauth_codex/manager.py +256 -0
  292. ccproxy/plugins/oauth_codex/models.py +239 -0
  293. ccproxy/plugins/oauth_codex/plugin.py +146 -0
  294. ccproxy/plugins/oauth_codex/provider.py +574 -0
  295. ccproxy/plugins/oauth_codex/storage.py +92 -0
  296. ccproxy/plugins/permissions/README.md +28 -0
  297. ccproxy/plugins/permissions/__init__.py +22 -0
  298. ccproxy/plugins/permissions/config.py +28 -0
  299. ccproxy/{cli/commands/permission_handler.py → plugins/permissions/handlers/cli.py} +49 -25
  300. ccproxy/plugins/permissions/handlers/protocol.py +33 -0
  301. ccproxy/plugins/permissions/handlers/terminal.py +675 -0
  302. ccproxy/{api/routes → plugins/permissions}/mcp.py +34 -7
  303. ccproxy/{models/permissions.py → plugins/permissions/models.py} +65 -1
  304. ccproxy/plugins/permissions/plugin.py +153 -0
  305. ccproxy/{api/routes/permissions.py → plugins/permissions/routes.py} +20 -16
  306. ccproxy/{api/services/permission_service.py → plugins/permissions/service.py} +65 -11
  307. ccproxy/{api → plugins/permissions}/ui/permission_handler_protocol.py +1 -1
  308. ccproxy/{api → plugins/permissions}/ui/terminal_permission_handler.py +66 -10
  309. ccproxy/plugins/pricing/README.md +34 -0
  310. ccproxy/plugins/pricing/__init__.py +6 -0
  311. ccproxy/{pricing → plugins/pricing}/cache.py +7 -6
  312. ccproxy/{config/pricing.py → plugins/pricing/config.py} +32 -6
  313. ccproxy/plugins/pricing/exceptions.py +35 -0
  314. ccproxy/plugins/pricing/loader.py +440 -0
  315. ccproxy/{pricing → plugins/pricing}/models.py +13 -23
  316. ccproxy/plugins/pricing/plugin.py +169 -0
  317. ccproxy/plugins/pricing/service.py +191 -0
  318. ccproxy/plugins/pricing/tasks.py +300 -0
  319. ccproxy/{pricing → plugins/pricing}/updater.py +86 -72
  320. ccproxy/plugins/pricing/utils.py +99 -0
  321. ccproxy/plugins/request_tracer/README.md +40 -0
  322. ccproxy/plugins/request_tracer/__init__.py +7 -0
  323. ccproxy/plugins/request_tracer/config.py +120 -0
  324. ccproxy/plugins/request_tracer/hook.py +415 -0
  325. ccproxy/plugins/request_tracer/plugin.py +255 -0
  326. ccproxy/scheduler/__init__.py +2 -14
  327. ccproxy/scheduler/core.py +26 -41
  328. ccproxy/scheduler/manager.py +61 -105
  329. ccproxy/scheduler/registry.py +6 -32
  330. ccproxy/scheduler/tasks.py +268 -276
  331. ccproxy/services/__init__.py +0 -1
  332. ccproxy/services/adapters/__init__.py +11 -0
  333. ccproxy/services/adapters/base.py +123 -0
  334. ccproxy/services/adapters/chain_composer.py +88 -0
  335. ccproxy/services/adapters/chain_validation.py +44 -0
  336. ccproxy/services/adapters/chat_accumulator.py +200 -0
  337. ccproxy/services/adapters/delta_utils.py +142 -0
  338. ccproxy/services/adapters/format_adapter.py +136 -0
  339. ccproxy/services/adapters/format_context.py +11 -0
  340. ccproxy/services/adapters/format_registry.py +158 -0
  341. ccproxy/services/adapters/http_adapter.py +1045 -0
  342. ccproxy/services/adapters/mock_adapter.py +118 -0
  343. ccproxy/services/adapters/protocols.py +35 -0
  344. ccproxy/services/adapters/simple_converters.py +571 -0
  345. ccproxy/services/auth_registry.py +180 -0
  346. ccproxy/services/cache/__init__.py +6 -0
  347. ccproxy/services/cache/response_cache.py +261 -0
  348. ccproxy/services/cli_detection.py +437 -0
  349. ccproxy/services/config/__init__.py +6 -0
  350. ccproxy/services/config/proxy_configuration.py +111 -0
  351. ccproxy/services/container.py +256 -0
  352. ccproxy/services/factories.py +380 -0
  353. ccproxy/services/handler_config.py +76 -0
  354. ccproxy/services/interfaces.py +298 -0
  355. ccproxy/services/mocking/__init__.py +6 -0
  356. ccproxy/services/mocking/mock_handler.py +291 -0
  357. ccproxy/services/tracing/__init__.py +7 -0
  358. ccproxy/services/tracing/interfaces.py +61 -0
  359. ccproxy/services/tracing/null_tracer.py +57 -0
  360. ccproxy/streaming/__init__.py +23 -0
  361. ccproxy/streaming/buffer.py +1056 -0
  362. ccproxy/streaming/deferred.py +897 -0
  363. ccproxy/streaming/handler.py +117 -0
  364. ccproxy/streaming/interfaces.py +77 -0
  365. ccproxy/streaming/simple_adapter.py +39 -0
  366. ccproxy/streaming/sse.py +109 -0
  367. ccproxy/streaming/sse_parser.py +127 -0
  368. ccproxy/templates/__init__.py +6 -0
  369. ccproxy/templates/plugin_scaffold.py +695 -0
  370. ccproxy/testing/endpoints/__init__.py +33 -0
  371. ccproxy/testing/endpoints/cli.py +215 -0
  372. ccproxy/testing/endpoints/config.py +874 -0
  373. ccproxy/testing/endpoints/console.py +57 -0
  374. ccproxy/testing/endpoints/models.py +100 -0
  375. ccproxy/testing/endpoints/runner.py +1903 -0
  376. ccproxy/testing/endpoints/tools.py +308 -0
  377. ccproxy/testing/mock_responses.py +70 -1
  378. ccproxy/testing/response_handlers.py +20 -0
  379. ccproxy/utils/__init__.py +0 -6
  380. ccproxy/utils/binary_resolver.py +476 -0
  381. ccproxy/utils/caching.py +327 -0
  382. ccproxy/utils/cli_logging.py +101 -0
  383. ccproxy/utils/command_line.py +251 -0
  384. ccproxy/utils/headers.py +228 -0
  385. ccproxy/utils/model_mapper.py +120 -0
  386. ccproxy/utils/startup_helpers.py +68 -446
  387. ccproxy/utils/version_checker.py +273 -6
  388. ccproxy_api-0.2.0a4.dist-info/METADATA +212 -0
  389. ccproxy_api-0.2.0a4.dist-info/RECORD +417 -0
  390. {ccproxy_api-0.1.7.dist-info → ccproxy_api-0.2.0a4.dist-info}/WHEEL +1 -1
  391. ccproxy_api-0.2.0a4.dist-info/entry_points.txt +24 -0
  392. ccproxy/__init__.py +0 -4
  393. ccproxy/adapters/__init__.py +0 -11
  394. ccproxy/adapters/base.py +0 -80
  395. ccproxy/adapters/codex/__init__.py +0 -11
  396. ccproxy/adapters/openai/__init__.py +0 -42
  397. ccproxy/adapters/openai/adapter.py +0 -953
  398. ccproxy/adapters/openai/models.py +0 -412
  399. ccproxy/adapters/openai/response_adapter.py +0 -355
  400. ccproxy/adapters/openai/response_models.py +0 -178
  401. ccproxy/api/middleware/headers.py +0 -49
  402. ccproxy/api/middleware/logging.py +0 -180
  403. ccproxy/api/middleware/request_content_logging.py +0 -297
  404. ccproxy/api/middleware/server_header.py +0 -58
  405. ccproxy/api/responses.py +0 -89
  406. ccproxy/api/routes/claude.py +0 -371
  407. ccproxy/api/routes/codex.py +0 -1251
  408. ccproxy/api/routes/metrics.py +0 -1029
  409. ccproxy/api/routes/proxy.py +0 -211
  410. ccproxy/api/services/__init__.py +0 -6
  411. ccproxy/auth/conditional.py +0 -84
  412. ccproxy/auth/credentials_adapter.py +0 -93
  413. ccproxy/auth/models.py +0 -118
  414. ccproxy/auth/oauth/models.py +0 -48
  415. ccproxy/auth/openai/__init__.py +0 -13
  416. ccproxy/auth/openai/credentials.py +0 -166
  417. ccproxy/auth/openai/oauth_client.py +0 -334
  418. ccproxy/auth/openai/storage.py +0 -184
  419. ccproxy/auth/storage/json_file.py +0 -158
  420. ccproxy/auth/storage/keyring.py +0 -189
  421. ccproxy/claude_sdk/__init__.py +0 -18
  422. ccproxy/claude_sdk/options.py +0 -194
  423. ccproxy/claude_sdk/session_pool.py +0 -550
  424. ccproxy/cli/docker/__init__.py +0 -34
  425. ccproxy/cli/docker/adapter_factory.py +0 -157
  426. ccproxy/cli/docker/params.py +0 -274
  427. ccproxy/config/auth.py +0 -153
  428. ccproxy/config/claude.py +0 -348
  429. ccproxy/config/cors.py +0 -79
  430. ccproxy/config/discovery.py +0 -95
  431. ccproxy/config/docker_settings.py +0 -264
  432. ccproxy/config/observability.py +0 -158
  433. ccproxy/config/reverse_proxy.py +0 -31
  434. ccproxy/config/scheduler.py +0 -108
  435. ccproxy/config/server.py +0 -86
  436. ccproxy/config/validators.py +0 -231
  437. ccproxy/core/codex_transformers.py +0 -389
  438. ccproxy/core/http.py +0 -328
  439. ccproxy/core/http_transformers.py +0 -812
  440. ccproxy/core/proxy.py +0 -143
  441. ccproxy/core/validators.py +0 -288
  442. ccproxy/models/errors.py +0 -42
  443. ccproxy/models/messages.py +0 -269
  444. ccproxy/models/requests.py +0 -107
  445. ccproxy/models/responses.py +0 -270
  446. ccproxy/models/types.py +0 -102
  447. ccproxy/observability/__init__.py +0 -51
  448. ccproxy/observability/access_logger.py +0 -457
  449. ccproxy/observability/sse_events.py +0 -303
  450. ccproxy/observability/stats_printer.py +0 -753
  451. ccproxy/observability/storage/__init__.py +0 -1
  452. ccproxy/observability/storage/duckdb_simple.py +0 -677
  453. ccproxy/observability/storage/models.py +0 -70
  454. ccproxy/observability/streaming_response.py +0 -107
  455. ccproxy/pricing/__init__.py +0 -19
  456. ccproxy/pricing/loader.py +0 -251
  457. ccproxy/services/claude_detection_service.py +0 -243
  458. ccproxy/services/codex_detection_service.py +0 -252
  459. ccproxy/services/credentials/__init__.py +0 -55
  460. ccproxy/services/credentials/config.py +0 -105
  461. ccproxy/services/credentials/manager.py +0 -561
  462. ccproxy/services/credentials/oauth_client.py +0 -481
  463. ccproxy/services/proxy_service.py +0 -1827
  464. ccproxy/static/.keep +0 -0
  465. ccproxy/utils/cost_calculator.py +0 -210
  466. ccproxy/utils/disconnection_monitor.py +0 -83
  467. ccproxy/utils/model_mapping.py +0 -199
  468. ccproxy/utils/models_provider.py +0 -150
  469. ccproxy/utils/simple_request_logger.py +0 -284
  470. ccproxy/utils/streaming_metrics.py +0 -199
  471. ccproxy_api-0.1.7.dist-info/METADATA +0 -615
  472. ccproxy_api-0.1.7.dist-info/RECORD +0 -191
  473. ccproxy_api-0.1.7.dist-info/entry_points.txt +0 -4
  474. /ccproxy/{api/middleware/auth.py → auth/models/__init__.py} +0 -0
  475. /ccproxy/{claude_sdk → plugins/claude_sdk}/exceptions.py +0 -0
  476. /ccproxy/{docker → plugins/docker}/models.py +0 -0
  477. /ccproxy/{docker → plugins/docker}/protocol.py +0 -0
  478. /ccproxy/{docker → plugins/docker}/validators.py +0 -0
  479. /ccproxy/{auth/oauth/storage.py → plugins/permissions/handlers/__init__.py} +0 -0
  480. /ccproxy/{api → plugins/permissions}/ui/__init__.py +0 -0
  481. {ccproxy_api-0.1.7.dist-info → ccproxy_api-0.2.0a4.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
+ }