@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,159 @@
1
+ """Per-credential rate-limit cooldown policy and application service."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import time
7
+ from collections.abc import Callable
8
+ from dataclasses import dataclass
9
+ from typing import Any
10
+
11
+ from . import rate_limit_policy
12
+ from .rate_limit_repository import RateLimitRepository
13
+
14
+
15
+ RATE_LIMIT_RESET_HEADER_NAMES = (
16
+ "x-ratelimit-reset-requests",
17
+ "x-rate-limit-reset-requests",
18
+ "ratelimit-reset",
19
+ "rate-limit-reset",
20
+ "x-ratelimit-reset",
21
+ "x-rate-limit-reset",
22
+ )
23
+ API_KEY_COOLDOWN_MAX_SECONDS = 90_000.0
24
+ API_KEY_COOLDOWN_DEFAULT_SECONDS = 60.0
25
+
26
+
27
+ @dataclass(frozen=True, slots=True)
28
+ class ApiKeyCooldownPorts:
29
+ repository: RateLimitRepository
30
+ rotation_name: Callable[[str, dict[str, Any]], str]
31
+ config_keys: Callable[[str, dict[str, Any]], list[str]]
32
+ meaningful_key: Callable[[str], bool]
33
+ log: Callable[[str, str], None]
34
+ now: Callable[[], float] = time.time
35
+
36
+
37
+ @dataclass(frozen=True, slots=True)
38
+ class ApiKeyCooldownService:
39
+ ports: ApiKeyCooldownPorts
40
+
41
+ def state_key(self, provider: str, config: dict[str, Any], key: str) -> str:
42
+ digest = hashlib.sha256(str(key).encode("utf-8")).hexdigest()[:12]
43
+ return f"{self.ports.rotation_name(provider, config)}:__key__:{digest}"
44
+
45
+ @staticmethod
46
+ def reset_seconds(headers: Any) -> float:
47
+ reset = rate_limit_policy.reset_seconds(
48
+ rate_limit_policy.first_header(headers, list(RATE_LIMIT_RESET_HEADER_NAMES))
49
+ )
50
+ if reset is None or reset <= 0:
51
+ reset = rate_limit_policy.retry_after_seconds(
52
+ rate_limit_policy.first_header(headers, ["Retry-After", "retry-after"])
53
+ )
54
+ if reset is None or reset <= 0:
55
+ reset = API_KEY_COOLDOWN_DEFAULT_SECONDS
56
+ return max(1.0, min(float(reset), API_KEY_COOLDOWN_MAX_SECONDS))
57
+
58
+ def register(
59
+ self,
60
+ provider: str,
61
+ config: dict[str, Any],
62
+ key: str,
63
+ headers: Any,
64
+ ) -> float:
65
+ if not self.ports.meaningful_key(key):
66
+ return 0.0
67
+ reset = self.reset_seconds(headers)
68
+ state_key = self.state_key(provider, config, key)
69
+ self.ports.repository.register_cooldown(state_key, reset)
70
+ self.ports.log(
71
+ "WARN",
72
+ f"api_key_cooldown provider={provider} "
73
+ f"key_hash={state_key.rsplit(':', 1)[-1]} rest={reset:.0f}s",
74
+ )
75
+ return reset
76
+
77
+ def cooldown_until(self, provider: str, config: dict[str, Any], key: str) -> float:
78
+ if not self.ports.meaningful_key(key):
79
+ return 0.0
80
+ return self.ports.repository.cooldown_until(self.state_key(provider, config, key))
81
+
82
+ def live_key_count(self, provider: str, config: dict[str, Any]) -> int:
83
+ keys = self.ports.config_keys(provider, config)
84
+ if len(keys) <= 1:
85
+ return len(keys)
86
+ now = self.ports.now()
87
+ return sum(1 for key in keys if self.cooldown_until(provider, config, key) <= now)
88
+
89
+ def has_live_key(self, provider: str, config: dict[str, Any]) -> bool:
90
+ return self.live_key_count(provider, config) > 0
91
+
92
+ def reset_for_router_start(self) -> int:
93
+ removed = self.ports.repository.reset_key_cooldowns()
94
+ if removed:
95
+ self.ports.log(
96
+ "INFO",
97
+ f"api_key_cooldown_reset_on_router_start removed={removed}",
98
+ )
99
+ return removed
100
+
101
+
102
+ @dataclass(frozen=True, slots=True)
103
+ class ApiKeyCooldownCompatibilityApi:
104
+ """Explicit facade surface that resolves the current repository per call."""
105
+
106
+ service_factory: Callable[[], ApiKeyCooldownService]
107
+
108
+ def state_key(self, provider: str, config: dict[str, Any], key: str) -> str:
109
+ return self.service_factory().state_key(provider, config, key)
110
+
111
+ @staticmethod
112
+ def reset_seconds(headers: Any) -> float:
113
+ return ApiKeyCooldownService.reset_seconds(headers)
114
+
115
+ def register(
116
+ self,
117
+ provider: str,
118
+ config: dict[str, Any],
119
+ key: str,
120
+ headers: Any,
121
+ ) -> float:
122
+ return self.service_factory().register(provider, config, key, headers)
123
+
124
+ def cooldown_until(
125
+ self, provider: str, config: dict[str, Any], key: str
126
+ ) -> float:
127
+ return self.service_factory().cooldown_until(provider, config, key)
128
+
129
+ def live_key_count(self, provider: str, config: dict[str, Any]) -> int:
130
+ return self.service_factory().live_key_count(provider, config)
131
+
132
+ def has_live_key(self, provider: str, config: dict[str, Any]) -> bool:
133
+ return self.service_factory().has_live_key(provider, config)
134
+
135
+ def reset_for_router_start(self) -> int:
136
+ return self.service_factory().reset_for_router_start()
137
+
138
+ @staticmethod
139
+ def retry_after_exceeds_request_timeout(
140
+ headers: Any, timeout: float
141
+ ) -> tuple[bool, float | None]:
142
+ retry_after = rate_limit_policy.first_header(
143
+ headers, ["Retry-After", "retry-after"]
144
+ )
145
+ seconds = rate_limit_policy.retry_after_seconds(retry_after)
146
+ if seconds is None:
147
+ return False, None
148
+ threshold = max(1.0, float(timeout) - 1.0)
149
+ return seconds >= threshold, seconds
150
+
151
+
152
+ __all__ = [
153
+ "API_KEY_COOLDOWN_DEFAULT_SECONDS",
154
+ "API_KEY_COOLDOWN_MAX_SECONDS",
155
+ "ApiKeyCooldownCompatibilityApi",
156
+ "ApiKeyCooldownPorts",
157
+ "ApiKeyCooldownService",
158
+ "RATE_LIMIT_RESET_HEADER_NAMES",
159
+ ]
@@ -19,11 +19,18 @@ from __future__ import annotations
19
19
  from abc import ABC, abstractmethod
20
20
  from dataclasses import dataclass, field
21
21
  from pathlib import Path
22
+ import re
22
23
  from typing import Any, Literal, Mapping, Sequence
23
24
 
24
25
 
25
26
  LaunchMode = Literal["native", "routed", "router"]
26
- MessageProtocol = Literal["anthropic_messages", "openai_chat", "openai_responses"]
27
+ MessageProtocol = Literal[
28
+ "anthropic_messages",
29
+ "openai_chat",
30
+ "openai_responses",
31
+ "ollama_chat",
32
+ "google_generative",
33
+ ]
27
34
 
28
35
 
29
36
  @dataclass(frozen=True)
@@ -100,6 +107,132 @@ class RateLimitState:
100
107
  detail: str | None = None
101
108
 
102
109
 
110
+ @dataclass(frozen=True)
111
+ class ProviderCapabilities:
112
+ """Provider-owned features that affect routing without naming providers."""
113
+
114
+ upstream_protocol: MessageProtocol = "openai_chat"
115
+ supports_streaming: bool = True
116
+ supports_tools: bool = True
117
+ supports_tool_choice: bool = True
118
+ supports_thinking: bool = False
119
+ preserves_anthropic_thinking: bool = False
120
+ requires_api_key: bool = False
121
+ local: bool = False
122
+ blocks_default_tools: bool = True
123
+ repairs_anthropic_tool_input: bool = False
124
+
125
+ @property
126
+ def supported_protocols(self) -> frozenset[MessageProtocol]:
127
+ return frozenset({self.upstream_protocol})
128
+
129
+
130
+ @dataclass(frozen=True)
131
+ class ProviderRequestPolicy:
132
+ """Provider-owned endpoint and transport defaults."""
133
+
134
+ chat_path: str
135
+ models_path: str
136
+ model_info_path: str | None = None
137
+ default_timeout_seconds: float = 60.0
138
+ model_alias_strategy: Literal["identity", "ncp"] = "identity"
139
+ credential_strategy: Literal["adapter", "anthropic_inbound"] = "adapter"
140
+ stream_required: bool = False
141
+ normalize_historical_tool_turns: bool = True
142
+ managed_service: Literal["none", "nvidia_proxy"] = "none"
143
+ probe_strategy: Literal["anthropic", "ollama", "opencode"] = "anthropic"
144
+
145
+
146
+ @dataclass(frozen=True)
147
+ class ProviderModelCatalogPolicy:
148
+ """Select a reusable model-catalog strategy without provider-name branching."""
149
+
150
+ kind: Literal["configured", "anthropic", "openai", "ollama", "lm_studio", "nvidia", "fireworks"] = "openai"
151
+ fallback_models: tuple[str, ...] = ()
152
+ allow_configured_fallback: bool = False
153
+ allow_public_without_auth: bool = False
154
+ use_bundled_catalog_fallback: bool = False
155
+
156
+
157
+ @dataclass(frozen=True)
158
+ class ProviderConfigurationPolicy:
159
+ """Provider-owned configuration mutation capabilities."""
160
+
161
+ mutation_strategy: Literal["common", "ollama"] = "common"
162
+ supports_route_through_router: bool = False
163
+ supports_model_endpoint_overrides: bool = False
164
+ native_compat_error: str | None = None
165
+ text_option_aliases: Mapping[str, str] = field(default_factory=dict)
166
+ strip_trailing_slash_fields: frozenset[str] = frozenset()
167
+ status_fields: tuple[str, ...] = ()
168
+ uses_ollama_status: bool = False
169
+ runtime_owns_model: bool = False
170
+ restricts_runtime_options: bool = False
171
+
172
+
173
+ @dataclass(frozen=True)
174
+ class ProviderStatusPolicy:
175
+ """Provider-owned base URL status projection strategy."""
176
+
177
+ kind: Literal["generic", "native_codex", "native_agy", "nvidia", "configured", "catalog"] = "generic"
178
+ label: str = ""
179
+ configured_description: str = ""
180
+ catalog_path: str = ""
181
+ catalog_count_key: Literal["data", "models"] = "data"
182
+ catalog_scope: Literal["configured", "fireworks_management"] = "configured"
183
+ catalog_count_label: str = "models"
184
+ unreachable_hint: str = "Set a reachable Base URL before launching Claude Code."
185
+ readiness_validation: Literal["none", "lm_studio"] = "none"
186
+
187
+
188
+ @dataclass(frozen=True)
189
+ class ProviderContextPolicy:
190
+ """Provider-owned context capacity and configuration strategy."""
191
+
192
+ capacity_strategy: Literal[
193
+ "managed", "nvidia", "remote_first", "hint_first", "configured_first", "ollama", "hint_configured", "anthropic_hint"
194
+ ] = "managed"
195
+ settings_strategy: Literal["managed", "ollama", "standard"] = "managed"
196
+ hosted_timeout: bool = False
197
+ timeout_weight: float = 1.0
198
+ uses_catalog_timeout: bool = False
199
+ managed_preset_inference: bool = False
200
+ context_family_before_size_markers: bool = False
201
+ preset_context_profile: Literal["default", "ollama", "nvidia"] = "default"
202
+ status_capacity_strategy: Literal[
203
+ "configured", "ollama_budget", "openai_budget", "provider"
204
+ ] = "configured"
205
+
206
+
207
+ @dataclass(frozen=True)
208
+ class ProviderOptionPresentationPolicy:
209
+ """Provider-owned option status capabilities."""
210
+
211
+ show_rate_limit: bool = False
212
+ show_native: bool = False
213
+ show_route: bool = False
214
+ show_tool_choice: bool = False
215
+ show_sampling: bool = False
216
+ show_stream: bool = False
217
+ show_ip_family: bool = False
218
+ show_rate_limit_controls: bool = False
219
+ show_sampling_controls: bool = False
220
+ show_ip_family_control: bool = False
221
+
222
+
223
+ @dataclass(frozen=True)
224
+ class ProviderUiPolicy:
225
+ """Provider-owned labels used by shared menus."""
226
+
227
+ menu_label: str = ""
228
+ routed_menu_label: str = ""
229
+ native_choice: str = ""
230
+ routed_choice: str = ""
231
+ model_placeholder: str = ""
232
+ advisor_placeholder: str = ""
233
+ uses_native_advisor: bool = False
234
+
235
+
103
236
  class RuntimeAdapter(ABC):
104
237
  """Adapter for a local/interactive coding runtime.
