ccproxy-api 0.1.6__py3-none-any.whl → 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (481) hide show
  1. ccproxy/api/__init__.py +1 -15
  2. ccproxy/api/app.py +439 -212
  3. ccproxy/api/bootstrap.py +30 -0
  4. ccproxy/api/decorators.py +85 -0
  5. ccproxy/api/dependencies.py +145 -176
  6. ccproxy/api/format_validation.py +54 -0
  7. ccproxy/api/middleware/cors.py +6 -3
  8. ccproxy/api/middleware/errors.py +402 -530
  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 +558 -0
  97. ccproxy/data/codex_headers_fallback.json +121 -0
  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 +63 -107
  329. ccproxy/scheduler/registry.py +6 -32
  330. ccproxy/scheduler/tasks.py +346 -314
  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 +95 -342
  387. ccproxy/utils/version_checker.py +279 -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.6.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 -1231
  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 -269
  458. ccproxy/services/codex_detection_service.py +0 -263
  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.6.dist-info/METADATA +0 -615
  472. ccproxy_api-0.1.6.dist-info/RECORD +0 -189
  473. ccproxy_api-0.1.6.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.6.dist-info → ccproxy_api-0.2.0.dist-info}/licenses/LICENSE +0 -0
@@ -1,14 +1,31 @@
1
1
  """Base scheduled task classes and task implementations."""
2
2
 
3
3
  import asyncio
4
- import contextlib
5
4
  import random
6
5
  import time
7
6
  from abc import ABC, abstractmethod
8
- from datetime import UTC
7
+ from datetime import UTC, datetime
9
8
  from typing import Any
10
9
 
11
10
  import structlog
11
+ from packaging import version as pkg_version
12
+
13
+ from ccproxy.core.async_task_manager import create_managed_task
14
+ from ccproxy.scheduler.errors import SchedulerError
15
+ from ccproxy.utils.version_checker import (
16
+ VersionCheckState,
17
+ commit_refs_match,
18
+ compare_versions,
19
+ extract_commit_from_version,
20
+ fetch_latest_branch_commit,
21
+ fetch_latest_github_version,
22
+ get_branch_override,
23
+ get_current_version,
24
+ get_version_check_state_path,
25
+ load_check_state,
26
+ resolve_branch_for_commit,
27
+ save_check_state,
28
+ )
12
29
 
13
30
 
14
31
  logger = structlog.get_logger(__name__)
@@ -50,6 +67,7 @@ class BaseScheduledTask(ABC):
50
67
  self._last_run_time: float = 0
51
68
  self._running = False
52
69
  self._task: asyncio.Task[Any] | None = None
70
+ self._stop_complete: asyncio.Event | None = None
53
71
 
54
72
  @abstractmethod
55
73
  async def run(self) -> bool:
@@ -111,12 +129,27 @@ class BaseScheduledTask(ABC):
111
129
  return
112
130
 
113
131
  self._running = True
132
+ self._stop_complete = asyncio.Event()
114
133
  logger.debug("task_starting", task_name=self.name)
115
134
 
116
135
  try:
117
136
  await self.setup()
118
- self._task = asyncio.create_task(self._run_loop())
137
+ self._task = await create_managed_task(
138
+ self._run_loop(),
139
+ name=f"scheduled_task_{self.name}",
140
+ creator="BaseScheduledTask",
141
+ )
119
142
  logger.debug("task_started", task_name=self.name)
143
+ except SchedulerError as e:
144
+ self._running = False
145
+ logger.error(
146
+ "task_start_scheduler_error",
147
+ task_name=self.name,
148
+ error=str(e),
149
+ error_type=type(e).__name__,
150
+ exc_info=e,
151
+ )
152
+ raise
120
153
  except Exception as e:
121
154
  self._running = False
122
155
  logger.error(
@@ -124,6 +157,7 @@ class BaseScheduledTask(ABC):
124
157
  task_name=self.name,
125
158
  error=str(e),
126
159
  error_type=type(e).__name__,
160
+ exc_info=e,
127
161
  )
128
162
  raise
129
163
 
