@oneciel-ai/ciel-runtime 0.1.1 → 0.2.0

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 (287) hide show
  1. package/README.md +132 -0
  2. package/ciel-runtime-menu.py +56 -6
  3. package/ciel_runtime.py +9379 -35909
  4. package/ciel_runtime_support/advisor_client.py +193 -0
  5. package/ciel_runtime_support/advisor_policy.py +320 -0
  6. package/ciel_runtime_support/advisor_refinement.py +160 -0
  7. package/ciel_runtime_support/advisor_request_builder.py +261 -0
  8. package/ciel_runtime_support/agy_installer.py +169 -0
  9. package/ciel_runtime_support/agy_mcp_restore.py +182 -0
  10. package/ciel_runtime_support/anthropic_model_policy.py +186 -0
  11. package/ciel_runtime_support/anthropic_response_writer.py +255 -0
  12. package/ciel_runtime_support/anthropic_tool_turns.py +130 -0
  13. package/ciel_runtime_support/api_key_cooldown.py +159 -0
  14. package/ciel_runtime_support/architecture.py +488 -1
  15. package/ciel_runtime_support/architecture_budget.py +42 -0
  16. package/ciel_runtime_support/channel_backlog.py +90 -0
  17. package/ciel_runtime_support/channel_cli.py +119 -0
  18. package/ciel_runtime_support/channel_compact_injection.py +82 -0
  19. package/ciel_runtime_support/channel_compact_poll.py +67 -0
  20. package/ciel_runtime_support/channel_compact_request_repository.py +113 -0
  21. package/ciel_runtime_support/channel_config_service.py +281 -0
  22. package/ciel_runtime_support/channel_connection_lifecycle.py +180 -0
  23. package/ciel_runtime_support/channel_connection_registry.py +128 -0
  24. package/ciel_runtime_support/channel_connection_worker.py +284 -0
  25. package/ciel_runtime_support/channel_cursor_recovery.py +92 -0
  26. package/ciel_runtime_support/channel_cursor_repository.py +89 -0
  27. package/ciel_runtime_support/channel_cursor_service.py +178 -0
  28. package/ciel_runtime_support/channel_event_identity.py +212 -0
  29. package/ciel_runtime_support/channel_event_projection.py +315 -0
  30. package/ciel_runtime_support/channel_inflight.py +127 -0
  31. package/ciel_runtime_support/channel_injection.py +115 -0
  32. package/ciel_runtime_support/channel_launch_guard_repository.py +58 -0
  33. package/ciel_runtime_support/channel_launch_policy.py +180 -0
  34. package/ciel_runtime_support/channel_llm_context.py +156 -0
  35. package/ciel_runtime_support/channel_mcp_discovery.py +186 -0
  36. package/ciel_runtime_support/channel_mcp_http_controller.py +239 -0
  37. package/ciel_runtime_support/channel_mcp_ownership.py +148 -0
  38. package/ciel_runtime_support/channel_mcp_tools.py +240 -0
  39. package/ciel_runtime_support/channel_mcp_transport.py +394 -0
  40. package/ciel_runtime_support/channel_message_dedupe.py +65 -0
  41. package/ciel_runtime_support/channel_message_policy.py +256 -0
  42. package/ciel_runtime_support/channel_message_prompt.py +305 -0
  43. package/ciel_runtime_support/channel_message_repository.py +234 -0
  44. package/ciel_runtime_support/channel_notification_projection.py +217 -0
  45. package/ciel_runtime_support/channel_panel.py +162 -0
  46. package/ciel_runtime_support/channel_pending_injection.py +209 -0
  47. package/ciel_runtime_support/channel_pending_poll.py +109 -0
  48. package/ciel_runtime_support/channel_probe_cache.py +433 -0
  49. package/ciel_runtime_support/channel_probe_report.py +101 -0
  50. package/ciel_runtime_support/channel_runtime_environment.py +181 -0
  51. package/ciel_runtime_support/channel_session_lifecycle.py +114 -0
  52. package/ciel_runtime_support/channel_session_repository.py +90 -0
  53. package/ciel_runtime_support/channel_terminal_dispatch.py +115 -0
  54. package/ciel_runtime_support/channel_terminal_input.py +277 -0
  55. package/ciel_runtime_support/channel_terminal_proxy.py +447 -0
  56. package/ciel_runtime_support/channel_tool_context.py +166 -0
  57. package/ciel_runtime_support/channel_transcript.py +414 -0
  58. package/ciel_runtime_support/channel_transcript_repository.py +96 -0
  59. package/ciel_runtime_support/channel_wake_claim_repository.py +126 -0
  60. package/ciel_runtime_support/channel_wake_delivery_repository.py +88 -0
  61. package/ciel_runtime_support/chat_files.py +138 -0
  62. package/ciel_runtime_support/chat_http_controller.py +235 -0
  63. package/ciel_runtime_support/claude_environment.py +375 -0
  64. package/ciel_runtime_support/claude_router.py +247 -193
  65. package/ciel_runtime_support/cli_dispatch.py +792 -0
  66. package/ciel_runtime_support/cli_parser.py +165 -0
  67. package/ciel_runtime_support/cli_usage.py +100 -0
  68. package/ciel_runtime_support/codex_app_server.py +20 -5
  69. package/ciel_runtime_support/codex_channel_sse_launch.py +87 -0
  70. package/ciel_runtime_support/codex_cli.py +42 -6
  71. package/ciel_runtime_support/codex_config.py +323 -0
  72. package/ciel_runtime_support/codex_launch_configuration.py +240 -0
  73. package/ciel_runtime_support/codex_launch_policy.py +66 -0
  74. package/ciel_runtime_support/codex_mcp_integration.py +195 -0
  75. package/ciel_runtime_support/codex_mcp_restore.py +304 -0
  76. package/ciel_runtime_support/codex_model_catalog.py +133 -0
  77. package/ciel_runtime_support/codex_process_lifecycle.py +271 -0
  78. package/ciel_runtime_support/codex_router.py +147 -1
  79. package/ciel_runtime_support/codex_session_repository.py +115 -0
  80. package/ciel_runtime_support/codex_session_selection.py +114 -0
  81. package/ciel_runtime_support/command_asset_installer.py +103 -0
  82. package/ciel_runtime_support/compatibility_probe.py +295 -0
  83. package/ciel_runtime_support/compatibility_protocol.py +251 -0
  84. package/ciel_runtime_support/compatibility_runtime.py +166 -0
  85. package/ciel_runtime_support/compatibility_test.py +370 -0
  86. package/ciel_runtime_support/config_migrations.py +307 -0
  87. package/ciel_runtime_support/config_repository.py +175 -0
  88. package/ciel_runtime_support/config_value_codec.py +64 -0
  89. package/ciel_runtime_support/configuration_cli.py +374 -0
  90. package/ciel_runtime_support/context_compaction.py +280 -0
  91. package/ciel_runtime_support/context_setup.py +208 -0
  92. package/ciel_runtime_support/context_summary_policy.py +392 -0
  93. package/ciel_runtime_support/credential_cli.py +104 -0
  94. package/ciel_runtime_support/credential_management.py +261 -0
  95. package/ciel_runtime_support/credentials.py +269 -0
  96. package/ciel_runtime_support/executable_discovery.py +141 -0
  97. package/ciel_runtime_support/github_copilot_oauth.py +335 -0
  98. package/ciel_runtime_support/github_copilot_oauth_runtime.py +213 -0
  99. package/ciel_runtime_support/header_forwarding.py +73 -0
  100. package/ciel_runtime_support/headless_config.py +221 -0
  101. package/ciel_runtime_support/http_response.py +129 -0
  102. package/ciel_runtime_support/install_diagnostics.py +149 -0
  103. package/ciel_runtime_support/kimi_identity.py +123 -0
  104. package/ciel_runtime_support/launch_diagnostics.py +204 -0
  105. package/ciel_runtime_support/launch_state.py +127 -0
  106. package/ciel_runtime_support/live_api_key_controller.py +58 -0
  107. package/ciel_runtime_support/llm_config_http.py +148 -0
  108. package/ciel_runtime_support/llm_option_config.py +259 -0
  109. package/ciel_runtime_support/llm_presentation_data.py +447 -0
  110. package/ciel_runtime_support/llm_presets.py +773 -0
  111. package/ciel_runtime_support/lm_studio_runtime.py +401 -0
  112. package/ciel_runtime_support/managed_mcp_config.py +144 -0
  113. package/ciel_runtime_support/managed_mcp_discovery.py +151 -0
  114. package/ciel_runtime_support/managed_service_cleanup.py +89 -0
  115. package/ciel_runtime_support/mcp_config_reader.py +230 -0
  116. package/ciel_runtime_support/mcp_http_proxy.py +607 -0
  117. package/ciel_runtime_support/mcp_inventory.py +59 -0
  118. package/ciel_runtime_support/mcp_notification_wait_policy.py +159 -0
  119. package/ciel_runtime_support/mcp_probe_codec.py +136 -0
  120. package/ciel_runtime_support/mcp_probe_transport.py +328 -0
  121. package/ciel_runtime_support/mcp_proxy_codec.py +345 -0
  122. package/ciel_runtime_support/mcp_proxy_config.py +107 -0
  123. package/ciel_runtime_support/mcp_proxy_notifications.py +261 -0
  124. package/ciel_runtime_support/mcp_proxy_process.py +560 -0
  125. package/ciel_runtime_support/mcp_split_proxy_http.py +165 -0
  126. package/ciel_runtime_support/mcp_stdio_probe.py +240 -0
  127. package/ciel_runtime_support/mcp_transport.py +146 -0
  128. package/ciel_runtime_support/model_cache_lifecycle.py +78 -0
  129. package/ciel_runtime_support/model_catalog_projection.py +61 -0
  130. package/ciel_runtime_support/model_context_hints.py +109 -0
  131. package/ciel_runtime_support/model_panel.py +147 -0
  132. package/ciel_runtime_support/model_registry_repository.py +231 -0
  133. package/ciel_runtime_support/npm_runtime.py +191 -0
  134. package/ciel_runtime_support/ollama_catalog.py +462 -0
  135. package/ciel_runtime_support/ollama_catalog_cli.py +41 -0
  136. package/ciel_runtime_support/ollama_catalog_repository.py +75 -0
  137. package/ciel_runtime_support/ollama_context_sync.py +87 -0
  138. package/ciel_runtime_support/ollama_forwarding.py +449 -0
  139. package/ciel_runtime_support/openai_chat_passthrough.py +67 -0
  140. package/ciel_runtime_support/openai_chat_router.py +64 -0
  141. package/ciel_runtime_support/openai_forwarding.py +194 -0
  142. package/ciel_runtime_support/openai_responses_router.py +291 -0
  143. package/ciel_runtime_support/openai_responses_stream.py +135 -0
  144. package/ciel_runtime_support/output_budget.py +89 -0
  145. package/ciel_runtime_support/package_lifecycle.py +217 -0
  146. package/ciel_runtime_support/plan_artifact_controller.py +104 -0
  147. package/ciel_runtime_support/prelaunch.py +959 -0
  148. package/ciel_runtime_support/prelaunch_launch_preference.py +75 -0
  149. package/ciel_runtime_support/prelaunch_panel_projection.py +396 -0
  150. package/ciel_runtime_support/prelaunch_terminal.py +764 -0
  151. package/ciel_runtime_support/process_control.py +708 -0
  152. package/ciel_runtime_support/prompt_compaction.py +322 -0
  153. package/ciel_runtime_support/prompt_injection.py +176 -0
  154. package/ciel_runtime_support/protocols/__init__.py +24 -0
  155. package/ciel_runtime_support/protocols/anthropic_content.py +29 -0
  156. package/ciel_runtime_support/protocols/anthropic_thinking_policy.py +336 -0
  157. package/ciel_runtime_support/protocols/chat_projection.py +315 -0
  158. package/ciel_runtime_support/protocols/conversation_policy.py +217 -0
  159. package/ciel_runtime_support/protocols/conversation_turn_policy.py +972 -0
  160. package/ciel_runtime_support/protocols/ollama_chat.py +111 -0
  161. package/ciel_runtime_support/protocols/ollama_response.py +231 -0
  162. package/ciel_runtime_support/protocols/openai_reasoning.py +75 -0
  163. package/ciel_runtime_support/protocols/openai_responses.py +271 -0
  164. package/ciel_runtime_support/protocols/pseudo_tool_history.py +248 -0
  165. package/ciel_runtime_support/protocols/tool_result_projection.py +112 -0
  166. package/ciel_runtime_support/provider_adapters.py +160 -0
  167. package/ciel_runtime_support/provider_catalog_sources.py +316 -0
  168. package/ciel_runtime_support/provider_choice.py +201 -0
  169. package/ciel_runtime_support/provider_compatibility.py +165 -0
  170. package/ciel_runtime_support/provider_config_mutations.py +361 -0
  171. package/ciel_runtime_support/provider_configuration_service.py +162 -0
  172. package/ciel_runtime_support/provider_context.py +308 -0
  173. package/ciel_runtime_support/provider_contract_projection.py +73 -0
  174. package/ciel_runtime_support/provider_descriptor.py +82 -0
  175. package/ciel_runtime_support/provider_endpoint_policy.py +130 -0
  176. package/ciel_runtime_support/provider_endpoint_probe.py +165 -0
  177. package/ciel_runtime_support/provider_launch_endpoint.py +109 -0
  178. package/ciel_runtime_support/provider_limits.py +457 -0
  179. package/ciel_runtime_support/provider_model_identity.py +139 -0
  180. package/ciel_runtime_support/provider_model_selection.py +431 -0
  181. package/ciel_runtime_support/provider_model_specs.py +142 -0
  182. package/ciel_runtime_support/provider_models.py +263 -0
  183. package/ciel_runtime_support/provider_network.py +176 -0
  184. package/ciel_runtime_support/provider_option_cli.py +238 -0
  185. package/ciel_runtime_support/provider_option_panel.py +275 -0
  186. package/ciel_runtime_support/provider_option_status.py +192 -0
  187. package/ciel_runtime_support/provider_policy.py +101 -0
  188. package/ciel_runtime_support/provider_query_policy.py +67 -0
  189. package/ciel_runtime_support/provider_readiness.py +112 -0
  190. package/ciel_runtime_support/provider_request_access.py +131 -0
  191. package/ciel_runtime_support/provider_request_builder.py +250 -0
  192. package/ciel_runtime_support/provider_responses_passthrough.py +81 -0
  193. package/ciel_runtime_support/provider_runtime_info.py +113 -0
  194. package/ciel_runtime_support/provider_runtime_modes.py +150 -0
  195. package/ciel_runtime_support/provider_sampling_policy.py +46 -0
  196. package/ciel_runtime_support/provider_status.py +145 -0
  197. package/ciel_runtime_support/provider_timeout_policy.py +184 -0
  198. package/ciel_runtime_support/provider_tool_policy.py +145 -0
  199. package/ciel_runtime_support/providers/__init__.py +65 -0
  200. package/ciel_runtime_support/providers/anthropic.py +160 -0
  201. package/ciel_runtime_support/providers/anthropic_catalog.py +119 -0
  202. package/ciel_runtime_support/providers/base.py +232 -0
  203. package/ciel_runtime_support/providers/catalog.py +326 -0
  204. package/ciel_runtime_support/providers/cloud.py +194 -0
  205. package/ciel_runtime_support/providers/constants.py +54 -0
  206. package/ciel_runtime_support/providers/deepseek.py +115 -0
  207. package/ciel_runtime_support/providers/fireworks.py +158 -0
  208. package/ciel_runtime_support/providers/github_copilot_oauth.py +199 -0
  209. package/ciel_runtime_support/providers/kimi.py +297 -0
  210. package/ciel_runtime_support/providers/lm_studio.py +76 -0
  211. package/ciel_runtime_support/providers/meta.py +257 -0
  212. package/ciel_runtime_support/providers/native.py +194 -0
  213. package/ciel_runtime_support/providers/nim.py +69 -0
  214. package/ciel_runtime_support/providers/nvidia.py +158 -0
  215. package/ciel_runtime_support/providers/nvidia_runtime.py +285 -0
  216. package/ciel_runtime_support/providers/ollama.py +181 -0
  217. package/ciel_runtime_support/providers/ollama_context.py +195 -0
  218. package/ciel_runtime_support/providers/ollama_runtime.py +213 -0
  219. package/ciel_runtime_support/providers/opencode.py +220 -0
  220. package/ciel_runtime_support/providers/opencode_go.py +37 -0
  221. package/ciel_runtime_support/providers/openrouter.py +57 -0
  222. package/ciel_runtime_support/providers/vllm.py +65 -0
  223. package/ciel_runtime_support/providers/zai.py +119 -0
  224. package/ciel_runtime_support/pseudo_tool_parser.py +115 -0
  225. package/ciel_runtime_support/rate_limit_policy.py +117 -0
  226. package/ciel_runtime_support/rate_limit_repository.py +154 -0
  227. package/ciel_runtime_support/registry.py +46 -0
  228. package/ciel_runtime_support/request_shortcuts.py +253 -0
  229. package/ciel_runtime_support/request_trace.py +323 -0
  230. package/ciel_runtime_support/response_collection.py +209 -0
  231. package/ciel_runtime_support/router_access.py +238 -0
  232. package/ciel_runtime_support/router_client_lifecycle.py +366 -0
  233. package/ciel_runtime_support/router_health_policy.py +101 -0
  234. package/ciel_runtime_support/router_http.py +513 -0
  235. package/ciel_runtime_support/router_process_lifecycle.py +401 -0
  236. package/ciel_runtime_support/router_rate_limit_service.py +285 -0
  237. package/ciel_runtime_support/router_server_runtime.py +103 -0
  238. package/ciel_runtime_support/router_shortcuts.py +201 -0
  239. package/ciel_runtime_support/routing_fallback.py +73 -0
  240. package/ciel_runtime_support/runtime_activity_repository.py +143 -0
  241. package/ciel_runtime_support/runtime_adapters.py +104 -0
  242. package/ciel_runtime_support/runtime_command_factory.py +73 -0
  243. package/ciel_runtime_support/runtime_compatibility.py +50 -0
  244. package/ciel_runtime_support/runtime_constants.py +178 -0
  245. package/ciel_runtime_support/runtime_launch.py +1602 -0
  246. package/ciel_runtime_support/runtime_llm_options.py +312 -0
  247. package/ciel_runtime_support/runtime_logging.py +161 -0
  248. package/ciel_runtime_support/runtime_paths.py +157 -0
  249. package/ciel_runtime_support/runtime_restart.py +84 -0
  250. package/ciel_runtime_support/runtime_upgrade.py +149 -0
  251. package/ciel_runtime_support/secure_json_repository.py +55 -0
  252. package/ciel_runtime_support/session_import.py +356 -0
  253. package/ciel_runtime_support/settings_repository.py +8 -0
  254. package/ciel_runtime_support/slash_command_assets.py +211 -0
  255. package/ciel_runtime_support/sse_stream.py +57 -0
  256. package/ciel_runtime_support/sse_trace.py +225 -0
  257. package/ciel_runtime_support/statusline_script.py +593 -0
  258. package/ciel_runtime_support/statusline_settings.py +53 -0
  259. package/ciel_runtime_support/stream_chunk_policy.py +18 -0
  260. package/ciel_runtime_support/streaming_anthropic.py +1955 -0
  261. package/ciel_runtime_support/synthetic_tool_policy.py +105 -0
  262. package/ciel_runtime_support/terminal_platform_io.py +127 -0
  263. package/ciel_runtime_support/timeout_profile.py +196 -0
  264. package/ciel_runtime_support/tool_dialects.py +85 -0
  265. package/ciel_runtime_support/tool_exposure_policy.py +63 -0
  266. package/ciel_runtime_support/tool_guard_hooks.py +218 -0
  267. package/ciel_runtime_support/tool_request_projection.py +96 -0
  268. package/ciel_runtime_support/tool_schema.py +483 -0
  269. package/ciel_runtime_support/tool_side_effect_dedupe.py +95 -0
  270. package/ciel_runtime_support/ui_text.py +266 -0
  271. package/ciel_runtime_support/upstream_error_policy.py +104 -0
  272. package/ciel_runtime_support/upstream_retry.py +419 -0
  273. package/ciel_runtime_support/upstream_stream_io.py +106 -0
  274. package/ciel_runtime_support/usage_events.py +96 -0
  275. package/ciel_runtime_support/visible_stream_filters.py +130 -0
  276. package/ciel_runtime_support/web_endpoints.py +447 -0
  277. package/ciel_runtime_support/web_ui.py +915 -0
  278. package/ciel_runtime_support/web_ui_controller.py +189 -0
  279. package/ciel_runtime_support/windows_console_input.py +137 -0
  280. package/ciel_runtime_support/windows_console_mode.py +112 -0
  281. package/docs/Architecture.md +54 -0
  282. package/docs/Configuration.md +17 -0
  283. package/docs/Module-Map.md +1093 -26
  284. package/docs/Providers.md +46 -1
  285. package/docs/adr/0001-runtime-bounded-contexts.md +295 -0
  286. package/npm-bin/run-ciel-runtime.js +22 -2
  287. package/package.json +9 -2
