ccproxy-api 0.1.7__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.
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.0.dist-info/METADATA +212 -0
  389. ccproxy_api-0.2.0.dist-info/RECORD +417 -0
  390. {ccproxy_api-0.1.7.dist-info → ccproxy_api-0.2.0.dist-info}/WHEEL +1 -1
  391. ccproxy_api-0.2.0.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.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,675 @@
1
+ """Terminal UI handler for confirmation requests using Textual with request stacking support."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextlib
7
+ import time
8
+ from dataclasses import dataclass
9
+ from typing import TYPE_CHECKING
10
+
11
+ from ccproxy.core.async_task_manager import (
12
+ create_fire_and_forget_task,
13
+ create_managed_task,
14
+ )
15
+ from ccproxy.core.logging import get_plugin_logger
16
+
17
+ from ..models import PermissionRequest
18
+
19
+
20
+ # During type checking, import real Textual types; at runtime, provide fallbacks if absent.
21
+ TEXTUAL_AVAILABLE: bool
22
+ if TYPE_CHECKING:
23
+ from textual.app import App, ComposeResult
24
+ from textual.containers import Container, Vertical
25
+ from textual.events import Key
26
+ from textual.reactive import reactive
27
+ from textual.screen import ModalScreen
28
+ from textual.timer import Timer
29
+ from textual.widgets import Label, Static
30
+
31
+ TEXTUAL_AVAILABLE = True
32
+ else: # pragma: no cover - optional dependency
33
+ try:
34
+ from textual.app import App, ComposeResult
35
+ from textual.containers import Container, Vertical
36
+ from textual.events import Key
37
+ from textual.reactive import reactive
38
+ from textual.screen import ModalScreen
39
+ from textual.timer import Timer
40
+ from textual.widgets import Label, Static
41
+
42
+ TEXTUAL_AVAILABLE = True
43
+ except ImportError:
44
+ TEXTUAL_AVAILABLE = False
45
+
46
+ # Minimal runtime stubs to avoid crashes when Textual is not installed
47
+ class App: # type: ignore[no-redef]
48
+ pass
49
+
50
+ class Container: # type: ignore[no-redef]
51
+ pass
52
+
53
+ class Vertical: # type: ignore[no-redef]
54
+ pass
55
+
56
+ class ModalScreen: # type: ignore[no-redef]
57
+ pass
58
+
59
+ class Label: # type: ignore[no-redef]
60
+ pass
61
+
62
+ class Static: # type: ignore[no-redef]
63
+ pass
64
+
65
+ def reactive(x: float) -> float: # type: ignore[no-redef]
66
+ return x
67
+
68
+ class Timer: # type: ignore[no-redef]
69
+ pass
70
+
71
+
72
+ logger = get_plugin_logger()
73
+
74
+
75
+ @dataclass
76
+ class PendingRequest:
77
+ """Represents a pending confirmation request with its response future."""
78
+
79
+ request: PermissionRequest
80
+ future: asyncio.Future[bool]
81
+ cancelled: bool = False
82
+
83
+
84
+ class ConfirmationScreen(ModalScreen[bool]):
85
+ """Modal screen for displaying a single confirmation request."""
86
+
87
+ BINDINGS = [
88
+ ("y", "confirm", "Yes"),
89
+ ("n", "deny", "No"),
90
+ ("enter", "confirm", "Confirm"),
91
+ ("escape", "deny", "Cancel"),
92
+ ("ctrl+c", "cancel", "Cancel"),
93
+ ]
94
+
95
+ def __init__(self, request: PermissionRequest) -> None:
96
+ super().__init__()
97
+ self.request = request
98
+ self.start_time = time.time()
99
+ self.countdown_timer: Timer | None = None
100
+
101
+ time_remaining = reactive(0.0)
102
+
103
+ def compose(self) -> ComposeResult:
104
+ """Compose the confirmation dialog."""
105
+ with Container(id="confirmation-dialog"):
106
+ yield Vertical(
107
+ Label("[bold red]Permission Request[/bold red]", id="title"),
108
+ self._create_info_display(),
109
+ Label("Calculating timeout...", id="countdown", classes="countdown"),
110
+ Label(
111
+ "[bold white]Allow this operation? (y/N):[/bold white]",
112
+ id="question",
113
+ ),
114
+ id="content",
115
+ )
116
+
117
+ def _create_info_display(self) -> Static:
118
+ """Create the information display widget."""
119
+ info_lines = [
120
+ f"[bold cyan]Tool:[/bold cyan] {self.request.tool_name}",
121
+ f"[bold cyan]Request ID:[/bold cyan] {self.request.id[:8]}...",
122
+ ]
123
+
124
+ # Add input parameters
125
+ for key, value in self.request.input.items():
126
+ display_value = value if len(value) <= 50 else f"{value[:47]}..."
127
+ info_lines.append(f"[bold cyan]{key}:[/bold cyan] {display_value}")
128
+
129
+ return Static("\n".join(info_lines), id="info")
130
+
131
+ def on_mount(self) -> None:
132
+ """Start the countdown timer when mounted."""
133
+ self.update_countdown()
134
+ self.countdown_timer = self.set_interval(0.1, self.update_countdown)
135
+
136
+ def update_countdown(self) -> None:
137
+ """Update the countdown display."""
138
+ elapsed = time.time() - self.start_time
139
+ remaining = max(0, self.request.time_remaining() - elapsed)
140
+ self.time_remaining = remaining
141
+
142
+ if remaining <= 0:
143
+ self._timeout()
144
+ else:
145
+ countdown_widget = self.query_one("#countdown", Label)
146
+ if remaining > 10:
147
+ style = "yellow"
148
+ elif remaining > 5:
149
+ style = "orange1"
150
+ else:
151
+ style = "red"
152
+ countdown_widget.update(f"[{style}]Timeout in {remaining:.1f}s[/{style}]")
153
+
154
+ def _timeout(self) -> None:
155
+ """Handle timeout."""
156
+ if self.countdown_timer:
157
+ self.countdown_timer.stop()
158
+ self.countdown_timer = None
159
+ # Schedule the async result display
160
+ self.call_later(self._show_result, False, "TIMEOUT - DENIED")
161
+
162
+ async def _show_result(self, allowed: bool, message: str) -> None:
163
+ """Show the result with visual feedback before dismissing.
164
+
165
+ Args:
166
+ allowed: Whether the request was allowed
167
+ message: Message to display
168
+ """
169
+ # Update the question to show the result
170
+ question_widget = self.query_one("#question", Label)
171
+ if allowed:
172
+ question_widget.update(f"[bold green]✓ {message}[/bold green]")
173
+ else:
174
+ question_widget.update(f"[bold red]✗ {message}[/bold red]")
175
+
176
+ # Update the dialog border color
177
+ dialog = self.query_one("#confirmation-dialog", Container)
178
+ if allowed:
179
+ dialog.styles.border = ("solid", "green")
180
+ else:
181
+ dialog.styles.border = ("solid", "red")
182
+
183
+ # Give user time to see the result
184
+ await asyncio.sleep(1.5)
185
+ self.dismiss(allowed)
186
+
187
+ def action_confirm(self) -> None:
188
+ """Confirm the request."""
189
+ if self.countdown_timer:
190
+ self.countdown_timer.stop()
191
+ self.countdown_timer = None
192
+ self.call_later(self._show_result, True, "ALLOWED")
193
+
194
+ def action_deny(self) -> None:
195
+ """Deny the request."""
196
+ if self.countdown_timer:
197
+ self.countdown_timer.stop()
198
+ self.countdown_timer = None
199
+ self.call_later(self._show_result, False, "DENIED")
200
+
201
+ def action_cancel(self) -> None:
202
+ """Cancel the request (Ctrl+C)."""
203
+ if self.countdown_timer:
204
+ self.countdown_timer.stop()
205
+ self.countdown_timer = None
206
+ self.call_later(self._show_result, False, "CANCELLED")
207
+ # Raise KeyboardInterrupt to forward it up
208
+ raise KeyboardInterrupt("User cancelled confirmation")
209
+
210
+
211
+ class ConfirmationApp(App[bool]):
212
+ """Simple Textual app for a single confirmation request."""
213
+
214
+ CSS = """
215
+
216
+ Screen {
217
+ border: none;
218
+ }
219
+
220
+ Static {
221
+ background: $surface;
222
+ }
223
+
224
+ #confirmation-dialog {
225
+ width: 60;
226
+ height: 18;
227
+ border: round solid $accent;
228
+ background: $surface;
229
+ padding: 1;
230
+ box-sizing: border-box;
231
+ }
232
+
233
+ #title {
234
+ text-align: center;
235
+ margin-bottom: 1;
236
+ }
237
+
238
+ #info {
239
+ border: solid $primary;
240
+ margin: 1;
241
+ padding: 1;
242
+ background: $surface;
243
+ height: auto;
244
+ }
245
+
246
+ #countdown {
247
+ text-align: center;
248
+ margin: 1;
249
+ background: $surface;
250
+ text-style: bold;
251
+ height: 1;
252
+ }
253
+
254
+ #question {
255
+ text-align: center;
256
+ margin: 1;
257
+ background: $surface;
258
+ }
259
+
260
+
261
+ .countdown {
262
+ text-style: bold;
263
+ }
264
+ """
265
+
266
+ BINDINGS = [
267
+ ("y", "confirm", "Yes"),
268
+ ("n", "deny", "No"),
269
+ ("enter", "confirm", "Confirm"),
270
+ ("escape", "deny", "Cancel"),
271
+ ("ctrl+c", "cancel", "Cancel"),
272
+ ]
273
+
274
+ def __init__(self, request: PermissionRequest) -> None:
275
+ super().__init__()
276
+ self.theme = "textual-ansi"
277
+ self.request = request
278
+ self.result = False
279
+ self.start_time = time.time()
280
+ self.countdown_timer: Timer | None = None
281
+
282
+ time_remaining = reactive(0.0)
283
+
284
+ def compose(self) -> ComposeResult:
285
+ """Compose the confirmation dialog directly."""
286
+ with Container(id="confirmation-dialog"):
287
+ yield Vertical(
288
+ Label("[bold red]Permission Request[/bold red]", id="title"),
289
+ self._create_info_display(),
290
+ Label("Calculating timeout...", id="countdown", classes="countdown"),
291
+ Label(
292
+ "[bold white]Allow this operation? (y/N):[/bold white]",
293
+ id="question",
294
+ ),
295
+ id="content",
296
+ )
297
+
298
+ def _create_info_display(self) -> Static:
299
+ """Create the information display widget."""
300
+ info_lines = [
301
+ f"[bold cyan]Tool:[/bold cyan] {self.request.tool_name}",
302
+ f"[bold cyan]Request ID:[/bold cyan] {self.request.id[:8]}...",
303
+ ]
304
+
305
+ # Add input parameters
306
+ for key, value in self.request.input.items():
307
+ display_value = value if len(value) <= 50 else f"{value[:47]}..."
308
+ info_lines.append(f"[bold cyan]{key}:[/bold cyan] {display_value}")
309
+
310
+ return Static("\n".join(info_lines), id="info")
311
+
312
+ def on_mount(self) -> None:
313
+ """Start the countdown timer when mounted."""
314
+ self.update_countdown()
315
+ self.countdown_timer = self.set_interval(0.1, self.update_countdown)
316
+
317
+ def update_countdown(self) -> None:
318
+ """Update the countdown display."""
319
+ elapsed = time.time() - self.start_time
320
+ remaining = max(0, self.request.time_remaining() - elapsed)
321
+ self.time_remaining = remaining
322
+
323
+ if remaining <= 0:
324
+ self._timeout()
325
+ else:
326
+ countdown_widget = self.query_one("#countdown", Label)
327
+ if remaining > 10:
328
+ style = "yellow"
329
+ elif remaining > 5:
330
+ style = "orange1"
331
+ else:
332
+ style = "red"
333
+ countdown_widget.update(f"[{style}]Timeout in {remaining:.1f}s[/{style}]")
334
+
335
+ def _timeout(self) -> None:
336
+ """Handle timeout."""
337
+ if self.countdown_timer:
338
+ self.countdown_timer.stop()
339
+ self.countdown_timer = None
340
+ # Schedule the async result display
341
+ self.call_later(self._show_result, False, "TIMEOUT - DENIED")
342
+
343
+ async def _show_result(self, allowed: bool, message: str) -> None:
344
+ """Show the result with visual feedback before exiting.
345
+
346
+ Args:
347
+ allowed: Whether the request was allowed
348
+ message: Message to display
349
+ """
350
+ # Update the question to show the result
351
+ question_widget = self.query_one("#question", Label)
352
+ if allowed:
353
+ question_widget.update(f"[bold green]✓ {message}[/bold green]")
354
+ else:
355
+ question_widget.update(f"[bold red]✗ {message}[/bold red]")
356
+
357
+ # Update the dialog border color
358
+ dialog = self.query_one("#confirmation-dialog", Container)
359
+ if allowed:
360
+ dialog.styles.border = ("solid", "green")
361
+ else:
362
+ dialog.styles.border = ("solid", "red")
363
+
364
+ # Give user time to see the result
365
+ await asyncio.sleep(1.5)
366
+ self.exit(allowed)
367
+
368
+ def action_confirm(self) -> None:
369
+ """Confirm the request."""
370
+ if self.countdown_timer:
371
+ self.countdown_timer.stop()
372
+ self.countdown_timer = None
373
+ self.call_later(self._show_result, True, "ALLOWED")
374
+
375
+ def action_deny(self) -> None:
376
+ """Deny the request."""
377
+ if self.countdown_timer:
378
+ self.countdown_timer.stop()
379
+ self.countdown_timer = None
380
+ self.call_later(self._show_result, False, "DENIED")
381
+
382
+ def action_cancel(self) -> None:
383
+ """Cancel the request (Ctrl+C)."""
384
+ if self.countdown_timer:
385
+ self.countdown_timer.stop()
386
+ self.countdown_timer = None
387
+ self.call_later(self._show_result, False, "CANCELLED")
388
+ # Raise KeyboardInterrupt to forward it up
389
+ raise KeyboardInterrupt("User cancelled confirmation")
390
+
391
+ async def on_key(self, event: Key) -> None:
392
+ """Handle global key events, especially Ctrl+C."""
393
+ if event.key == "ctrl+c":
394
+ # Forward the KeyboardInterrupt
395
+ self.exit(False)
396
+ raise KeyboardInterrupt("User cancelled confirmation")
397
+
398
+
399
+ class TerminalPermissionHandler:
400
+ """Handles confirmation requests in the terminal using Textual with request stacking.
401
+
402
+ Implements ConfirmationHandlerProtocol for type safety and interoperability.
403
+ """
404
+
405
+ def __init__(self) -> None:
406
+ """Initialize the terminal confirmation handler."""
407
+ self._request_queue: (
408
+ asyncio.Queue[tuple[PermissionRequest, asyncio.Future[bool]]] | None
409
+ ) = None
410
+ self._cancelled_requests: set[str] = set()
411
+ self._processing_task: asyncio.Task[None] | None = None
412
+ self._active_apps: dict[str, ConfirmationApp] = {}
413
+
414
+ def _get_request_queue(
415
+ self,
416
+ ) -> asyncio.Queue[tuple[PermissionRequest, asyncio.Future[bool]]]:
417
+ """Lazily initialize and return the request queue."""
418
+ if self._request_queue is None:
419
+ self._request_queue = asyncio.Queue()
420
+ return self._request_queue
421
+
422
+ def _safe_set_future_result(
423
+ self, future: asyncio.Future[bool], result: bool
424
+ ) -> bool:
425
+ """Safely set a future result, handling already cancelled futures.
426
+
427
+ Args:
428
+ future: The future to set the result on
429
+ result: The result to set
430
+
431
+ Returns:
432
+ bool: True if result was set successfully, False if future was cancelled
433
+ """
434
+ if future.cancelled():
435
+ return False
436
+ try:
437
+ future.set_result(result)
438
+ return True
439
+ except asyncio.InvalidStateError:
440
+ # Future was already resolved or cancelled
441
+ return False
442
+
443
+ def _safe_set_future_exception(
444
+ self, future: asyncio.Future[bool], exception: BaseException
445
+ ) -> bool:
446
+ """Safely set a future exception, handling already cancelled futures.
447
+
448
+ Args:
449
+ future: The future to set the exception on
450
+ exception: The exception to set
451
+
452
+ Returns:
453
+ bool: True if exception was set successfully, False if future was cancelled
454
+ """
455
+ if future.cancelled():
456
+ return False
457
+ try:
458
+ future.set_exception(exception)
459
+ return True
460
+ except asyncio.InvalidStateError:
461
+ # Future was already resolved or cancelled
462
+ return False
463
+
464
+ async def _process_queue(self) -> None:
465
+ """Process requests from the queue one by one."""
466
+ while True:
467
+ try:
468
+ request, future = await self._get_request_queue().get()
469
+
470
+ # Check if request is valid for processing
471
+ if not self._is_request_processable(request, future):
472
+ continue
473
+
474
+ # Process the request
475
+ await self._process_single_request(request, future)
476
+
477
+ except asyncio.CancelledError:
478
+ break
479
+ except Exception as e:
480
+ logger.error("queue_processing_error", error=str(e), exc_info=e)
481
+
482
+ def _is_request_processable(
483
+ self, request: PermissionRequest, future: asyncio.Future[bool]
484
+ ) -> bool:
485
+ """Check if a request can be processed."""
486
+ # Check if cancelled before processing
487
+ if request.id in self._cancelled_requests:
488
+ self._safe_set_future_result(future, False)
489
+ self._cancelled_requests.discard(request.id)
490
+ return False
491
+
492
+ # Check if expired
493
+ if request.time_remaining() <= 0:
494
+ self._safe_set_future_result(future, False)
495
+ return False
496
+
497
+ return True
498
+
499
+ async def _process_single_request(
500
+ self, request: PermissionRequest, future: asyncio.Future[bool]
501
+ ) -> None:
502
+ """Process a single permission request."""
503
+ app = None
504
+ try:
505
+ # Create and run a simple app for this request
506
+ app = ConfirmationApp(request)
507
+ self._active_apps[request.id] = app
508
+
509
+ app_result = await app.run_async(inline=True, inline_no_clear=True)
510
+ result = bool(app_result) if app_result is not None else False
511
+
512
+ # Apply cancellation if it occurred during processing
513
+ if request.id in self._cancelled_requests:
514
+ result = False
515
+ self._cancelled_requests.discard(request.id)
516
+
517
+ self._safe_set_future_result(future, result)
518
+
519
+ except KeyboardInterrupt:
520
+ self._safe_set_future_exception(
521
+ future, KeyboardInterrupt("User cancelled confirmation")
522
+ )
523
+ except Exception as e:
524
+ logger.error(
525
+ "confirmation_app_error",
526
+ request_id=request.id,
527
+ error=str(e),
528
+ exc_info=e,
529
+ )
530
+ self._safe_set_future_result(future, False)
531
+ finally:
532
+ # Always cleanup app reference
533
+ if app:
534
+ self._active_apps.pop(request.id, None)
535
+
536
+ def _ensure_processing_task_running(self) -> None:
537
+ """Ensure the processing task is running."""
538
+ if self._processing_task is None or self._processing_task.done():
539
+ # Use fire-and-forget since this is called from sync context
540
+ create_fire_and_forget_task(
541
+ self._create_processing_task(),
542
+ name="terminal_handler_processing",
543
+ creator="TerminalHandler",
544
+ )
545
+
546
+ async def _ensure_processing_task_running_async(self) -> None:
547
+ """Ensure the processing task is running (async version for tests)."""
548
+ if self._processing_task is None or self._processing_task.done():
549
+ await self._create_processing_task()
550
+
551
+ async def _create_processing_task(self) -> None:
552
+ """Create the processing task in async context."""
553
+ self._processing_task = await create_managed_task(
554
+ self._process_queue(),
555
+ name="terminal_handler_queue_processor",
556
+ creator="TerminalHandler",
557
+ )
558
+
559
+ async def _queue_and_wait_for_result(self, request: PermissionRequest) -> bool:
560
+ """Queue a request and wait for its result."""
561
+ future: asyncio.Future[bool] = asyncio.Future()
562
+ await self._get_request_queue().put((request, future))
563
+ return await future
564
+
565
+ async def handle_permission(self, request: PermissionRequest) -> bool:
566
+ """Handle a permission request.
567
+
568
+ Args:
569
+ request: The permission request to handle
570
+
571
+ Returns:
572
+ bool: True if the user confirmed, False otherwise
573
+ """
574
+ if not TEXTUAL_AVAILABLE:
575
+ logger.warning(
576
+ "textual_not_available_denying_request",
577
+ request_id=request.id,
578
+ tool_name=request.tool_name,
579
+ )
580
+ return False
581
+
582
+ try:
583
+ logger.info(
584
+ "handling_confirmation_request",
585
+ request_id=request.id,
586
+ tool_name=request.tool_name,
587
+ time_remaining=request.time_remaining(),
588
+ )
589
+
590
+ # Check if request has already expired
591
+ if request.time_remaining() <= 0:
592
+ logger.info("confirmation_request_expired", request_id=request.id)
593
+ return False
594
+
595
+ # Ensure processing task is running
596
+ self._ensure_processing_task_running()
597
+
598
+ # Queue request and wait for result
599
+ result = await self._queue_and_wait_for_result(request)
600
+
601
+ logger.info(
602
+ "confirmation_request_completed", request_id=request.id, result=result
603
+ )
604
+
605
+ return result
606
+
607
+ except Exception as e:
608
+ logger.error(
609
+ "confirmation_handling_error",
610
+ request_id=request.id,
611
+ error=str(e),
612
+ exc_info=e,
613
+ )
614
+ return False
615
+
616
+ def cancel_confirmation(self, request_id: str, reason: str = "cancelled") -> None:
617
+ """Cancel an ongoing confirmation request.
618
+
619
+ Args:
620
+ request_id: The ID of the request to cancel
621
+ reason: The reason for cancellation
622
+ """
623
+ logger.info("cancelling_confirmation", request_id=request_id, reason=reason)
624
+ self._cancelled_requests.add(request_id)
625
+
626
+ # If there's an active dialog for this request, close it immediately
627
+ if request_id in self._active_apps:
628
+ app = self._active_apps[request_id]
629
+ # Schedule the cancellation feedback asynchronously
630
+ create_fire_and_forget_task(
631
+ self._cancel_active_dialog(app, reason),
632
+ name="terminal_dialog_cancel",
633
+ creator="TerminalHandler",
634
+ )
635
+
636
+ async def _cancel_active_dialog(self, app: ConfirmationApp, reason: str) -> None:
637
+ """Cancel an active dialog with visual feedback.
638
+
639
+ Args:
640
+ app: The active ConfirmationApp to cancel
641
+ reason: The reason for cancellation
642
+ """
643
+ try:
644
+ # Determine the message and result based on reason
645
+ if "approved by another handler" in reason.lower():
646
+ message = "APPROVED BY ANOTHER HANDLER"
647
+ allowed = True
648
+ elif "denied by another handler" in reason.lower():
649
+ message = "DENIED BY ANOTHER HANDLER"
650
+ allowed = False
651
+ else:
652
+ message = f"CANCELLED - {reason.upper()}"
653
+ allowed = False
654
+
655
+ # Show visual feedback through the app's _show_result method
656
+ await app._show_result(allowed, message)
657
+
658
+ except Exception as e:
659
+ logger.error(
660
+ "cancel_dialog_error",
661
+ error=str(e),
662
+ exc_info=e,
663
+ )
664
+ # Fallback: just exit the app without feedback
665
+ with contextlib.suppress(Exception):
666
+ app.exit(False)
667
+
668
+ async def shutdown(self) -> None:
669
+ """Shutdown the handler and cleanup resources."""
670
+ if self._processing_task and not self._processing_task.done():
671
+ self._processing_task.cancel()
672
+ with contextlib.suppress(asyncio.CancelledError):
673
+ await self._processing_task
674
+
675
+ self._processing_task = None