@@ -135,21 +169,55 @@ class BaseScheduledTask(ABC):
135
169
  self._running = False
136
170
  logger.debug("task_stopping", task_name=self.name)
137
171
 
138
- # Cancel the running task
172
+ # Cancel the running task and wait for it to complete
139
173
  if self._task and not self._task.done():
140
174
  self._task.cancel()
141
- with contextlib.suppress(asyncio.CancelledError):
175
+ try:
176
+ # Wait for the task to complete cancellation
142
177
  await self._task
178
+ except asyncio.CancelledError:
179
+ # Expected when task is cancelled
180
+ pass
181
+ except Exception as e:
182
+ logger.warning(
183
+ "task_stop_unexpected_error",
184
+ task_name=self.name,
185
+ error=str(e),
186
+ error_type=type(e).__name__,
187
+ )
188
+
189
+ # Ensure the task reference is cleared
190
+ self._task = None
191
+
192
+ # Wait for the completion event to be signaled
193
+ if self._stop_complete is not None:
194
+ try:
195
+ await asyncio.wait_for(self._stop_complete.wait(), timeout=1.0)
196
+ except TimeoutError:
197
+ logger.warning(
198
+ "task_stop_completion_timeout",
199
+ task_name=self.name,
200
+ message="Task stop completion event not signaled within timeout",
201
+ )
143
202
 
144
203
  try:
145
204
  await self.cleanup()
146
205
  logger.debug("task_stopped", task_name=self.name)
206
+ except SchedulerError as e:
207
+ logger.error(
208
+ "task_cleanup_scheduler_error",
209
+ task_name=self.name,
210
+ error=str(e),
211
+ error_type=type(e).__name__,
212
+ exc_info=e,
213
+ )
147
214
  except Exception as e:
148
215
  logger.error(
149
216
  "task_cleanup_failed",
150
217
  task_name=self.name,
151
218
  error=str(e),
152
219
  error_type=type(e).__name__,
220
+ exc_info=e,
153
221
  )
154
222
 
155
223
  async def _run_loop(self) -> None:
@@ -199,6 +267,32 @@ class BaseScheduledTask(ABC):
199
267
  except asyncio.CancelledError:
200
268
  logger.debug("task_cancelled", task_name=self.name)
201
269
  break
270
+ except TimeoutError as e:
271
+ self._consecutive_failures += 1
272
+ logger.error(
273
+ "task_execution_timeout_error",
274
+ task_name=self.name,
275
+ error=str(e),
276
+ error_type=type(e).__name__,
277
+ consecutive_failures=self._consecutive_failures,
278
+ exc_info=e,
279
+ )
280
+ # Use backoff delay for exceptions too
281
+ backoff_delay = self.calculate_next_delay()
282
+ await asyncio.sleep(backoff_delay)
283
+ except SchedulerError as e:
284
+ self._consecutive_failures += 1
285
+ logger.error(
286
+ "task_execution_scheduler_error",
287
+ task_name=self.name,
288
+ error=str(e),
289
+ error_type=type(e).__name__,
290
+ consecutive_failures=self._consecutive_failures,
291
+ exc_info=e,
292
+ )
293
+ # Use backoff delay for exceptions too
294
+ backoff_delay = self.calculate_next_delay()
295
+ await asyncio.sleep(backoff_delay)
202
296
  except Exception as e:
203
297
  self._consecutive_failures += 1
204
298
  logger.error(
@@ -207,12 +301,16 @@ class BaseScheduledTask(ABC):
207
301
  error=str(e),
208
302
  error_type=type(e).__name__,
209
303
  consecutive_failures=self._consecutive_failures,
304
+ exc_info=e,
210
305
  )
211
-
212
306
  # Use backoff delay for exceptions too
213
307
  backoff_delay = self.calculate_next_delay()
214
308
  await asyncio.sleep(backoff_delay)
215
309
 
310
+ # Signal that the task has completed
311
+ if self._stop_complete is not None:
312
+ self._stop_complete.set()
313
+
216
314
  @property
217
315
  def is_running(self) -> bool:
218
316
  """Check if the task is currently running."""
@@ -246,243 +344,6 @@ class BaseScheduledTask(ABC):
246
344
  }