@@ -0,0 +1,401 @@
1
+ """Router process shutdown and port-replacement application policy."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from dataclasses import dataclass
7
+ import os
8
+ from pathlib import Path
9
+ import subprocess
10
+ import sys
11
+ import threading
12
+ from typing import Any
13
+
14
+
15
+ def schedule_router_restart(
16
+ delay: float,
17
+ script_path: Path,
18
+ log: Callable[[str, str], None],
19
+ *,
20
+ timer_factory: Callable[..., Any] = threading.Timer,
21
+ exec_process: Callable[[str, list[str]], Any] = os.execv,
22
+ executable: str = sys.executable,
23
+ stderr: Any = sys.stderr,
24
+ ) -> None:
25
+ def restart() -> None:
26
+ try:
27
+ log("INFO", "router_debug_restart execing router process")
28
+ exec_process(
29
+ executable, [executable, str(script_path), "serve"]
30
+ )
31
+ except Exception as exc:
32
+ try:
33
+ log(
34
+ "ERROR",
35
+ f"router_debug_restart_failed {type(exc).__name__}: {exc}",
36
+ )
37
+ except Exception as log_exc:
38
+ try:
39
+ stderr.write(
40
+ "router_debug_restart_diagnostic_failed "
41
+ f"restart_error={type(exc).__name__}: {exc} "
42
+ f"log_error={type(log_exc).__name__}: {log_exc}\n"
43
+ )
44
+ stderr.flush()
45
+ except (OSError, ValueError):
46
+ return
47
+
48
+ timer = timer_factory(delay, restart)
49
+ timer.daemon = True
50
+ timer.start()
51
+
52
+
53
+ @dataclass(frozen=True, slots=True)
54
+ class RouterProcessConfig:
55
+ pid_path: Path
56
+ router_port: int
57
+ router_base: str
58
+ config_dir: Path
59
+
60
+
61
+ @dataclass(frozen=True, slots=True)
62
+ class RouterStatePorts:
63
+ health: Callable[[], dict[str, Any] | None]
64
+ foreign_config: Callable[[dict[str, Any] | None], bool]
65
+ current_config: Callable[[dict[str, Any] | None], bool]
66
+ log: Callable[[str, str], None]
67
+
68
+
69
+ @dataclass(frozen=True, slots=True)
70
+ class RouterTerminationPorts:
71
+ terminate_pid: Callable[[int, str, bool], bool]
72
+ terminate_pid_file: Callable[[Path, str, bool], bool]
73
+ terminate_health: Callable[[dict[str, Any] | None, bool], bool]
74
+ stop_processes: Callable[[bool], bool]
75
+ listener_pids: Callable[[], list[int]]
76
+
77
+
78
+ @dataclass(frozen=True, slots=True)
79
+ class ClockPorts:
80
+ now: Callable[[], float]
81
+ sleep: Callable[[float], None]
82
+
83
+
84
+ @dataclass(frozen=True, slots=True)
85
+ class RouterStartupIdentity:
86
+ version: str
87
+ source_fingerprint: str
88
+
89
+
90
+ @dataclass(frozen=True, slots=True)
91
+ class RouterStartupStatePorts:
92
+ health: Callable[[], dict[str, Any] | None]
93
+ active_client_pids: Callable[[], list[int]]
94
+ health_matches_current: Callable[[dict[str, Any] | None], bool]
95
+ health_config_matches_current: Callable[[dict[str, Any] | None], bool]
96
+ terminate_active_clients: Callable[..., bool]
97
+ ensure_port_available: Callable[..., None]
98
+ reuse_enabled: Callable[[], bool]
99
+ log: Callable[[str, str], None]
100
+
101
+
102
+ @dataclass(frozen=True, slots=True)
103
+ class RouterSpawnPorts:
104
+ popen: Callable[..., Any]
105
+ router_up: Callable[[], bool]
106
+ now: Callable[[], float]
107
+ sleep: Callable[[float], None]
108
+ process_id: Callable[[], int]
109
+ environment: Callable[[], dict[str, str]]
110
+
111
+
112
+ def start_router_if_needed(
113
+ *,
114
+ replace_active_clients: bool,
115
+ config: RouterProcessConfig,
116
+ identity: RouterStartupIdentity,
117
+ state: RouterStartupStatePorts,
118
+ spawn: RouterSpawnPorts,
119
+ executable: str,
120
+ entrypoint: Path,
121
+ log_path: Path,
122
+ platform_name: str,
123
+ ) -> bool:
124
+ health = state.health()
125
+ if health is not None:
126
+ active_clients = state.active_client_pids()
127
+ if state.health_matches_current(health):
128
+ if active_clients:
129
+ if replace_active_clients:
130
+ state.log(
131
+ "WARN",
132
+ "router_prelaunch_replace_active_clients "
133
+ f"base={config.router_base} active_clients={','.join(map(str, active_clients))}",
134
+ )
135
+ state.terminate_active_clients("prelaunch_active_clients", active_clients, quiet=True)
136
+ state.ensure_port_available("prelaunch_active_clients", health)
137
+ else:
138
+ state.log(
139
+ "INFO",
140
+ "router_check_state running=True spawn=False "
141
+ f"base={config.router_base} active_clients={','.join(map(str, active_clients))}",
142
+ )
143
+ return True
144
+ elif state.reuse_enabled():
145
+ state.log("INFO", f"router_check_state running=True spawn=False base={config.router_base} reuse=env")
146
+ return True
147
+ else:
148
+ state.log(
149
+ "INFO",
150
+ "router_prelaunch_replace "
151
+ f"running_version={health.get('version') or '-'} current_version={identity.version} "
152
+ f"running_source={health.get('source_fingerprint') or '-'} current_source={identity.source_fingerprint} "
153
+ f"pid={health.get('pid') or '-'}",
154
+ )
155
+ state.ensure_port_available("prelaunch_replace", health)
156
+ elif state.health_config_matches_current(health) and active_clients:
157
+ if replace_active_clients:
158
+ state.log(
159
+ "WARN",
160
+ "router_version_mismatch_replace_active_clients "
161
+ f"running_version={health.get('version') or '-'} current_version={identity.version} "
162
+ f"active_clients={','.join(map(str, active_clients))}",
163
+ )
164
+ state.terminate_active_clients("version_mismatch_active_clients", active_clients, quiet=True)
165
+ state.ensure_port_available("version_mismatch_active_clients", health)
166
+ else:
167
+ raise RuntimeError(
168
+ f"ciel-runtime router on {config.router_base} belongs to this config but has active clients "
169
+ f"({','.join(map(str, active_clients))}) and differs from this launch "
170
+ f"(running_version={health.get('version') or '-'}, current_version={identity.version}). "
171
+ "Stop the other Claude Code session or launch this instance with a different "
172
+ "CIEL_RUNTIME_ROUTER_PORT."
173
+ )
174
+ else:
175
+ state.log(
176
+ "WARN",
177
+ "router_version_mismatch_restart "
178
+ f"running_version={health.get('version') or '-'} current_version={identity.version} "
179
+ f"running_source={health.get('source_fingerprint') or '-'} current_source={identity.source_fingerprint}",
180
+ )
181
+ state.ensure_port_available("version_mismatch", health)
182
+ else:
183
+ state.ensure_port_available("pre_spawn", None)
184
+ config.config_dir.mkdir(parents=True, exist_ok=True)
185
+ command = [executable, str(entrypoint), "serve"]
186
+ kwargs: dict[str, Any] = {}
187
+ if platform_name == "nt":
188
+ flags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
189
+ if flags:
190
+ kwargs["creationflags"] = flags
191
+ else:
192
+ kwargs["start_new_session"] = True
193
+ state.log("INFO", f"router_check_state running=False spawn=True base={config.router_base}")
194
+ environment = spawn.environment()
195
+ environment["CIEL_RUNTIME_MANAGED_ROUTER"] = "1"
196
+ environment["CIEL_RUNTIME_ROUTER_OWNER_PID"] = str(spawn.process_id())
197
+ with log_path.open("ab", buffering=0) as log:
198
+ spawn.popen(
199
+ command,
200
+ stdin=subprocess.DEVNULL,
201
+ stdout=log,
202
+ stderr=log,
203
+ env=environment,
204
+ **kwargs,
205
+ )
206
+ started_at = spawn.now()
207
+ deadline = started_at + 30
208
+ while spawn.now() < deadline:
209
+ if spawn.router_up():
210
+ state.log("INFO", f"router_spawned running=True base={config.router_base} elapsed={spawn.now()-started_at:.1f}s")
211
+ return True
212
+ spawn.sleep(0.5)
213
+ raise RuntimeError(f"ciel-runtime router did not start. See {log_path}")
214
+
215
+
216
+ def terminate_pid_file(
217
+ path: Path,
218
+ label: str,
219
+ quiet: bool,
220
+ *,
221
+ terminate_pid: Callable[[int, str, bool], bool],
222
+ pid_is_running: Callable[[int], bool],
223
+ ) -> bool:
224
+ if not path.exists():
225
+ return False
226
+ try:
227
+ pid = int(path.read_text().strip())
228
+ except Exception:
229
+ try:
230
+ path.unlink()
231
+ except FileNotFoundError:
232
+ pass
233
+ return False
234
+ stopped = terminate_pid(pid, label, quiet)
235
+ if stopped or not pid_is_running(pid):
236
+ try:
237
+ path.unlink()
238
+ except FileNotFoundError:
239
+ pass
240
+ return stopped
241
+
242
+
243
+ def terminate_health_pid(
244
+ health: dict[str, Any] | None,
245
+ quiet: bool,
246
+ *,
247
+ config: RouterProcessConfig,
248
+ state: RouterStatePorts,
249
+ terminate_pid: Callable[[int, str, bool], bool],
250
+ protected_pids: tuple[int, int],
251
+ ) -> bool:
252
+ if not isinstance(health, dict):
253
+ return False
254
+ if not state.current_config(health):
255
+ state.log(
256
+ "INFO",
257
+ "router_kill_skipped_foreign_config "
258
+ f"running_config={health.get('config_dir') or '-'} "
259
+ f"current_config={config.config_dir}",
260
+ )
261
+ return False
262
+ try:
263
+ pid = int(health.get("pid") or 0)
264
+ except Exception:
265
+ return False
266
+ if pid in protected_pids:
267
+ return False
268
+ return terminate_pid(pid, "ciel-runtime router", quiet)
269
+
270
+
271
+ def stop_router_processes(
272
+ quiet: bool,
273
+ *,
274
+ config: RouterProcessConfig,
275
+ state: RouterStatePorts,
276
+ termination: RouterTerminationPorts,
277
+ ) -> bool:
278
+ stopped = termination.terminate_pid_file(config.pid_path, "ciel-runtime router", quiet)
279
+ health = state.health()
280
+ if state.foreign_config(health):
281
+ state.log(
282
+ "INFO",
283
+ "router_stop_skipped_foreign_config "
284
+ f"running_config={health.get('config_dir') or '-'} "
285
+ f"current_config={config.config_dir}",
286
+ )
287
+ return stopped
288
+ if state.current_config(health):
289
+ stopped = termination.terminate_health(health, True) or stopped
290
+ return stopped
291
+
292
+
293
+ def ensure_port_available(
294
+ reason: str,
295
+ health: dict[str, Any] | None,
296
+ max_wait_seconds: float,
297
+ *,
298
+ config: RouterProcessConfig,
299
+ state: RouterStatePorts,
300
+ termination: RouterTerminationPorts,
301
+ clock: ClockPorts,
302
+ ) -> None:
303
+ if state.foreign_config(health):
304
+ raise RuntimeError(_foreign_port_message(config, health))
305
+ stopped = termination.terminate_health(health, True)
306
+ stopped = termination.stop_processes(True) or stopped
307
+ deadline = clock.now() + max(0.1, max_wait_seconds)
308
+ while clock.now() < deadline:
309
+ current_health = state.health()
310
+ if state.foreign_config(current_health):
311
+ raise RuntimeError(_foreign_port_message(config, current_health))
312
+ if current_health is None and not termination.listener_pids():
313
+ state.log(
314
+ "INFO",
315
+ f"router_port_clear reason={reason} port={config.router_port} "
316
+ f"stopped={str(stopped).lower()}",
317
+ )
318
+ return
319
+ if current_health is not None:
320
+ termination.terminate_health(current_health, True)
321
+ termination.stop_processes(True)
322
+ clock.sleep(0.1)
323
+ pids = termination.listener_pids()
324
+ current_health = state.health()
325
+ description = ""
326
+ if isinstance(current_health, dict):
327
+ description = (
328
+ f" version={current_health.get('version') or '-'}"
329
+ f" source={current_health.get('source_fingerprint') or '-'}"
330
+ f" pid={current_health.get('pid') or '-'}"
331
+ )
332
+ raise RuntimeError(
333
+ f"stale ciel-runtime router is still serving on {config.router_base}; "
334
+ f"port {config.router_port} listener_pids={pids or '-'}{description}; "
335
+ "run `ciel-runtime stop` and launch again."
336
+ )
337
+
338
+
339
+ def stop_with_guarantee(
340
+ reason: str,
341
+ max_wait_seconds: float,
342
+ quiet: bool,
343
+ *,
344
+ config: RouterProcessConfig,
345
+ state: RouterStatePorts,
346
+ termination: RouterTerminationPorts,
347
+ clock: ClockPorts,
348
+ ) -> bool:
349
+ initial_health = state.health()
350
+ if initial_health is None:
351
+ state.log("INFO", f"router_kill_guarantee reason={reason} state=already_down")
352
+ return False
353
+ if state.foreign_config(initial_health):
354
+ state.log(
355
+ "INFO",
356
+ "router_kill_guarantee_skipped_foreign_config "
357
+ f"reason={reason} running_config={initial_health.get('config_dir') or '-'} "
358
+ f"current_config={config.config_dir}",
359
+ )
360
+ return False
361
+ termination.stop_processes(quiet)
362
+ deadline = clock.now() + max(0.1, max_wait_seconds)
363
+ while clock.now() < deadline:
364
+ health = state.health()
365
+ if state.foreign_config(health):
366
+ state.log(
367
+ "INFO",
368
+ "router_kill_guarantee_skipped_foreign_config "
369
+ f"reason={reason} running_config={health.get('config_dir') or '-'} "
370
+ f"current_config={config.config_dir}",
371
+ )
372
+ return False
373
+ if health is None:
374
+ elapsed_ms = int((max_wait_seconds - (deadline - clock.now())) * 1000)
375
+ state.log(
376
+ "INFO",
377
+ f"router_kill_guarantee reason={reason} state=killed elapsed_ms={elapsed_ms}",
378
+ )
379
+ return True
380
+ clock.sleep(0.1)
381
+ state.log(
382
+ "ERROR",
383
+ f"router_kill_guarantee reason={reason} state=still_up_after_{max_wait_seconds:.1f}s",
384
+ )
385
+ raise RuntimeError(
386
+ f"ciel-runtime router is still serving on {config.router_base} after a "
387
+ f"{max_wait_seconds:.1f}s shutdown attempt for '{reason}'. Aborting to prevent "
388
+ "the subsequent claude launch from accidentally routing through it. "
389
+ f"Investigate the PID at {config.pid_path} or use `ciel-runtime stop` manually."
390
+ )
391
+
392
+
393
+ def _foreign_port_message(
394
+ config: RouterProcessConfig, health: dict[str, Any] | None
395
+ ) -> str:
396
+ foreign_config = health.get("config_dir") if isinstance(health, dict) else "-"
397
+ return (
398
+ f"ciel-runtime router port {config.router_port} is already used by another "
399
+ f"ciel-runtime config ({foreign_config}). Set CIEL_RUNTIME_ROUTER_PORT or "
400
+ "CIEL_RUNTIME_CONFIG_DIR for this instance instead of killing the other router."
401
+ )
@@ -0,0 +1,285 @@
1
+ """Router-wide provider rate-limit application service."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from collections.abc import Callable
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from threading import Lock, RLock
10
+ from typing import Any
11
+
12
+ from . import rate_limit_policy
13
+ from .provider_limits import (
14
+ RateLimitApplyPolicy,
15
+ RateLimitApplyServices,
16
+ RateLimitBackoffPolicy,
17
+ RateLimitBackoffServices,
18
+ RateLimitLearningPolicy,
19
+ RateLimitLearningServices,
20
+ RateLimitStateStore,
21
+ apply_rate_limit,
22
+ learn_rate_limit_headers,
23
+ register_rate_limit_backoff,
24
+ )
25
+ from .rate_limit_repository import RateLimitRepository
26
+
27
+
28
+ @dataclass(frozen=True, slots=True)
29
+ class RouterRateLimitPaths:
30
+ config_dir: Path
31
+ state_path: Path
32
+ lock: Lock | RLock
33
+
34
+
35
+ @dataclass(frozen=True, slots=True)
36
+ class RouterRateLimitPorts:
37
+ current_model_id: Callable[[str, dict[str, Any]], str]
38
+ api_key_count: Callable[[str, dict[str, Any]], int]
39
+ positive_int: Callable[[Any], int | None]
40
+ log: Callable[[str, str], None]
41
+ now: Callable[[], float] = time.time
42
+ sleep: Callable[[float], None] = time.sleep
43
+
44
+
45
+ @dataclass(frozen=True, slots=True)
46
+ class RouterRateLimitService:
47
+ paths: RouterRateLimitPaths
48
+ repository: RateLimitRepository
49
+ ports: RouterRateLimitPorts
50
+
51
+ def legacy_key(self, provider: str, config: dict[str, Any], model: str | None) -> str:
52
+ return f"{provider}:{model or self.ports.current_model_id(provider, config)}"
53
+
54
+ def configured_rpm(self, provider: str, config: dict[str, Any]) -> int | None:
55
+ del provider
56
+ return rate_limit_policy.configured_rpm(config, self.ports.positive_int)
57
+
58
+ def rpm(self, provider: str, config: dict[str, Any]) -> int | None:
59
+ rpm = self.configured_rpm(provider, config)
60
+ return rpm if rpm and rpm > 0 else None
61
+
62
+ @staticmethod
63
+ def key(provider: str, config: dict[str, Any], model: str | None = None) -> str:
64
+ del config, model
65
+ return f"{provider}:__global__"
66
+
67
+ def state_entry(
68
+ self, provider: str, config: dict[str, Any], model: str | None = None
69
+ ) -> dict[str, Any]:
70
+ return self.repository.entry(
71
+ self.key(provider, config, model), self.legacy_key(provider, config, model)
72
+ )
73
+
74
+ def effective_rpm(
75
+ self, provider: str, config: dict[str, Any], model: str | None = None
76
+ ) -> int | None:
77
+ return self.repository.effective_rpm(
78
+ self.key(provider, config, model),
79
+ self.legacy_key(provider, config, model),
80
+ self.configured_rpm(provider, config),
81
+ )
82
+
83
+ @staticmethod
84
+ def capacity(rpm: int) -> int:
85
+ return rate_limit_policy.capacity(rpm)
86
+
87
+ @staticmethod
88
+ def recent(
89
+ timestamps: Any, now: float, window: float, *, include_future: bool
90
+ ) -> list[float]:
91
+ return rate_limit_policy.recent_timestamps(
92
+ timestamps, now, window, include_future=include_future
93
+ )
94
+
95
+ def usage(
96
+ self, provider: str, config: dict[str, Any], model: str | None = None
97
+ ) -> tuple[int, int | None]:
98
+ return self.repository.usage(
99
+ self.key(provider, config, model),
100
+ self.legacy_key(provider, config, model),
101
+ self.effective_rpm(provider, config, model),
102
+ self.recent,
103
+ )
104
+
105
+ def record_usage(
106
+ self,
107
+ provider: str,
108
+ config: dict[str, Any],
109
+ model: str | None,
110
+ rpm: int | None,
111
+ ) -> tuple[int, int | None]:
112
+ return self.repository.record_usage(
113
+ self.key(provider, config, model),
114
+ self.legacy_key(provider, config, model),
115
+ rpm,
116
+ self.recent,
117
+ )
118
+
119
+ def _state_store(self) -> RateLimitStateStore:
120
+ return RateLimitStateStore(
121
+ CONFIG_DIR=self.paths.config_dir,
122
+ RATE_LIMIT_STATE_PATH=self.paths.state_path,
123
+ _RATE_LIMIT_LOCK=self.paths.lock,
124
+ router_log=self.ports.log,
125
+ )
126
+
127
+ def learn_headers(
128
+ self, provider: str, config: dict[str, Any], model: str | None, headers: Any
129
+ ) -> None:
130
+ learn_rate_limit_headers(
131
+ provider,
132
+ config,
133
+ model,
134
+ headers,
135
+ services=RateLimitLearningServices(
136
+ state_store=self._state_store(),
137
+ policy=RateLimitLearningPolicy(
138
+ current_upstream_model_id=self.ports.current_model_id,
139
+ first_header=rate_limit_policy.first_header,
140
+ first_int_in_header=rate_limit_policy.first_integer,
141
+ provider_api_key_count=self.ports.api_key_count,
142
+ rate_limit_reset_seconds=rate_limit_policy.reset_seconds,
143
+ router_rate_limit_configured_rpm=self.configured_rpm,
144
+ router_rate_limit_key=self.key,
145
+ router_rate_limit_recent=self.recent,
146
+ ),
147
+ ),
148
+ )
149
+
150
+ def register_backoff(
151
+ self,
152
+ provider: str,
153
+ config: dict[str, Any],
154
+ model: str | None,
155
+ retry_after: str | None = None,
156
+ ) -> float:
157
+ return register_rate_limit_backoff(
158
+ provider,
159
+ config,
160
+ model,
161
+ retry_after,
162
+ services=RateLimitBackoffServices(
163
+ state_store=self._state_store(),
164
+ policy=RateLimitBackoffPolicy(
165
+ current_upstream_model_id=self.ports.current_model_id,
166
+ parse_retry_after_seconds=rate_limit_policy.retry_after_seconds,
167
+ provider_api_key_count=self.ports.api_key_count,
168
+ router_rate_limit_capacity=self.capacity,
169
+ router_rate_limit_configured_rpm=self.configured_rpm,
170
+ router_rate_limit_effective_rpm=self.effective_rpm,
171
+ router_rate_limit_key=self.key,
172
+ router_rate_limit_recent=self.recent,
173
+ ),
174
+ ),
175
+ )
176
+
177
+ def apply(
178
+ self, provider: str, config: dict[str, Any], model: str | None = None
179
+ ) -> tuple[float, int, int | None]:
180
+ return apply_rate_limit(
181
+ provider,
182
+ config,
183
+ model,
184
+ services=RateLimitApplyServices(
185
+ state_store=self._state_store(),
186
+ policy=RateLimitApplyPolicy(
187
+ current_upstream_model_id=self.ports.current_model_id,
188
+ provider_api_key_count=self.ports.api_key_count,
189
+ record_router_rate_usage=self.record_usage,
190
+ router_rate_limit_capacity=self.capacity,
191
+ router_rate_limit_effective_rpm=self.effective_rpm,
192
+ router_rate_limit_key=self.key,
193
+ router_rate_limit_recent=self.recent,
194
+ wait_for_router_rate_limit_penalty=self.wait_for_penalty,
195
+ ),
196
+ ),
197
+ )
198
+
199
+ def wait_for_penalty(
200
+ self,
201
+ provider: str,
202
+ config: dict[str, Any],
203
+ model: str | None,
204
+ rpm: int | None,
205
+ ) -> float:
206
+ multi_key = self.ports.api_key_count(provider, config) > 1
207
+ waited = 0.0
208
+ while True:
209
+ entry = self.state_entry(provider, config, model)
210
+ now = self.ports.now()
211
+ try:
212
+ penalty_until = (
213
+ 0.0 if multi_key else float(entry.get("penalty_until") or 0.0)
214
+ )
215
+ except Exception:
216
+ penalty_until = 0.0
217
+ wait = max(0.0, penalty_until - now)
218
+ if wait <= 0.001:
219
+ return waited
220
+ sleep_for = min(wait, 10.0)
221
+ self.ports.log(
222
+ "INFO",
223
+ f"rate_limit_penalty_wait provider={provider} model={model or ''} "
224
+ f"rpm={rpm if rpm is not None else 'auto'} wait={wait:.2f}s "
225
+ f"waited={waited:.2f}s",
226
+ )
227
+ self.ports.sleep(sleep_for)
228
+ waited += sleep_for
229
+
230
+
231
+ @dataclass(frozen=True, slots=True)
232
+ class RouterRateLimitApi:
233
+ """Explicit compatibility API for late-bound router rate-limit services."""
234
+
235
+ service_factory: Callable[[], RouterRateLimitService]
236
+
237
+ def legacy_key(self, provider: str, pcfg: dict[str, Any], model: str | None) -> str:
238
+ return self.service_factory().legacy_key(provider, pcfg, model)
239
+
240
+ def configured_rpm(self, provider: str, pcfg: dict[str, Any]) -> int | None:
241
+ return self.service_factory().configured_rpm(provider, pcfg)
242
+
243
+ def rpm(self, provider: str, pcfg: dict[str, Any]) -> int | None:
244
+ return self.service_factory().rpm(provider, pcfg)
245
+
246
+ def key(self, provider: str, pcfg: dict[str, Any], model: str | None = None) -> str:
247
+ return self.service_factory().key(provider, pcfg, model)
248
+
249
+ def state_entry(self, provider: str, pcfg: dict[str, Any], model: str | None = None) -> dict[str, Any]:
250
+ return self.service_factory().state_entry(provider, pcfg, model)
251
+
252
+ def effective_rpm(self, provider: str, pcfg: dict[str, Any], model: str | None = None) -> int | None:
253
+ return self.service_factory().effective_rpm(provider, pcfg, model)
254
+
255
+ def capacity(self, rpm: int) -> int:
256
+ return self.service_factory().capacity(rpm)
257
+
258
+ def recent(self, timestamps: Any, now: float, window: float, *, include_future: bool) -> list[float]:
259
+ return self.service_factory().recent(timestamps, now, window, include_future=include_future)
260
+
261
+ def usage(self, provider: str, pcfg: dict[str, Any], model: str | None = None) -> tuple[int, int | None]:
262
+ return self.service_factory().usage(provider, pcfg, model)
263
+
264
+ def record_usage(self, provider: str, pcfg: dict[str, Any], model: str | None, rpm: int | None) -> tuple[int, int | None]:
265
+ return self.service_factory().record_usage(provider, pcfg, model, rpm)
266
+
267
+ def learn_headers(self, provider: str, pcfg: dict[str, Any], model: str | None, headers: Any) -> None:
268
+ self.service_factory().learn_headers(provider, pcfg, model, headers)
269
+
270
+ def register_backoff(self, provider: str, pcfg: dict[str, Any], model: str | None, retry_after: str | None = None) -> float:
271
+ return self.service_factory().register_backoff(provider, pcfg, model, retry_after)
272
+
273
+ def apply(self, provider: str, pcfg: dict[str, Any], model: str | None = None) -> tuple[float, int, int | None]:
274
+ return self.service_factory().apply(provider, pcfg, model)
275
+
276
+ def wait_for_penalty(self, provider: str, pcfg: dict[str, Any], model: str | None, rpm: int | None) -> float:
277
+ return self.service_factory().wait_for_penalty(provider, pcfg, model, rpm)
278
+
279
+
280
+ __all__ = [
281
+ "RouterRateLimitApi",
282
+ "RouterRateLimitPaths",
283
+ "RouterRateLimitPorts",
284
+ "RouterRateLimitService",
285
+ ]