@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,103 @@
1
+ """Ownership-aware installer for Claude commands and Codex prompts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Callable
9
+
10
+
11
+ @dataclass(frozen=True, slots=True)
12
+ class CommandAsset:
13
+ content: str
14
+ ownership_markers: tuple[str, ...]
15
+
16
+
17
+ def is_owned_command_file(path: Path, markers: tuple[str, ...]) -> bool:
18
+ try:
19
+ text = path.read_text(encoding="utf-8", errors="replace")
20
+ except Exception:
21
+ return False
22
+ return any(marker in text for marker in markers)
23
+
24
+
25
+ @dataclass(frozen=True, slots=True)
26
+ class CommandAssetInstaller:
27
+ directory: Path
28
+ warn: Callable[[str], None]
29
+
30
+ def install_one(self, name: str, asset: CommandAsset) -> None:
31
+ try:
32
+ self.directory.mkdir(parents=True, exist_ok=True)
33
+ path = self.directory / name
34
+ if path.exists():
35
+ existing = path.read_text(encoding="utf-8", errors="replace")
36
+ if existing == asset.content:
37
+ return
38
+ if not is_owned_command_file(path, asset.ownership_markers):
39
+ return
40
+ path.write_text(asset.content, encoding="utf-8")
41
+ try:
42
+ os.chmod(path, 0o600)
43
+ except Exception:
44
+ pass
45
+ except Exception as error:
46
+ self.warn(f"could not install {name} ({type(error).__name__}: {error}).")
47
+
48
+ def remove_one(self, name: str, markers: tuple[str, ...]) -> None:
49
+ try:
50
+ path = self.directory / name
51
+ if path.exists() and is_owned_command_file(path, markers):
52
+ path.unlink()
53
+ except Exception as error:
54
+ self.warn(f"could not remove {name} ({type(error).__name__}: {error}).")
55
+
56
+ def install_all(
57
+ self,
58
+ assets: dict[str, CommandAsset],
59
+ stale_glob: str | None = None,
60
+ stale_markers: tuple[str, ...] = (),
61
+ ) -> None:
62
+ try:
63
+ self.directory.mkdir(parents=True, exist_ok=True)
64
+ if stale_glob:
65
+ for path in self.directory.glob(stale_glob):
66
+ if path.name not in assets and is_owned_command_file(path, stale_markers):
67
+ try:
68
+ path.unlink()
69
+ except Exception:
70
+ pass
71
+ stale_channel = self.directory / "channel.md"
72
+ if stale_channel.exists() and is_owned_command_file(
73
+ stale_channel,
74
+ ("CIEL_RUNTIME_CHANNEL_BRIDGE", "ciel-runtime channel bridge"),
75
+ ):
76
+ try:
77
+ stale_channel.unlink()
78
+ except Exception:
79
+ pass
80
+ for name, asset in assets.items():
81
+ self.install_one(name, asset)
82
+ except Exception as error:
83
+ self.warn(f"could not install command assets ({type(error).__name__}: {error}).")
84
+
85
+ def remove_all(
86
+ self,
87
+ assets: dict[str, CommandAsset],
88
+ stale_glob: str | None = None,
89
+ stale_markers: tuple[str, ...] = (),
90
+ ) -> None:
91
+ try:
92
+ if not self.directory.exists():
93
+ return
94
+ for name, asset in assets.items():
95
+ path = self.directory / name
96
+ if path.exists() and is_owned_command_file(path, asset.ownership_markers):
97
+ path.unlink()
98
+ if stale_glob:
99
+ for path in self.directory.glob(stale_glob):
100
+ if path.name not in assets and is_owned_command_file(path, stale_markers):
101
+ path.unlink()
102
+ except Exception as error:
103
+ self.warn(f"could not remove command assets ({type(error).__name__}: {error}).")
@@ -0,0 +1,295 @@
1
+ """Provider-aware API-key compatibility probe request builder."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any, Callable, Protocol
7
+ import urllib.error
8
+
9
+ from .architecture import ProviderRequestPolicy
10
+
11
+
12
+ ProviderConfig = dict[str, Any]
13
+ RequestBody = dict[str, Any]
14
+
15
+
16
+ class CompatibilityApiKeyProbeError(Exception):
17
+ def __init__(
18
+ self,
19
+ message: str,
20
+ code: int | None = None,
21
+ diagnosis: str = "",
22
+ ) -> None:
23
+ super().__init__(message)
24
+ self.code = code
25
+ self.diagnosis = diagnosis
26
+
27
+
28
+ class OllamaProbeRequest(Protocol):
29
+ def __call__(
30
+ self,
31
+ model: str,
32
+ body: RequestBody,
33
+ config: ProviderConfig,
34
+ *,
35
+ stream: bool,
36
+ provider: str,
37
+ ) -> RequestBody: ...
38
+
39
+
40
+ class OpenAiProbeRequest(Protocol):
41
+ def __call__(
42
+ self,
43
+ provider: str,
44
+ model: str,
45
+ body: RequestBody,
46
+ config: ProviderConfig,
47
+ *,
48
+ stream: bool,
49
+ ) -> RequestBody: ...
50
+
51
+
52
+ class ProbePost(Protocol):
53
+ def __call__(
54
+ self,
55
+ url: str,
56
+ body: RequestBody,
57
+ *,
58
+ headers: dict[str, str],
59
+ timeout: float,
60
+ provider: str,
61
+ pcfg: ProviderConfig,
62
+ ) -> Any: ...
63
+
64
+
65
+ @dataclass(frozen=True, slots=True)
66
+ class CompatibilityProbeProjectionPorts:
67
+ normalize_thinking: Callable[
68
+ [str, ProviderConfig, RequestBody],
69
+ RequestBody,
70
+ ]
71
+ normalize_tool_choice: Callable[
72
+ [str, ProviderConfig, RequestBody],
73
+ RequestBody,
74
+ ]
75
+ resolve_model: Callable[[str, ProviderConfig, str], str]
76
+ headers: Callable[[str, ProviderConfig], dict[str, str]]
77
+ request_policy: Callable[
78
+ [str, ProviderConfig],
79
+ ProviderRequestPolicy,
80
+ ]
81
+
82
+
83
+ @dataclass(frozen=True, slots=True)
84
+ class CompatibilityProbeRoutingPorts:
85
+ ollama_request: OllamaProbeRequest
86
+ openai_request: OpenAiProbeRequest
87
+ endpoint: Callable[[str, ProviderConfig, str], str]
88
+ opencode_endpoint_kind: Callable[[str, str, ProviderConfig], str]
89
+ openai_router_enabled: Callable[[str, ProviderConfig], bool]
90
+ request_base: Callable[[str, ProviderConfig], str]
91
+ join_url: Callable[[str, str], str]
92
+ ncp_model_id: Callable[[str], str]
93
+
94
+
95
+ @dataclass(frozen=True, slots=True)
96
+ class CompatibilityProbeAnthropicPorts:
97
+ cap_body: Callable[[str, ProviderConfig, RequestBody], RequestBody]
98
+ apply_options: Callable[[str, ProviderConfig, RequestBody], RequestBody]
99
+ resolve_tool_models: Callable[
100
+ [str, ProviderConfig, RequestBody],
101
+ RequestBody,
102
+ ]
103
+ native_compat_enabled: Callable[[str, ProviderConfig], bool]
104
+ native_base_url: Callable[[str, ProviderConfig], str]
105
+
106
+
107
+ @dataclass(frozen=True, slots=True)
108
+ class CompatibilityApiKeyProbeBuilder:
109
+ projection: CompatibilityProbeProjectionPorts
110
+ routing: CompatibilityProbeRoutingPorts
111
+ anthropic: CompatibilityProbeAnthropicPorts
112
+
113
+ def build(
114
+ self,
115
+ provider: str,
116
+ config: ProviderConfig,
117
+ model: str,
118
+ request_body: RequestBody,
119
+ ) -> tuple[str, RequestBody, dict[str, str]]:
120
+ body = self.projection.normalize_thinking(
121
+ provider,
122
+ config,
123
+ request_body,
124
+ )
125
+ body = self.projection.normalize_tool_choice(provider, config, body)
126
+ upstream_model = self.projection.resolve_model(provider, config, model)
127
+ headers = self.projection.headers(provider, config)
128
+ policy = self.projection.request_policy(provider, config)
129
+
130
+ if policy.probe_strategy == "ollama":
131
+ probe_body = self.routing.ollama_request(
132
+ upstream_model,
133
+ body,
134
+ config,
135
+ stream=False,
136
+ provider=provider,
137
+ )
138
+ return (
139
+ self.routing.endpoint(provider, config, "ollama_chat"),
140
+ probe_body,
141
+ headers,
142
+ )
143
+
144
+ if policy.probe_strategy == "opencode":
145
+ endpoint_kind = self.routing.opencode_endpoint_kind(
146
+ provider,
147
+ upstream_model,
148
+ config,
149
+ )
150
+ if endpoint_kind == "openai-chat":
151
+ probe_body = self.routing.openai_request(
152
+ provider,
153
+ upstream_model,
154
+ body,
155
+ config,
156
+ stream=False,
157
+ )
158
+ url = self.routing.join_url(
159
+ self.routing.request_base(provider, config),
160
+ "/v1/chat/completions",
161
+ )
162
+ return url, probe_body, headers
163
+ if endpoint_kind != "anthropic-messages":
164
+ raise CompatibilityApiKeyProbeError(
165
+ f"model {upstream_model!r} uses unsupported endpoint family "
166
+ f"{endpoint_kind!r} for API-key probing"
167
+ )
168
+
169
+ if self.routing.openai_router_enabled(provider, config):
170
+ if policy.model_alias_strategy == "ncp":
171
+ upstream_model = self.routing.ncp_model_id(upstream_model)
172
+ probe_body = self.routing.openai_request(
173
+ provider,
174
+ upstream_model,
175
+ body,
176
+ config,
177
+ stream=False,
178
+ )
179
+ return (
180
+ self.routing.endpoint(provider, config, "openai_chat"),
181
+ probe_body,
182
+ headers,
183
+ )
184
+
185
+ body = self.anthropic.cap_body(provider, config, body)
186
+ body = self.anthropic.apply_options(provider, config, body)
187
+ body = dict(body)
188
+ body["model"] = upstream_model
189
+ body = self.anthropic.resolve_tool_models(provider, config, body)
190
+ if self.anthropic.native_compat_enabled(provider, config):
191
+ base = self.anthropic.native_base_url(provider, config)
192
+ else:
193
+ base = self.routing.request_base(provider, config)
194
+ return self.routing.join_url(base, "/v1/messages"), body, headers
195
+
196
+
197
+ @dataclass(frozen=True, slots=True)
198
+ class CompatibilityApiKeyProbeRunnerPorts:
199
+ api_keys: Callable[[str, ProviderConfig], list[str]]
200
+ mask_secret: Callable[[str], str]
201
+ build_request: Callable[
202
+ [str, ProviderConfig, str, RequestBody],
203
+ tuple[str, RequestBody, dict[str, str]],
204
+ ]
205
+ post: ProbePost
206
+ http_error_message: Callable[[urllib.error.HTTPError], str]
207
+ failure_diagnosis: Callable[[str, int | None, str], str | None]
208
+
209
+
210
+ @dataclass(frozen=True, slots=True)
211
+ class CompatibilityApiKeyProbeRunner:
212
+ ports: CompatibilityApiKeyProbeRunnerPorts
213
+
214
+ @staticmethod
215
+ def single_key_config(
216
+ config: ProviderConfig,
217
+ key: str,
218
+ ) -> ProviderConfig:
219
+ keyed = dict(config)
220
+ keyed["api_key"] = key
221
+ keyed["api_keys"] = []
222
+ return keyed
223
+
224
+ def run(
225
+ self,
226
+ provider: str,
227
+ config: ProviderConfig,
228
+ model: str,
229
+ request_body: RequestBody,
230
+ timeout: float,
231
+ ) -> list[str]:
232
+ keys = self.ports.api_keys(provider, config)
233
+ if len(keys) <= 1:
234
+ return []
235
+ lines = [f"API key checks: running {len(keys)} configured keys"]
236
+ for index, key in enumerate(keys, start=1):
237
+ label = (
238
+ f"API key {index}/{len(keys)} "
239
+ f"({self.ports.mask_secret(key)})"
240
+ )
241
+ keyed_config = self.single_key_config(config, key)
242
+ try:
243
+ url, body, headers = self.ports.build_request(
244
+ provider,
245
+ keyed_config,
246
+ model,
247
+ request_body,
248
+ )
249
+ self.ports.post(
250
+ url,
251
+ body,
252
+ headers=headers,
253
+ timeout=timeout,
254
+ provider=provider,
255
+ pcfg=keyed_config,
256
+ )
257
+ except CompatibilityApiKeyProbeError:
258
+ raise
259
+ except urllib.error.HTTPError as exc:
260
+ message = self.ports.http_error_message(exc)
261
+ diagnosis = (
262
+ self.ports.failure_diagnosis(
263
+ provider,
264
+ exc.code,
265
+ message,
266
+ )
267
+ or ""
268
+ )
269
+ raise CompatibilityApiKeyProbeError(
270
+ f"{label}: {message}",
271
+ exc.code,
272
+ diagnosis,
273
+ ) from exc
274
+ except TimeoutError as exc:
275
+ raise CompatibilityApiKeyProbeError(
276
+ f"{label}: timed out before the {timeout:g}s "
277
+ "API-key probe timeout"
278
+ ) from exc
279
+ except Exception as exc:
280
+ raise CompatibilityApiKeyProbeError(
281
+ f"{label}: {type(exc).__name__}: {exc}"
282
+ ) from exc
283
+ lines.append(f"{label}: OK")
284
+ return lines
285
+
286
+
287
+ __all__ = [
288
+ "CompatibilityApiKeyProbeBuilder",
289
+ "CompatibilityApiKeyProbeError",
290
+ "CompatibilityApiKeyProbeRunner",
291
+ "CompatibilityApiKeyProbeRunnerPorts",
292
+ "CompatibilityProbeAnthropicPorts",
293
+ "CompatibilityProbeProjectionPorts",
294
+ "CompatibilityProbeRoutingPorts",
295
+ ]
@@ -0,0 +1,251 @@
1
+ """Protocol codec for provider compatibility probes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from collections.abc import Callable
8
+ from dataclasses import dataclass
9
+ from typing import Any
10
+ import urllib.error
11
+
12
+
13
+ @dataclass(frozen=True, slots=True)
14
+ class CompatibilityProtocolPorts:
15
+ max_tokens_for_model: Callable[[str], int]
16
+ first_header: Callable[[Any, list[str]], str | None]
17
+ parse_retry_after: Callable[[str], float | None]
18
+ format_duration: Callable[[float], str]
19
+
20
+
21
+ class CompatibilityProtocolCodec:
22
+ def __init__(self, tool_name: str, ports: CompatibilityProtocolPorts) -> None:
23
+ self.tool_name = tool_name
24
+ self.ports = ports
25
+
26
+ def tool_schema(self) -> dict[str, Any]:
27
+ return {
28
+ "name": self.tool_name,
29
+ "description": "A minimal compatibility test tool. It echoes one required text argument.",
30
+ "input_schema": {
31
+ "type": "object",
32
+ "properties": {"text": {"type": "string"}},
33
+ "required": ["text"],
34
+ "additionalProperties": False,
35
+ },
36
+ }
37
+
38
+ def text_request(self, model: str) -> dict[str, Any]:
39
+ return {
40
+ "model": model,
41
+ "max_tokens": self.ports.max_tokens_for_model(model),
42
+ "stream": False,
43
+ "messages": [
44
+ {
45
+ "role": "user",
46
+ "content": "Compatibility text test. Reply with exactly OK and do not call tools.",
47
+ }
48
+ ],
49
+ }
50
+
51
+ def tool_request(self, model: str) -> dict[str, Any]:
52
+ return {
53
+ "model": model,
54
+ "max_tokens": 128,
55
+ "stream": False,
56
+ "messages": [
57
+ {
58
+ "role": "user",
59
+ "content": "Compatibility tool test. Use the compat_echo tool exactly once with text set to ping.",
60
+ }
61
+ ],
62
+ "tools": [self.tool_schema()],
63
+ "tool_choice": {"type": "tool", "name": self.tool_name},
64
+ }
65
+
66
+ def tool_result_request(
67
+ self,
68
+ model: str,
69
+ tool_use: dict[str, Any],
70
+ ) -> dict[str, Any]:
71
+ tool_id = str(tool_use.get("id") or "toolu_compat_echo_1")
72
+ tool_input = (
73
+ tool_use.get("input")
74
+ if isinstance(tool_use.get("input"), dict)
75
+ else {"text": "ping"}
76
+ )
77
+ return {
78
+ "model": model,
79
+ "max_tokens": 64,
80
+ "stream": False,
81
+ "messages": [
82
+ {
83
+ "role": "user",
84
+ "content": "Compatibility tool test. Use the compat_echo tool exactly once with text set to ping.",
85
+ },
86
+ {
87
+ "role": "assistant",
88
+ "content": [
89
+ {
90
+ "type": "tool_use",
91
+ "id": tool_id,
92
+ "name": self.tool_name,
93
+ "input": tool_input,
94
+ }
95
+ ],
96
+ },
97
+ {
98
+ "role": "user",
99
+ "content": [
100
+ {
101
+ "type": "tool_result",
102
+ "tool_use_id": tool_id,
103
+ "content": "pong",
104
+ },
105
+ {
106
+ "type": "text",
107
+ "text": "Now reply with FINAL_OK and do not call tools.",
108
+ },
109
+ ],
110
+ },
111
+ ],
112
+ "tools": [self.tool_schema()],
113
+ }
114
+
115
+ @staticmethod
116
+ def content_blocks(data: Any) -> list[dict[str, Any]]:
117
+ if not isinstance(data, dict):
118
+ return []
119
+ content = data.get("content")
120
+ if not isinstance(content, list):
121
+ return []
122
+ return [block for block in content if isinstance(block, dict)]
123
+
124
+ def content_types(self, data: Any) -> list[str]:
125
+ return [str(block.get("type", "?")) for block in self.content_blocks(data)]
126
+
127
+ def text_preview(self, data: Any) -> str:
128
+ parts = [
129
+ block["text"].strip()
130
+ for block in self.content_blocks(data)
131
+ if block.get("type") == "text" and isinstance(block.get("text"), str)
132
+ ]
133
+ return " ".join(parts).strip()[:300]
134
+
135
+ def find_tool_use(self, data: Any) -> tuple[dict[str, Any] | None, str]:
136
+ for block in self.content_blocks(data):
137
+ if block.get("type") != "tool_use":
138
+ continue
139
+ if block.get("name") != self.tool_name:
140
+ return None, f"unexpected tool name {block.get('name')!r}"
141
+ tool_input = block.get("input")
142
+ if not isinstance(tool_input, dict):
143
+ return None, "tool input was not a JSON object"
144
+ if tool_input.get("text") != "ping":
145
+ return None, (
146
+ f"tool input text was {tool_input.get('text')!r}, expected 'ping'"
147
+ )
148
+ if not block.get("id"):
149
+ return None, "tool_use block did not include an id"
150
+ return block, ""
151
+ types = ", ".join(self.content_types(data)) or "none"
152
+ preview = self.text_preview(data)
153
+ suffix = f"; text={preview!r}" if preview else ""
154
+ return None, (
155
+ f"no {self.tool_name} tool_use block returned; "
156
+ f"content blocks: {types}{suffix}"
157
+ )
158
+
159
+ def summarize_response(self, data: Any, label: str) -> list[str]:
160
+ lines = [f"{label}: OK"]
161
+ if not isinstance(data, dict):
162
+ return lines
163
+ stop = data.get("stop_reason")
164
+ if stop:
165
+ lines.append(f"Stop reason: {stop}")
166
+ types = self.content_types(data)
167
+ if types:
168
+ lines.append("Content blocks: " + ", ".join(types[:6]))
169
+ usage = data.get("usage")
170
+ if isinstance(usage, dict):
171
+ tokens = []
172
+ if "input_tokens" in usage:
173
+ tokens.append(f"in={usage['input_tokens']}")
174
+ if "output_tokens" in usage:
175
+ tokens.append(f"out={usage['output_tokens']}")
176
+ if tokens:
177
+ lines.append("Tokens: " + ", ".join(tokens))
178
+ return lines
179
+
180
+ def http_error_message(self, exc: urllib.error.HTTPError) -> str:
181
+ raw = exc.read().decode("utf-8", errors="ignore")
182
+ message = raw.strip()
183
+ error_type = ""
184
+ try:
185
+ error = json.loads(raw)
186
+ if isinstance(error, dict):
187
+ if isinstance(error.get("error"), dict):
188
+ error_object = error["error"]
189
+ error_type = str(error_object.get("type") or "").strip()
190
+ message = str(
191
+ error_object.get("message")
192
+ or json.dumps(error_object, ensure_ascii=False)
193
+ )
194
+ elif error.get("message"):
195
+ message = str(error["message"])
196
+ error_type = str(error.get("type") or "").strip()
197
+ except Exception:
198
+ pass
199
+ if error_type and error_type not in message:
200
+ message = f"{error_type}: {message}"
201
+ retry_after = self.ports.first_header(
202
+ exc.headers,
203
+ ["Retry-After", "retry-after"],
204
+ )
205
+ if not retry_after:
206
+ return message
207
+ retry_text = retry_after.strip()
208
+ retry_seconds = self.ports.parse_retry_after(retry_text)
209
+ if retry_seconds is None:
210
+ return f"{message} Retry-After: {retry_text}"
211
+ display = self.ports.format_duration(retry_seconds)
212
+ if not retry_text:
213
+ return f"{message} Retry-After: {display}"
214
+ raw_suffix = f"{retry_text}s" if re.fullmatch(r"\d+(?:\.\d+)?", retry_text) else retry_text
215
+ return f"{message} Retry-After: {display} ({raw_suffix})"
216
+
217
+
218
+ @dataclass(frozen=True, slots=True)
219
+ class CompatibilityProtocolApi:
220
+ """Explicit compatibility facade backed by a late-bound protocol codec."""
221
+
222
+ codec_factory: Callable[[], CompatibilityProtocolCodec]
223
+
224
+ def tool_schema(self) -> dict[str, Any]:
225
+ return self.codec_factory().tool_schema()
226
+
227
+ def text_request(self, model: str) -> dict[str, Any]:
228
+ return self.codec_factory().text_request(model)
229
+
230
+ def tool_request(self, model: str) -> dict[str, Any]:
231
+ return self.codec_factory().tool_request(model)
232
+
233
+ def tool_result_request(
234
+ self, model: str, tool_use: dict[str, Any]
235
+ ) -> dict[str, Any]:
236
+ return self.codec_factory().tool_result_request(model, tool_use)
237
+
238
+ def content_types(self, data: Any) -> list[str]:
239
+ return self.codec_factory().content_types(data)
240
+
241
+ def text_preview(self, data: Any) -> str:
242
+ return self.codec_factory().text_preview(data)
243
+
244
+ def find_tool_use(self, data: Any) -> tuple[dict[str, Any] | None, str]:
245
+ return self.codec_factory().find_tool_use(data)
246
+
247
+ def summarize_response(self, data: Any, label: str) -> list[str]:
248
+ return self.codec_factory().summarize_response(data, label)
249
+
250
+ def http_error_message(self, exc: urllib.error.HTTPError) -> str:
251
+ return self.codec_factory().http_error_message(exc)