247
345
 
248
346
 
249
- class PushgatewayTask(BaseScheduledTask):
250
- """Task for pushing metrics to Pushgateway periodically."""
251
-
252
- def __init__(
253
- self,
254
- name: str,
255
- interval_seconds: float,
256
- enabled: bool = True,
257
- max_backoff_seconds: float = 300.0,
258
- ):
259
- """
260
- Initialize pushgateway task.
261
-
262
- Args:
263
- name: Task name
264
- interval_seconds: Interval between pushgateway operations
265
- enabled: Whether task is enabled
266
- max_backoff_seconds: Maximum backoff delay for failures
267
- """
268
- super().__init__(
269
- name=name,
270
- interval_seconds=interval_seconds,
271
- enabled=enabled,
272
- max_backoff_seconds=max_backoff_seconds,
273
- )
274
- self._metrics_instance: Any | None = None
275
-
276
- async def setup(self) -> None:
277
- """Initialize metrics instance for pushgateway operations."""
278
- try:
279
- from ccproxy.observability.metrics import get_metrics
280
-
281
- self._metrics_instance = get_metrics()
282
- logger.debug("pushgateway_task_setup_complete", task_name=self.name)
283
- except Exception as e:
284
- logger.error(
285
- "pushgateway_task_setup_failed",
286
- task_name=self.name,
287
- error=str(e),
288
- error_type=type(e).__name__,
289
- )
290
- raise
291
-
292
- async def run(self) -> bool:
293
- """Execute pushgateway metrics push."""
294
- try:
295
- if not self._metrics_instance:
296
- logger.warning("pushgateway_no_metrics_instance", task_name=self.name)
297
- return False
298
-
299
- if not self._metrics_instance.is_pushgateway_enabled():
300
- logger.debug("pushgateway_disabled", task_name=self.name)
301
- return True # Not an error, just disabled
302
-
303
- success = bool(self._metrics_instance.push_to_gateway())
304
-
305
- if success:
306
- logger.debug("pushgateway_push_success", task_name=self.name)
307
- else:
308
- logger.warning("pushgateway_push_failed", task_name=self.name)
309
-
310
- return success
311
-
312
- except Exception as e:
313
- logger.error(
314
- "pushgateway_task_error",
315
- task_name=self.name,
316
- error=str(e),
317
- error_type=type(e).__name__,
318
- )
319
- return False
320
-
321
-
322
- class StatsPrintingTask(BaseScheduledTask):
323
- """Task for printing stats summary periodically."""
324
-
325
- def __init__(
326
- self,
327
- name: str,
328
- interval_seconds: float,
329
- enabled: bool = True,
330
- ):
331
- """
332
- Initialize stats printing task.
333
-
334
- Args:
335
- name: Task name
336
- interval_seconds: Interval between stats printing
337
- enabled: Whether task is enabled
338
- """
339
- super().__init__(
340
- name=name,
341
- interval_seconds=interval_seconds,
342
- enabled=enabled,
343
- )
344
- self._stats_collector_instance: Any | None = None
345
- self._metrics_instance: Any | None = None
346
-
347
- async def setup(self) -> None:
348
- """Initialize stats collector and metrics instances."""
349
- try:
350
- from ccproxy.config.settings import get_settings
351
- from ccproxy.observability.metrics import get_metrics
352
- from ccproxy.observability.stats_printer import get_stats_collector
353
-
354
- self._metrics_instance = get_metrics()
355
- settings = get_settings()
356
- self._stats_collector_instance = get_stats_collector(
357
- settings=settings.observability,
358
- metrics_instance=self._metrics_instance,
359
- )
360
- logger.debug("stats_printing_task_setup_complete", task_name=self.name)
361
- except Exception as e:
362
- logger.error(
363
- "stats_printing_task_setup_failed",
364
- task_name=self.name,
365
- error=str(e),
366
- error_type=type(e).__name__,
367
- )
368
- raise
369
-
370
- async def run(self) -> bool:
371
- """Execute stats printing."""
372
- try:
373
- if not self._stats_collector_instance:
374
- logger.warning("stats_printing_no_collector", task_name=self.name)
375
- return False
376
-
377
- await self._stats_collector_instance.print_stats()
378
- logger.debug("stats_printing_success", task_name=self.name)
379
- return True
380
-
381
- except Exception as e:
382
- logger.error(
383
- "stats_printing_task_error",
384
- task_name=self.name,
385
- error=str(e),
386
- error_type=type(e).__name__,
387
- )
388
- return False
389
-
390
-
391
- class PricingCacheUpdateTask(BaseScheduledTask):
392
- """Task for updating pricing cache periodically."""
393
-
394
- def __init__(
395
- self,
396
- name: str,
397
- interval_seconds: float,
398
- enabled: bool = True,
399
- force_refresh_on_startup: bool = False,
400
- pricing_updater: Any | None = None,
401
- ):
402
- """
403
- Initialize pricing cache update task.
404
-
405
- Args:
406
- name: Task name
407
- interval_seconds: Interval between pricing updates
408
- enabled: Whether task is enabled
409
- force_refresh_on_startup: Whether to force refresh on first run
410
- pricing_updater: Injected pricing updater instance
411
- """
412
- super().__init__(
413
- name=name,
414
- interval_seconds=interval_seconds,
415
- enabled=enabled,
416
- )
417
- self.force_refresh_on_startup = force_refresh_on_startup
418
- self._pricing_updater = pricing_updater
419
- self._first_run = True
420
-
421
- async def setup(self) -> None:
422
- """Initialize pricing updater instance if not injected."""
423
- if self._pricing_updater is None:
424
- try:
425
- from ccproxy.config.pricing import PricingSettings
426
- from ccproxy.pricing.cache import PricingCache
427
- from ccproxy.pricing.updater import PricingUpdater
428
-
429
- # Create pricing components with dependency injection
430
- settings = PricingSettings()
431
- cache = PricingCache(settings)
432
- self._pricing_updater = PricingUpdater(cache, settings)
433
- logger.debug("pricing_update_task_setup_complete", task_name=self.name)
434
- except Exception as e:
435
- logger.error(
436
- "pricing_update_task_setup_failed",
437
- task_name=self.name,
438
- error=str(e),
439
- error_type=type(e).__name__,
440
- )
441
- raise
442
- else:
443
- logger.debug(
444
- "pricing_update_task_using_injected_updater", task_name=self.name
445
- )
446
-
447
- async def run(self) -> bool:
448
- """Execute pricing cache update."""
449
- try:
450
- if not self._pricing_updater:
451
- logger.warning("pricing_update_no_updater", task_name=self.name)
452
- return False
453
-
454
- # Force refresh on first run if configured
455
- force_refresh = self._first_run and self.force_refresh_on_startup
456
- self._first_run = False
457
-
458
- if force_refresh:
459
- logger.info("pricing_update_force_refresh_startup", task_name=self.name)
460
- refresh_result = await self._pricing_updater.force_refresh()
461
- success = bool(refresh_result)
462
- else:
463
- # Regular update check
464
- pricing_data = await self._pricing_updater.get_current_pricing(
465
- force_refresh=False
466
- )
467
- success = pricing_data is not None
468
-
469
- if success:
470
- logger.debug("pricing_update_success", task_name=self.name)
471
- else:
472
- logger.warning("pricing_update_failed", task_name=self.name)
473
-
474
- return success
475
-
476
- except Exception as e:
477
- logger.error(
478
- "pricing_update_task_error",
479
- task_name=self.name,
480
- error=str(e),
481
- error_type=type(e).__name__,
482
- )
483
- return False
484
-
485
-
486
347
  class PoolStatsTask(BaseScheduledTask):