105
238
 
@@ -136,6 +269,22 @@ class ProviderAdapter(ABC):
136
269
  def default_base_url(self) -> str:
137
270
  """Return the provider default API base URL."""
138
271
 
272
+ def normalize_base_url(self, value: str) -> str:
273
+ """Normalize a user-supplied endpoint before it is persisted."""
274
+
275
+ return str(value or "").rstrip("/")
276
+
277
+ def default_configuration(self) -> Mapping[str, Any]:
278
+ """Return the minimal persisted configuration for a newly registered provider."""
279
+
280
+ return {
281
+ "base_url": self.default_base_url(),
282
+ "api_key": "",
283
+ "current_model": "",
284
+ "advisor_model": "",
285
+ "custom_models": [],
286
+ }
287
+
139
288
  @abstractmethod
140
289
  def list_models(self, config: ProviderConfig) -> Sequence[ModelInfo]:
141
290
  """Return known models for this provider."""
@@ -149,6 +298,343 @@ class ProviderAdapter(ABC):
149
298
 
150
299
  return None
151
300
 
301
+ def capabilities(self, config: ProviderConfig) -> ProviderCapabilities:
302
+ """Return routing capabilities for this configured provider."""
303
+
304
+ del config
305
+ return ProviderCapabilities()
306
+
307
+ def request_policy(self, config: ProviderConfig) -> ProviderRequestPolicy:
308
+ """Return endpoint and transport defaults for this provider."""
309
+
310
+ del config
311
+ return ProviderRequestPolicy(chat_path="/v1/chat/completions", models_path="/v1/models")
312
+
313
+ def resolve_endpoint(self, operation: str, config: ProviderConfig) -> str:
314
+ """Resolve a provider operation to a path without exposing provider conditionals."""
315
+
316
+ policy = self.request_policy(config)
317
+ paths = {
318
+ "chat": policy.chat_path,
319
+ "models": policy.models_path,
320
+ "model_info": policy.model_info_path,
321
+ "anthropic_messages": "/v1/messages",
322
+ "openai_chat": "/v1/chat/completions",
323
+ "openai_responses": "/v1/responses",
324
+ "ollama_chat": "/api/chat",
325
+ }
326
+ path = paths.get(operation)
327
+ if not path:
328
+ raise KeyError(f"{self.name} does not support provider operation: {operation}")
329
+ return path
330
+
331
+ def build_model_headers(self, config: ProviderConfig, api_key: str | None) -> Mapping[str, str]:
332
+ """Build headers for model discovery; providers may override request auth."""
333
+
334
+ return self.build_headers(config, api_key)
335
+
336
+ def model_paths(self, config: ProviderConfig) -> tuple[str, ...]:
337
+ """Return model discovery paths in provider-preferred fallback order."""
338
+
339
+ primary = self.request_policy(config).models_path
340
+ return (primary,) if primary == "/models" else (primary, "/models")
341
+
342
+ def supported_protocols(self, config: ProviderConfig, model: str | None = None) -> frozenset[MessageProtocol]:
343
+ """Return protocols this provider can use for the selected model."""
344
+
345
+ del model
346
+ return self.capabilities(config).supported_protocols
347
+
348
+ def select_protocol(
349
+ self,
350
+ operation: MessageProtocol,
351
+ config: ProviderConfig,
352
+ model: str | None = None,
353
+ ) -> MessageProtocol:
354
+ """Choose the upstream protocol for an inbound protocol operation."""
355
+
356
+ supported = self.supported_protocols(config, model)
357
+ if operation in supported:
358
+ return operation
359
+ return self.capabilities(config).upstream_protocol
360
+
361
+ def supports_tool_choice(self, config: ProviderConfig, model: str | None = None) -> bool:
362
+ configured = config.options.get("supports_tool_choice")
363
+ if configured is not None:
364
+ return bool(configured)
365
+ del model
366
+ return self.capabilities(config).supports_tool_choice
367
+
368
+ def normalizes_anthropic_tool_use(
369
+ self,
370
+ config: ProviderConfig,
371
+ ) -> bool:
372
+ configured = config.options.get("normalize_anthropic_tool_use")
373
+ if configured is not None:
374
+ return bool(configured)
375
+ capabilities = self.capabilities(config)
376
+ return (
377
+ capabilities.upstream_protocol != "anthropic_messages"
378
+ and not capabilities.preserves_anthropic_thinking
379
+ )
380
+
381
+ def preserves_anthropic_thinking(self, config: ProviderConfig) -> bool:
382
+ configured = config.options.get("preserve_anthropic_thinking")
383
+ if configured is not None:
384
+ return bool(configured)
385
+ return self.capabilities(config).preserves_anthropic_thinking
386
+
387
+ def openai_reasoning_passback_enabled(
388
+ self, config: ProviderConfig, model: str | None = None
389
+ ) -> bool:
390
+ """Whether OpenAI reasoning_content should survive history conversion."""
391
+
392
+ del config, model
393
+ return False
394
+
395
+ def model_catalog_policy(self, config: ProviderConfig) -> ProviderModelCatalogPolicy:
396
+ del config
397
+ return ProviderModelCatalogPolicy()
398
+
399
+ def project_model_metadata(self, raw: Mapping[str, Any]) -> Mapping[str, Any]:
400
+ """Project provider response fields into the shared model metadata shape."""
401
+
402
+ return {
403
+ key: raw[key]
404
+ for key in ("owned_by", "root", "object")
405
+ if raw.get(key) is not None
406
+ }
407
+
408
+ def project_router_model_metadata(
409
+ self, config: ProviderConfig, model_id: str
410
+ ) -> Mapping[str, Any]:
411
+ """Project provider-specific metadata exposed by the router model catalog."""
412
+
413
+ del config, model_id
414
+ return {}
415
+
416
+ def normalize_model_id(self, model_id: str) -> str:
417
+ """Normalize a configured/catalog model id for shared runtime use."""
418
+
419
+ text = str(model_id or "").strip()
420
+ return re.sub(r"\[(?:1m)\]\s*$", "", text, flags=re.IGNORECASE).strip()
421
+
422
+ def upstream_api_model_id(self, model_id: str) -> str:
423
+ """Return the provider's wire-level model id."""
424
+
425
+ return str(model_id or "").strip()
426
+
427
+ def preserves_claude_model_alias(self, model_id: str) -> bool:
428
+ """Whether an already Claude-facing model id must remain unchanged."""
429
+
430
+ del model_id
431
+ return False
432
+
433
+ def display_model_name(self, model_id: str, provider_label: str) -> str:
434
+ """Project a provider model id into the shared human-readable label."""
435
+
436
+ cleaned = str(model_id).replace("/", " ").replace("-", " ").replace("_", " ")
437
+ return (
438
+ f"{provider_label.replace('-', ' ')} {cleaned}"
439
+ .title()
440
+ .replace("Vllm", "vLLM")
441
+ .replace("Nvidia", "Nvidia")
442
+ )
443
+
444
+ def launch_model_strategy(self, config: ProviderConfig) -> str:
445
+ """Return the provider-owned launch alias strategy."""
446
+
447
+ del config
448
+ return "alias"
449
+
450
+ def requires_catalog_model_selection(self, config: ProviderConfig) -> bool:
451
+ """Whether placeholder model ids must be replaced from provider discovery."""
452
+
453
+ del config
454
+ return False
455
+
456
+ def placeholder_model_ids(self) -> frozenset[str]:
457
+ """Return non-routable placeholder model ids accepted in configuration."""
458
+
459
+ return frozenset({"", "model"})
460
+
461
+ def routing_mode_update(self, enabled: bool) -> tuple[str, ...]:
462
+ """Describe a persisted route-through-router mode change."""
463
+
464
+ state = "routed through ciel-runtime router" if enabled else "direct provider mode"
465
+ return ("Provider routing mode updated.", f"mode: {state}")
466
+
467
+ def selection_config_updates(self, config: ProviderConfig) -> Mapping[str, Any]:
468
+ """Return provider-owned defaults applied when this provider is selected."""
469
+
470
+ del config
471
+ return {}
472
+
473
+ def selection_update_status_lines(
474
+ self, config: ProviderConfig, updates: Mapping[str, Any]
475
+ ) -> tuple[str, ...]:
476
+ """Describe provider-owned normalization performed during selection."""
477
+
478
+ del config, updates
479
+ return ()
480
+
481
+ def model_selection_config_updates(
482
+ self, config: ProviderConfig, model_id: str
483
+ ) -> Mapping[str, Any]:
484
+ """Return provider-owned config updates for an explicit model choice."""
485
+
486
+ del config, model_id
487
+ return {}
488
+
489
+ def selection_status_lines(self, config: ProviderConfig) -> tuple[str, ...]:
490
+ """Return provider-owned status details after selection."""
491
+
492
+ del config
493
+ return ()
494
+
495
+ def configuration_policy(self, config: ProviderConfig) -> ProviderConfigurationPolicy:
496
+ """Return provider-owned option mutation behavior."""
497
+
498
+ del config
499
+ return ProviderConfigurationPolicy()
500
+
501
+ def status_policy(self, config: ProviderConfig) -> ProviderStatusPolicy:
502
+ """Return provider-owned base URL status behavior."""
503
+
504
+ request = self.request_policy(config)
505
+ count_key = "models" if request.models_path == "/api/tags" else "data"
506
+ return ProviderStatusPolicy(catalog_path=request.models_path, catalog_count_key=count_key)
507
+
508
+ def context_policy(self, config: ProviderConfig) -> ProviderContextPolicy:
509
+ """Return provider-owned context capacity and mutation behavior."""
510
+
511
+ del config
512
+ return ProviderContextPolicy()
513
+
514
+ def option_presentation_policy(self, config: ProviderConfig) -> ProviderOptionPresentationPolicy:
515
+ del config
516
+ return ProviderOptionPresentationPolicy()
517
+
518
+ def ui_policy(self, config: ProviderConfig) -> ProviderUiPolicy:
519
+ """Return provider-owned presentation and runtime compatibility metadata."""
520
+
521
+ del config
522
+ return ProviderUiPolicy(menu_label=self.name)
523
+
524
+ def shows_claude_workflow_options(self, config: ProviderConfig) -> bool:
525
+ del config
526
+ return True
527
+
528
+ def intercepts_advisor_shortcut(self, config: ProviderConfig) -> bool:
529
+ """Whether the router should handle /advisor instead of the native runtime."""
530
+
531
+ del config
532
+ return True
533
+
534
+ def option_timeout_default(self) -> str:
535
+ return "default"
536
+
537
+ def model_configuration_profile(
538
+ self, config: ProviderConfig
539
+ ) -> tuple[Mapping[str, Any], str | None]:
540
+ """Return provider/model-specific configuration updates and an optional notice."""
541
+
542
+ del config
543
+ return {}, None
544
+
545
+ def propagates_inbound_beta_query(self, config: ProviderConfig) -> bool:
546
+ """Whether an inbound Claude beta query should be forwarded upstream."""
547
+
548
+ del config
549
+ return False
550
+
551
+ def api_key_display_name(self) -> str:
552
+ """Return the provider-owned name used in API-key status text."""
553
+
554
+ return self.name
555
+
556
+ def api_key_status(self, config: ProviderConfig, *, key_count: int, primary_detail: str) -> str:
557
+ """Format API-key readiness without central provider-name branching."""
558
+
559
+ scope = self.api_key_display_name()
560
+ round_robin = f"{key_count} keys, round-robin"
561
+ if key_count > 1:
562
+ detail = f"{scope}{primary_detail}" if scope else primary_detail.lstrip("; ")
563
+ return f"API keys: {round_robin} ({detail})"
564
+ if key_count:
565
+ detail = f"{scope}{primary_detail}" if scope else primary_detail.lstrip("; ")
566
+ return f"API key: set ({detail})"
567
+ if self.capabilities(config).requires_api_key:
568
+ return f"API key: missing ({scope} required)"
569
+ if self.capabilities(config).local:
570
+ return f"API key: not required for {scope}"
571
+ return "API key: optional or not configured"
572
+
573
+ def launch_api_key_error(self, config: ProviderConfig) -> str | None:
574
+ """Return a launch blocker when this provider requires an absent key."""
575
+
576
+ if self.capabilities(config).requires_api_key and not config.api_keys:
577
+ return f"Launch blocked: {self.api_key_display_name()} requires an API key."
578
+ return None
579
+
580
+ def model_panel_badge(self, config: ProviderConfig, model: str) -> str:
581
+ """Return an optional provider-owned model label for selection UIs."""
582
+
583
+ del config, model
584
+ return ""
585
+
586
+ def supports_server_advisor_tool(self, config: ProviderConfig) -> bool:
587
+ """Whether the upstream executes Claude's server-side advisor tool."""
588
+
589
+ del config
590
+ return False
591
+
592
+ def context_compaction_available(self, config: ProviderConfig) -> bool:
593
+ """Whether this configured provider can run an auxiliary summary request."""
594
+
595
+ del config
596
+ return True
597
+
598
+ def router_native_anthropic_enabled(self, config: ProviderConfig, model: str | None = None) -> bool:
599
+ """Whether the generic router may send Anthropic Messages directly upstream."""
600
+
601
+ del config, model
602
+ return False
603
+
604
+ def advisor_panel_notice(self, config: ProviderConfig) -> tuple[tuple[str, ...], tuple[str, ...]] | None:
605
+ """Return a provider-owned advisor panel replacement, when applicable."""
606
+
607
+ del config
608
+ return None
609
+
610
+ def advisor_model_badge(self, config: ProviderConfig, model: str) -> str:
611
+ """Return an optional provider-owned advisor model annotation."""
612
+
613
+ del config, model
614
+ return ""
615
+
616
+ def normalize_request_options(self, config: ProviderConfig, request: Mapping[str, Any]) -> Mapping[str, Any]:
617
+ del config
618
+ return request
619
+
620
+ def openai_reasoning_effort(
621
+ self, config: ProviderConfig, model: str, request: Mapping[str, Any]
622
+ ) -> str | None:
623
+ """Return a provider-normalized Chat Completions reasoning effort."""
624
+
625
+ del config, model, request
626
+ return None
627
+
628
+ def allows_sampling_overrides(self, config: ProviderConfig) -> bool:
629
+ """Whether user-provided sampling controls are valid for this provider."""
630
+
631
+ del config
632
+ return True
633
+
634
+ def normalize_tool_choice(self, config: ProviderConfig, model: str, tool_choice: Any) -> Any:
635
+ del config, model
636
+ return tool_choice
637
+
152
638
 
153
639
  class MessageProtocolAdapter(ABC):
154
640
  """Adapter for upstream request/response wire formats."""
@@ -191,6 +677,7 @@ __all__ = [
191
677
  "ModelInfo",
192
678
  "ProviderAdapter",
193
679
  "ProviderConfig",
680
+ "ProviderUiPolicy",
194
681
  "RateLimitState",
195
682
  "RuntimeAdapter",
196
683
  "RuntimeCommand",