@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,104 @@
1
+ """Terminal controller for persisted provider credentials."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+ from typing import Any, Callable
6
+
7
+
8
+ @dataclass(frozen=True, slots=True)
9
+ class CredentialCliPolicy:
10
+ required_providers: frozenset[str]
11
+
12
+
13
+ @dataclass(frozen=True, slots=True)
14
+ class CredentialCliPorts:
15
+ normalize_provider: Callable[[str], str]
16
+ load_config: Callable[[], dict[str, Any]]
17
+ key_count: Callable[[str, dict[str, Any]], int]
18
+ primary_key: Callable[[str, dict[str, Any]], str]
19
+ mask: Callable[[str | None], str]
20
+ fingerprint: Callable[[str | None], str]
21
+ clear_requested: Callable[[Any], bool]
22
+ clear: Callable[[str], list[str]]
23
+ store_input: Callable[[str, str], list[str]]
24
+ store_many: Callable[[str, list[str]], list[str]]
25
+
26
+
27
+ @dataclass(frozen=True, slots=True)
28
+ class CredentialCliIO:
29
+ stdin_isatty: Callable[[], bool]
30
+ prompt_secret: Callable[[str], str]
31
+ write_line: Callable[[str], None]
32
+
33
+
34
+ @dataclass(frozen=True, slots=True)
35
+ class CredentialCliController:
36
+ policy: CredentialCliPolicy
37
+ ports: CredentialCliPorts
38
+ io: CredentialCliIO
39
+
40
+ def set_one(self, args: Any) -> None:
41
+ provider = self.ports.normalize_provider(args.provider)
42
+ key = args.key.strip()
43
+ if not key:
44
+ raise SystemExit("No key provided; unchanged.")
45
+ self._write(self.ports.store_input(provider, key))
46
+
47
+ def set_many(self, args: Any) -> None:
48
+ provider = self.ports.normalize_provider(args.provider)
49
+ raw = "\n".join(str(item) for item in getattr(args, "keys", []) if str(item).strip())
50
+ keys = self._parse_cli_keys(raw)
51
+ if not keys:
52
+ raise SystemExit("No API keys provided; unchanged.")
53
+ self._write(self.ports.store_many(provider, keys))
54
+
55
+ def manage(self, args: Any) -> None:
56
+ if not args.provider:
57
+ self._write_status()
58
+ return
59
+ provider = self.ports.normalize_provider(args.provider)
60
+ action = str(getattr(args, "action", "") or "").strip()
61
+ if self.ports.clear_requested(action):
62
+ self._write(self.ports.clear(provider))
63
+ return
64
+ if not self.io.stdin_isatty():
65
+ self.io.write_line("For security, do not paste API keys into Claude Code chat.")
66
+ self.io.write_line(f"Run this in the SSH terminal instead: ciel-runtimectl api-key {provider}")
67
+ return
68
+ key = self.io.prompt_secret(f"API key for {provider}: ").strip()
69
+ if not key:
70
+ raise SystemExit("No key entered; unchanged.")
71
+ self._write(self.ports.store_input(provider, key))
72
+
73
+ def _write_status(self) -> None:
74
+ config = self.ports.load_config()
75
+ self.io.write_line("API key status:")
76
+ for provider, provider_config in config["providers"].items():
77
+ count = self.ports.key_count(provider, provider_config)
78
+ if count > 1:
79
+ label = f"{count} keys (round-robin)"
80
+ elif count == 1:
81
+ label = "set"
82
+ else:
83
+ label = "missing" if provider in self.policy.required_providers else "not required"
84
+ primary = self.ports.primary_key(provider, provider_config)
85
+ suffix = (
86
+ f" (primary {self.ports.mask(primary)}; fp {self.ports.fingerprint(primary)})"
87
+ if count
88
+ else ""
89
+ )
90
+ self.io.write_line(f" {provider:<15} {label}{suffix}")
91
+ self.io.write_line("\nSet securely from terminal: ciel-runtimectl api-key anthropic")
92
+ self.io.write_line("Set multiple keys: ciel-runtimectl set-api-keys deepseek KEY1,KEY2")
93
+ self.io.write_line("For NVIDIA hosted, use: ciel-runtimectl api-key nvidia-hosted")
94
+
95
+ def _parse_cli_keys(self, raw: str) -> list[str]:
96
+ # store_many owns canonical parsing; preserve separate CLI empty-input validation.
97
+ return [item.strip() for item in raw.splitlines() if item.strip()]
98
+
99
+ def _write(self, lines: list[str]) -> None:
100
+ for line in lines:
101
+ self.io.write_line(line)
102
+
103
+
104
+ __all__ = ["CredentialCliController", "CredentialCliIO", "CredentialCliPolicy", "CredentialCliPorts"]
@@ -0,0 +1,261 @@
1
+ """Credential persistence transactions for provider API keys."""
2
+ from __future__ import annotations
3
+
4
+ from copy import deepcopy
5
+ from dataclasses import dataclass
6
+ import os
7
+ from pathlib import Path
8
+ from threading import Lock, RLock
9
+ from typing import Any, Callable
10
+
11
+
12
+ def parse_dotenv_file(path: Path) -> dict[str, str]:
13
+ """Read the simple KEY=VALUE format used by runtime-owned env files."""
14
+
15
+ environment: dict[str, str] = {}
16
+ if not path.exists():
17
+ return environment
18
+ for line in path.read_text(errors="ignore").splitlines():
19
+ line = line.strip()
20
+ if not line or line.startswith("#") or "=" not in line:
21
+ continue
22
+ key, value = line.split("=", 1)
23
+ environment[key.strip()] = value.strip().strip("'\"")
24
+ return environment
25
+
26
+
27
+ @dataclass(frozen=True, slots=True)
28
+ class CredentialPersistencePorts:
29
+ load_config: Callable[[], dict[str, Any]]
30
+ save_config: Callable[[dict[str, Any]], None]
31
+ clear_model_cache: Callable[[], None]
32
+ parse_keys: Callable[[Any], list[str]]
33
+ clear_requested: Callable[[Any], bool]
34
+ rotation_name: Callable[[str, dict[str, Any]], str]
35
+ error_text: Callable[[Any], bool] = lambda _value: False
36
+
37
+
38
+ @dataclass(frozen=True, slots=True)
39
+ class ExternalCredentialPorts:
40
+ enabled: Callable[[str], bool]
41
+ store: Callable[[str], None]
42
+ clear: Callable[[], None]
43
+ has_key: Callable[[], bool]
44
+ normalize_provider_config: Callable[[dict[str, Any]], bool]
45
+ location: Any
46
+
47
+
48
+ @dataclass(frozen=True, slots=True)
49
+ class CredentialPresentationPorts:
50
+ mask: Callable[[str | None], str]
51
+ fingerprint: Callable[[str | None], str]
52
+
53
+
54
+ class CredentialRotationRepository:
55
+ def __init__(self, cursor: dict[str, int], lock: Lock | RLock) -> None:
56
+ self._cursor = cursor
57
+ self._lock = lock
58
+
59
+ def reset(self, name: str) -> None:
60
+ with self._lock:
61
+ self._cursor.pop(name, None)
62
+
63
+
64
+ @dataclass(frozen=True, slots=True)
65
+ class EnvCredentialRepository:
66
+ """Persist one credential in a chmod-protected dotenv file."""
67
+
68
+ path: Path
69
+ key_name: str
70
+ defaults: dict[str, str]
71
+ read_env_file: Callable[[Path], dict[str, str]]
72
+ parse_values: Callable[[Any], list[str]]
73
+
74
+ def store(self, value: str) -> None:
75
+ environment = self.read_env_file(self.path)
76
+ environment[self.key_name] = value
77
+ for name, default in self.defaults.items():
78
+ environment.setdefault(name, default)
79
+ self._write(environment)
80
+
81
+ def clear(self) -> None:
82
+ environment = self.read_env_file(self.path)
83
+ if self.key_name not in environment:
84
+ return
85
+ environment.pop(self.key_name, None)
86
+ self._write(environment)
87
+
88
+ def has_key(self) -> bool:
89
+ return bool(self.parse_values(self.read_env_file(self.path).get(self.key_name)))
90
+
91
+ def _write(self, environment: dict[str, str]) -> None:
92
+ self.path.parent.mkdir(parents=True, exist_ok=True)
93
+ self.path.write_text("".join(f"{key}={value}\n" for key, value in environment.items()))
94
+ os.chmod(self.path, 0o600)
95
+
96
+
97
+ def nvidia_env_credential_repository(
98
+ path: Path,
99
+ read_env_file: Callable[[Path], dict[str, str]],
100
+ parse_values: Callable[[Any], list[str]],
101
+ base_url: str,
102
+ ) -> EnvCredentialRepository:
103
+ return EnvCredentialRepository(
104
+ path=path,
105
+ key_name="NVIDIA_API_KEY",
106
+ defaults={
107
+ "NVIDIA_BASE_URL": base_url,
108
+ "PROXY_HOST": "127.0.0.1",
109
+ "PROXY_PORT": "8788",
110
+ "STORAGE_ENGINE": "sqlite",
111
+ },
112
+ read_env_file=read_env_file,
113
+ parse_values=parse_values,
114
+ )
115
+
116
+
117
+ @dataclass(frozen=True, slots=True)
118
+ class CredentialManagementService:
119
+ persistence: CredentialPersistencePorts
120
+ external: ExternalCredentialPorts
121
+ presentation: CredentialPresentationPorts
122
+ rotation: CredentialRotationRepository
123
+ config_location: Any
124
+
125
+ def store_one(self, provider: str, key: str) -> list[str]:
126
+ if self.persistence.clear_requested(key):
127
+ return self.clear(provider)
128
+ config = self.persistence.load_config()
129
+ provider_config = config["providers"][provider]
130
+ if self.external.enabled(provider):
131
+ self.external.store(key)
132
+ provider_config.pop("api_keys", None)
133
+ if self.external.normalize_provider_config(provider_config):
134
+ self.persistence.save_config(config)
135
+ location = self.external.location
136
+ else:
137
+ provider_config["api_key"] = key
138
+ provider_config.pop("api_keys", None)
139
+ self.persistence.save_config(config)
140
+ location = self.config_location
141
+ self.persistence.clear_model_cache()
142
+ return [
143
+ f"Stored API key for {provider}.",
144
+ f"Saved: {self.presentation.mask(key)}; "
145
+ f"fp {self.presentation.fingerprint(key)} in {location}",
146
+ ]
147
+
148
+ def clear(self, provider: str) -> list[str]:
149
+ config = self.persistence.load_config()
150
+ providers = config["providers"]
151
+ other_key_fields = self._snapshot_other_provider_keys(providers, provider)
152
+ provider_config = providers[provider]
153
+ had_key = bool(
154
+ self.persistence.parse_keys(provider_config.get("api_key"))
155
+ or self.persistence.parse_keys(provider_config.get("api_keys"))
156
+ )
157
+ provider_config.pop("api_key", None)
158
+ provider_config.pop("api_keys", None)
159
+ if self.external.enabled(provider):
160
+ had_key = had_key or self.external.has_key()
161
+ self.external.clear()
162
+ self.external.normalize_provider_config(provider_config)
163
+ self._restore_other_provider_keys(providers, other_key_fields)
164
+ self.persistence.save_config(config)
165
+ self.persistence.clear_model_cache()
166
+ self.rotation.reset(self.persistence.rotation_name(provider, provider_config))
167
+ if had_key:
168
+ return [f"Cleared stored API key(s) for {provider}. Other providers unchanged."]
169
+ return [f"No stored API key(s) for {provider}; other providers unchanged."]
170
+
171
+ def store_many(self, provider: str, keys: list[str]) -> list[str]:
172
+ parsed = self.persistence.parse_keys(keys)
173
+ if len(parsed) == 1 and self.persistence.clear_requested(parsed[0]):
174
+ return self.clear(provider)
175
+ if not parsed:
176
+ raise SystemExit("No API keys provided; unchanged.")
177
+ config = self.persistence.load_config()
178
+ provider_config = config["providers"][provider]
179
+ provider_config["api_key"] = parsed[0]
180
+ if len(parsed) > 1:
181
+ provider_config["api_keys"] = parsed
182
+ else:
183
+ provider_config.pop("api_keys", None)
184
+ if self.external.enabled(provider):
185
+ self.external.store(parsed[0])
186
+ self.external.normalize_provider_config(provider_config)
187
+ self.persistence.save_config(config)
188
+ self.persistence.clear_model_cache()
189
+ self.rotation.reset(self.persistence.rotation_name(provider, provider_config))
190
+ return [
191
+ f"Stored {len(parsed)} API key{'s' if len(parsed) != 1 else ''} for {provider}.",
192
+ f"Round-robin: {'enabled' if len(parsed) > 1 else 'disabled'}",
193
+ f"Primary: {self.presentation.mask(parsed[0])}; "
194
+ f"fp {self.presentation.fingerprint(parsed[0])}",
195
+ ]
196
+
197
+ def store_input(self, provider: str, raw_value: str) -> list[str]:
198
+ if self.persistence.clear_requested(raw_value):
199
+ return self.clear(provider)
200
+ keys = self.persistence.parse_keys(raw_value)
201
+ # Refuse to persist a "key" that is actually an upstream error string
202
+ # (operator 2026-07-29: the ollama-cloud api_key held the literal text
203
+ # of a 504 URLError; every chat call then failed 401 while the
204
+ # unauthenticated /api/tags kept working, masking the corruption).
205
+ rejected = [key for key in keys if self.persistence.error_text(key)]
206
+ if rejected:
207
+ raise SystemExit(
208
+ "Refusing to store an API key that looks like an error message "
209
+ f"({len(rejected)} item(s) contain error prose). Paste the key "
210
+ "itself, not the error output. Unchanged."
211
+ )
212
+ if len(keys) > 1:
213
+ return self.store_many(provider, keys)
214
+ if len(keys) == 1:
215
+ return self.store_one(provider, keys[0])
216
+ raise SystemExit("No API key provided; unchanged.")
217
+
218
+ @staticmethod
219
+ def _snapshot_other_provider_keys(
220
+ providers: dict[str, Any], target_provider: str
221
+ ) -> dict[str, tuple[bool, Any, bool, Any]]:
222
+ snapshots: dict[str, tuple[bool, Any, bool, Any]] = {}
223
+ for name, provider_config in providers.items():
224
+ if name == target_provider or not isinstance(provider_config, dict):
225
+ continue
226
+ snapshots[name] = (
227
+ "api_key" in provider_config,
228
+ deepcopy(provider_config.get("api_key")),
229
+ "api_keys" in provider_config,
230
+ deepcopy(provider_config.get("api_keys")),
231
+ )
232
+ return snapshots
233
+
234
+ @staticmethod
235
+ def _restore_other_provider_keys(
236
+ providers: dict[str, Any], snapshots: dict[str, tuple[bool, Any, bool, Any]]
237
+ ) -> None:
238
+ for name, (has_key, key, has_keys, keys) in snapshots.items():
239
+ provider_config = providers.get(name)
240
+ if not isinstance(provider_config, dict):
241
+ continue
242
+ if has_key:
243
+ provider_config["api_key"] = key
244
+ else:
245
+ provider_config.pop("api_key", None)
246
+ if has_keys:
247
+ provider_config["api_keys"] = keys
248
+ else:
249
+ provider_config.pop("api_keys", None)
250
+
251
+
252
+ __all__ = [
253
+ "CredentialManagementService",
254
+ "CredentialPersistencePorts",
255
+ "CredentialPresentationPorts",
256
+ "CredentialRotationRepository",
257
+ "EnvCredentialRepository",
258
+ "ExternalCredentialPorts",
259
+ "nvidia_env_credential_repository",
260
+ "parse_dotenv_file",
261
+ ]
@@ -0,0 +1,269 @@
1
+ """Credential resolution ports for API keys, OAuth pass-through, and future stores."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ import hashlib
7
+ import re
8
+ from typing import Any, Mapping, Protocol
9
+
10
+ from ciel_runtime_support.architecture import ProviderConfig
11
+
12
+
13
+ API_KEY_CLEAR_TOKENS = frozenset({"clear", "unset", "none", "null", "off", "delete", "remove"})
14
+
15
+ # Persisted credential hygiene: these substrings mean the stored "key" is not a
16
+ # key at all but an upstream error string that was pasted or written into the
17
+ # credential field (operator 2026-07-29: the ollama-cloud api_key held the
18
+ # literal text "504 URLError: <urlopen error EOF occurred in violation of
19
+ # protocol (_ssl.c:2406" — every chat call then failed 401 while /api/tags,
20
+ # which is unauthenticated, kept working and made the key look valid).
21
+ SECRET_ERROR_MARKERS = (
22
+ "urlerror",
23
+ "httperror",
24
+ "http error",
25
+ "<urlopen error",
26
+ "eof occurred in violation of protocol",
27
+ "traceback (most recent call last)",
28
+ "connection refused",
29
+ "connection reset",
30
+ "timed out",
31
+ "unauthorized",
32
+ "401 client error",
33
+ "403 client error",
34
+ "404 client error",
35
+ "500 internal server error",
36
+ "502 bad gateway",
37
+ "503 service unavailable",
38
+ "504 gateway",
39
+ )
40
+
41
+ # Structural shapes shared by every common API key format (Anthropic sk-ant-*,
42
+ # Ollama, DeepSeek, OpenRouter sk-or-*, etc.): single line, no whitespace, no
43
+ # error prose. A credential is a token, not a sentence.
44
+ _KEY_PLAUSIBLE_RE = re.compile(r"^\S{8,512}$")
45
+
46
+
47
+ def looks_like_error_text(value: Any) -> bool:
48
+ text = str(value or "").strip().lower()
49
+ if not text:
50
+ return False
51
+ return any(marker in text for marker in SECRET_ERROR_MARKERS)
52
+
53
+
54
+ def plausible_api_key(value: Any) -> bool:
55
+ """Minimal structural sanity check: one non-space token, no error prose."""
56
+
57
+ text = str(value or "").strip()
58
+ if not _KEY_PLAUSIBLE_RE.match(text):
59
+ return False
60
+ return not looks_like_error_text(text)
61
+ SECRET_TEXT_PATTERNS = (
62
+ re.compile(r"ak_key_[A-Za-z0-9_-]+_secret_[A-Za-z0-9_-]+"),
63
+ re.compile(r"(AINET_API_KEY\s*=\s*)(\S+)", re.IGNORECASE),
64
+ re.compile(r"(Authorization\s*:\s*Bearer\s+)(\S+)", re.IGNORECASE),
65
+ re.compile(r"(token=)(ak_key_[A-Za-z0-9_-]+_secret_[A-Za-z0-9_-]+)", re.IGNORECASE),
66
+ )
67
+
68
+
69
+ def meaningful_key_value(value: Any) -> bool:
70
+ text = str(value or "").strip()
71
+ return bool(text and text not in {"dummy", "not-used", "ollama"})
72
+
73
+
74
+ def api_key_clear_requested(value: Any) -> bool:
75
+ return str(value or "").strip().lower() in API_KEY_CLEAR_TOKENS
76
+
77
+
78
+ def parse_api_key_list(value: Any) -> list[str]:
79
+ """Parse pasted, delimited, and soft-wrapped key input deterministically."""
80
+
81
+ if value is None:
82
+ return []
83
+ raw_items: list[Any]
84
+ if isinstance(value, (list, tuple, set)):
85
+ raw_items = [key for item in value for key in parse_api_key_list(item)]
86
+ else:
87
+ text = str(value)
88
+ if re.search(r"[,;]", text):
89
+ raw_items = []
90
+ for field in re.split(r"[,;]+", text):
91
+ current = ""
92
+ for line in str(field).splitlines() or [str(field)]:
93
+ if not line.strip():
94
+ continue
95
+ if current and line[:1].isspace():
96
+ current += line.strip()
97
+ else:
98
+ if current:
99
+ raw_items.append(current)
100
+ current = line.strip()
101
+ if current:
102
+ raw_items.append(current)
103
+ else:
104
+ raw_items = re.split(r"[\r\n]+", text)
105
+ return list(
106
+ dict.fromkeys(
107
+ key
108
+ for item in raw_items
109
+ if meaningful_key_value(key := str(item or "").strip())
110
+ )
111
+ )
112
+
113
+
114
+ def mask_secret(value: str | None) -> str:
115
+ text = value or ""
116
+ if not text:
117
+ return "not set"
118
+ if len(text) <= 8:
119
+ return "*" * len(text)
120
+ return f"{text[:4]}...{text[-4:]}"
121
+
122
+
123
+ def secret_fingerprint(value: str | None, length: int = 12) -> str:
124
+ text = value or ""
125
+ if not text:
126
+ return "-"
127
+ digest = hashlib.sha256(text.encode("utf-8", errors="replace")).hexdigest()
128
+ return digest[: max(4, length)]
129
+
130
+
131
+ def redact_sensitive_text(text: str) -> str:
132
+ redacted = SECRET_TEXT_PATTERNS[0].sub(lambda match: mask_secret(match.group(0)), text)
133
+ for pattern in SECRET_TEXT_PATTERNS[1:]:
134
+ redacted = pattern.sub(
135
+ lambda match: f"{match.group(1)}{mask_secret(match.group(2))}",
136
+ redacted,
137
+ )
138
+ return redacted
139
+
140
+
141
+ def redact_sensitive_obj(value: Any) -> Any:
142
+ if isinstance(value, str):
143
+ return redact_sensitive_text(value)
144
+ if isinstance(value, list):
145
+ return [redact_sensitive_obj(item) for item in value]
146
+ if isinstance(value, dict):
147
+ return {
148
+ key: mask_secret(str(item))
149
+ if str(key).lower() in {"api_key", "api_keys", "apikey", "token", "authorization", "bearer_token"}
150
+ else redact_sensitive_obj(item)
151
+ for key, item in value.items()
152
+ }
153
+ return value
154
+
155
+
156
+ def provider_config_api_keys(
157
+ pcfg: Mapping[str, Any],
158
+ supplemental_keys: Any = (),
159
+ ) -> list[str]:
160
+ keys = [
161
+ *parse_api_key_list(supplemental_keys),
162
+ *parse_api_key_list(pcfg.get("api_keys")),
163
+ *parse_api_key_list(pcfg.get("api_key")),
164
+ ]
165
+ return list(dict.fromkeys(keys))
166
+
167
+
168
+ def provider_contract_config(
169
+ provider: str,
170
+ pcfg: Mapping[str, Any],
171
+ api_keys: list[str] | tuple[str, ...],
172
+ ) -> ProviderConfig:
173
+ return ProviderConfig(
174
+ name=provider,
175
+ base_url=str(pcfg.get("base_url") or ""),
176
+ model=str(pcfg.get("current_model") or pcfg.get("model") or ""),
177
+ api_keys=tuple(api_keys),
178
+ options=pcfg,
179
+ )
180
+
181
+
182
+ @dataclass(frozen=True, slots=True)
183
+ class CredentialContext:
184
+ provider: str
185
+ api_key: str = ""
186
+ inbound_headers: Any | None = None
187
+
188
+
189
+ @dataclass(frozen=True, slots=True)
190
+ class ResolvedCredential:
191
+ source: str
192
+ headers: Mapping[str, str]
193
+
194
+
195
+ class CredentialSource(Protocol):
196
+ def resolve(self, context: CredentialContext) -> ResolvedCredential | None: ...
197
+
198
+
199
+ @dataclass(frozen=True, slots=True)
200
+ class ApiKeyCredentialSource:
201
+ header: str = "x-api-key"
202
+
203
+ def resolve(self, context: CredentialContext) -> ResolvedCredential | None:
204
+ key = str(context.api_key or "").strip()
205
+ return ResolvedCredential("api_key", {self.header: key}) if key else None
206
+
207
+
208
+ @dataclass(frozen=True, slots=True)
209
+ class InboundHeaderCredentialSource:
210
+ """Resolve credentials from an explicit inbound authentication allowlist."""
211
+
212
+ allowed_headers: tuple[str, ...]
213
+ required_any: tuple[str, ...] = ("authorization", "x-api-key")
214
+
215
+ def resolve(self, context: CredentialContext) -> ResolvedCredential | None:
216
+ inbound = context.inbound_headers
217
+ if inbound is None:
218
+ return None
219
+ try:
220
+ inbound_by_name = {
221
+ str(raw_name).lower(): raw_value
222
+ for raw_name, raw_value in inbound.items()
223
+ }
224
+ except Exception:
225
+ inbound_by_name = {}
226
+ headers: dict[str, str] = {}
227
+ for name in self.allowed_headers:
228
+ try:
229
+ value = inbound.get(name)
230
+ except Exception:
231
+ value = None
232
+ if value is None:
233
+ value = inbound_by_name.get(name.lower())
234
+ if value:
235
+ headers[name] = str(value)
236
+ if not any(headers.get(name) for name in self.required_any):
237
+ return None
238
+ return ResolvedCredential("inbound", headers)
239
+
240
+
241
+ class CredentialChain:
242
+ """Chain of Responsibility: first available credential wins."""
243
+
244
+ def __init__(self, *sources: CredentialSource) -> None:
245
+ self._sources = sources
246
+
247
+ def resolve(self, context: CredentialContext) -> ResolvedCredential | None:
248
+ for source in self._sources:
249
+ if credential := source.resolve(context):
250
+ return credential
251
+ return None
252
+
253
+
254
+ ANTHROPIC_INBOUND_CREDENTIAL_SOURCE = InboundHeaderCredentialSource(
255
+ allowed_headers=(
256
+ "authorization",
257
+ "x-api-key",
258
+ )
259
+ )
260
+
261
+
262
+ def resolve_anthropic_credentials(
263
+ api_key: str,
264
+ inbound_headers: Any | None,
265
+ ) -> ResolvedCredential | None:
266
+ return CredentialChain(
267
+ ApiKeyCredentialSource(),
268
+ ANTHROPIC_INBOUND_CREDENTIAL_SOURCE,
269
+ ).resolve(CredentialContext("anthropic", api_key, inbound_headers))