487
348
  """Task for displaying pool statistics periodically."""
488
349
 
@@ -601,6 +462,7 @@ class PoolStatsTask(BaseScheduledTask):
601
462
  task_name=self.name,
602
463
  error=str(e),
603
464
  error_type=type(e).__name__,
465
+ exc_info=e,
604
466
  )
605
467
  return False
606
468
 
@@ -613,7 +475,9 @@ class VersionUpdateCheckTask(BaseScheduledTask):
613
475
  name: str,
614
476
  interval_seconds: float,
615
477
  enabled: bool = True,
616
- startup_max_age_hours: float = 1.0,
478
+ version_check_cache_ttl_hours: float = 1.0,
479
+ *,
480
+ skip_first_scheduled_run: bool = True,
617
481
  ):
618
482
  """
619
483
  Initialize version update check task.
@@ -622,110 +486,278 @@ class VersionUpdateCheckTask(BaseScheduledTask):
622
486
  name: Task name
623
487
  interval_seconds: Interval between version checks
624
488
  enabled: Whether task is enabled
625
- startup_max_age_hours: Maximum age in hours before running startup check
489
+ version_check_cache_ttl_hours: Maximum cache age (hours) used at startup before contacting GitHub
490
+ skip_first_scheduled_run: If True, first scheduled loop execution is skipped
626
491
  """
627
492
  super().__init__(
628
493
  name=name,
629
494
  interval_seconds=interval_seconds,
630
495
  enabled=enabled,
631
496
  )
632
- self.startup_max_age_hours = startup_max_age_hours
497
+ self.version_check_cache_ttl_hours = version_check_cache_ttl_hours
498
+ # Mark first scheduled execution; allow skipping to avoid duplicate run after startup
633
499
  self._first_run = True
500
+ self._skip_first_run = skip_first_scheduled_run
501
+
502
+ def _log_version_comparison(
503
+ self, current_version: str, latest_version: str, *, source: str | None = None
504
+ ) -> None:
505
+ """
506
+ Log version comparison results with appropriate warning level.
507
+
508
+ Args:
509
+ current_version: Current version string
510
+ latest_version: Latest version string
511
+ """
512
+ if compare_versions(current_version, latest_version):
513
+ logger.warning(
514
+ "version_update_available",
515
+ task_name=self.name,
516
+ current_version=current_version,
517
+ latest_version=latest_version,
518
+ source=source,
519
+ description=(f"New version available: {latest_version}"),
520
+ )
521
+ else:
522
+ logger.debug(
523
+ "version_check_complete_no_update",
524
+ task_name=self.name,
525
+ current_version=current_version,
526
+ latest_version=latest_version,
527
+ source=source,
528
+ description=(
529
+ f"No update: latest_version={latest_version} "
530
+ f"current_version={current_version}"
531
+ ),
532
+ )
634
533
 
635
534
  async def run(self) -> bool:
636
535
  """Execute version update check."""
637
536
  try:
638
- from datetime import datetime
639
-
640
- from ccproxy.utils.version_checker import (
641
- VersionCheckState,
642
- compare_versions,
643
- fetch_latest_github_version,
644
- get_current_version,
645
- get_version_check_state_path,
646
- load_check_state,
647
- save_check_state,
537
+ logger.debug(
538
+ "version_check_task_run_start",
539
+ task_name=self.name,
540
+ first_run=self._first_run,
648
541
  )
649
-
650
542
  state_path = get_version_check_state_path()
651
543
  current_time = datetime.now(UTC)
652
544
 
653
- # Check if we should run based on startup logic
654
- if self._first_run:
545
+ # Skip first scheduled run to avoid duplicate check after startup
546
+ if self._first_run and self._skip_first_run:
655
547
  self._first_run = False
656
- should_run_startup_check = False
548
+ logger.debug(
549
+ "version_check_first_run_skipped",
550
+ task_name=self.name,
551
+ message="Skipping first scheduled run since startup check already completed",
552
+ )
553
+ return True
657
554
 
658
- # Load existing state if available
659
- existing_state = await load_check_state(state_path)
660
- if existing_state:
661
- # Check age of last check
662
- time_diff = current_time - existing_state.last_check_at
663
- age_hours = time_diff.total_seconds() / 3600
555
+ # Determine freshness window using configured cache TTL
556
+ # Applies to both startup and scheduled runs to avoid unnecessary network calls
557
+ max_age_hours = self.version_check_cache_ttl_hours
664
558
 
665
- if age_hours > self.startup_max_age_hours:
666
- should_run_startup_check = True
667
- logger.debug(
668
- "version_check_startup_needed",
559
+ # Load previous state if available
560
+ prev_state: VersionCheckState | None = await load_check_state(state_path)
561
+
562
+ current_version = get_current_version()
563
+ current_commit = extract_commit_from_version(current_version)
564
+
565
+ if prev_state is not None:
566
+ invalidation_reason: str | None = None
567
+ if (
568
+ prev_state.running_version is not None
569
+ and prev_state.running_version != current_version
570
+ ):
571
+ invalidation_reason = "version"
572
+ elif (
573
+ prev_state.running_commit is not None
574
+ and current_commit is not None
575
+ and not commit_refs_match(prev_state.running_commit, current_commit)
576
+ ):
577
+ invalidation_reason = "commit"
578
+
579
+ if invalidation_reason is not None:
580
+ logger.debug(
581
+ "version_check_cache_invalidated",
582
+ task_name=self.name,
583
+ reason=invalidation_reason,
584
+ cached_running_version=prev_state.running_version,
585
+ cached_running_commit=prev_state.running_commit,
586
+ current_version=current_version,
587
+ current_commit=current_commit,
588
+ )
589
+ prev_state = None
590
+
591
+ latest_version: str | None = None
592
+ latest_branch_commit: str | None = None
593
+ source: str | None = None
594
+
595
+ # If we have a recent state within the freshness window, avoid network call
596
+ if prev_state is not None:
597
+ age_hours = (
598
+ current_time - prev_state.last_check_at
599
+ ).total_seconds() / 3600.0
600
+ if age_hours < max_age_hours:
601
+ logger.debug(
602
+ "version_check_cache_fresh",
603
+ task_name=self.name,
604
+ age_hours=round(age_hours, 3),
605
+ max_age_hours=max_age_hours,
606
+ )
607
+ latest_version = prev_state.latest_version_found
608
+ latest_branch_commit = prev_state.latest_branch_commit
609
+ source = "cache"
610
+ else:
611
+ logger.debug(
612
+ "version_check_cache_stale",
613
+ task_name=self.name,
614
+ age_hours=round(age_hours, 3),
615
+ max_age_hours=max_age_hours,
616
+ )
617
+
618
+ current_version_parsed = pkg_version.parse(current_version)
619
+ branch_name: str | None = None
620
+
621
+ if current_version_parsed.is_devrelease and current_commit is not None:
622
+ branch_name = get_branch_override()
623
+ if branch_name is None and prev_state is not None:
624
+ branch_name = prev_state.latest_branch_name
625
+ if branch_name is None:
626
+ branch_name = await resolve_branch_for_commit(current_commit)
627
+
628
+ if branch_name is not None:
629
+ if source == "cache" and (
630
+ prev_state is None
631
+ or prev_state.latest_branch_name != branch_name
632
+ or not prev_state.latest_branch_commit
633
+ ):
634
+ latest_branch_commit = None
635
+ source = None
636
+
637
+ if latest_branch_commit is None:
638
+ latest_branch_commit = await fetch_latest_branch_commit(branch_name)
639
+ if latest_branch_commit is None:
640
+ logger.warning(
641
+ "version_check_branch_fetch_failed",
642
+ task_name=self.name,
643
+ branch=branch_name,
644
+ )
645
+ return False
646
+
647
+ await save_check_state(
648
+ state_path,
649
+ VersionCheckState(
650
+ last_check_at=current_time,
651
+ latest_version_found=(
652
+ latest_version
653
+ or (
654
+ prev_state.latest_version_found
655
+ if prev_state is not None
656
+ else None
657
+ )
658
+ ),
659
+ latest_branch_name=branch_name,
660
+ latest_branch_commit=latest_branch_commit,
661
+ running_version=current_version,
662
+ running_commit=current_commit,
663
+ ),
664
+ )
665
+ source = "network"
666
+
667
+ if current_commit is None:
668
+ logger.debug(
669
+ "branch_revision_no_commit_to_compare",
670
+ task_name=self.name,
671
+ branch=branch_name,
672
+ source=source,
673
+ )
674
+ else:
675
+ update_available = not commit_refs_match(
676
+ current_commit, latest_branch_commit
677
+ )
678
+ if update_available:
679
+ logger.warning(
680
+ "branch_revision_update_available",
669
681
  task_name=self.name,
670
- age_hours=age_hours,
671
- max_age_hours=self.startup_max_age_hours,
682
+ branch=branch_name,
683
+ current_commit=current_commit,
684
+ latest_commit=latest_branch_commit,
685
+ source=source,
686
+ description=(
687
+ "New commits available for branch "
688
+ f"{branch_name}: {latest_branch_commit}"
689
+ ),
672
690
  )
673
691
  else:
674
692
  logger.debug(
675
- "version_check_startup_skipped",
693
+ "branch_revision_up_to_date",
676
694
  task_name=self.name,
677
- age_hours=age_hours,
678
- max_age_hours=self.startup_max_age_hours,
695
+ branch=branch_name,
696
+ current_commit=current_commit,
697
+ source=source,
679
698
  )
680
- return True # Skip this run
681
- else:
682
- # No previous state, run check
683
- should_run_startup_check = True
684
- logger.debug("version_check_startup_no_state", task_name=self.name)
685
-
686
- if not should_run_startup_check:
687
- return True
688
-
689
- # Fetch latest version from GitHub
690
- latest_version = await fetch_latest_github_version()
691
- if latest_version is None:
692
- logger.warning("version_check_fetch_failed", task_name=self.name)
693
- return False
694
-
695
- # Get current version
696
- current_version = get_current_version()
697
-
698
- # Save state
699
- new_state = VersionCheckState(
700
- last_check_at=current_time,
701
- latest_version_found=latest_version,
702
- )
703
- await save_check_state(state_path, new_state)
704
-
705
- # Compare versions
706
- if compare_versions(current_version, latest_version):
707
- logger.info(
708
- "version_update_available",
709
- task_name=self.name,
710
- current_version=current_version,
711
- latest_version=latest_version,
712
- message=f"New version {latest_version} available! You are running {current_version}",
713
- )
714
699
  else:
715
- logger.debug(
716
- "version_check_complete_no_update",
717
- task_name=self.name,
718
- current_version=current_version,
719
- latest_version=latest_version,
700
+ if latest_version is None:
701
+ latest_version = await fetch_latest_github_version()
702
+ if latest_version is None:
703
+ logger.warning(
704
+ "version_check_fetch_failed", task_name=self.name
705
+ )
706
+ return False
707
+ await save_check_state(
708
+ state_path,
709
+ VersionCheckState(
710
+ last_check_at=current_time,
711
+ latest_version_found=latest_version,
712
+ latest_branch_name=(
713
+ prev_state.latest_branch_name
714
+ if prev_state is not None
715
+ else None
716
+ ),
717
+ latest_branch_commit=(
718
+ prev_state.latest_branch_commit
719
+ if prev_state is not None
720
+ else None
721
+ ),
722
+ running_version=current_version,
723
+ running_commit=current_commit,
724
+ ),
725
+ )
726
+ source = "network"
727
+ self._log_version_comparison(
728
+ current_version, latest_version, source=source
720
729
  )
721
730
 
731
+ # Mark first run as complete
732
+ if self._first_run:
733
+ self._first_run = False
734
+
722
735
  return True
723
736
 
737
+ except ImportError as e:
738
+ logger.error(
739
+ "version_check_task_import_error",
740
+ task_name=self.name,
741
+ error=str(e),
742
+ error_type=type(e).__name__,
743
+ exc_info=e,
744
+ )
745
+ return False
746
+
724
747
  except Exception as e:
725
748
  logger.error(
726
749
  "version_check_task_error",
727
750
  task_name=self.name,
728
751
  error=str(e),
729
752
  error_type=type(e).__name__,
753
+ exc_info=e,
730
754
  )
731
755
  return False
756
+
757
+
758
+ # Test helper task exposed for tests that import from this module
759
+ class MockScheduledTask(BaseScheduledTask):
760
+ """Minimal mock task used by tests for registration and lifecycle checks."""
761
+
762
+ async def run(self) -> bool:
763
+ return True