@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,165 @@
1
+ """HTTP adapter for Codex's split MCP transport."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ import json
7
+ import os
8
+ import time
9
+ from typing import Any, Callable
10
+ import urllib.error
11
+ import urllib.parse
12
+ import urllib.request
13
+
14
+
15
+ @dataclass(frozen=True, slots=True)
16
+ class McpSplitProxyHttpPorts:
17
+ resolve_server: Callable[[str], tuple[str, dict[str, Any]] | None]
18
+ upstream_url: Callable[[dict[str, Any], str], str]
19
+ runtime_headers: Callable[[dict[str, Any]], dict[str, str]]
20
+ copy_response_headers: Callable[[Any, Any], None]
21
+ is_client_disconnect: Callable[[BaseException], bool]
22
+ write_json: Callable[..., Any]
23
+ log: Callable[[str, str], None]
24
+
25
+
26
+ @dataclass(frozen=True, slots=True)
27
+ class McpSplitProxyHttpAdapter:
28
+ ports: McpSplitProxyHttpPorts
29
+ channel_notification_method: str
30
+
31
+ @staticmethod
32
+ def local_sse_hold_seconds() -> float:
33
+ raw = os.environ.get("CIEL_RUNTIME_CODEX_MCP_LOCAL_SSE_SECONDS", "3600")
34
+ try:
35
+ value = float(str(raw).strip())
36
+ except (TypeError, ValueError):
37
+ value = 3600.0
38
+ return max(0.0, min(24 * 3600.0, value))
39
+
40
+ def handle_get(self, handler: Any, path: str) -> bool:
41
+ resolved = self.ports.resolve_server(path)
42
+ if resolved is None:
43
+ return False
44
+ name, _server = resolved
45
+ handler.send_response(200)
46
+ handler.send_header("content-type", "text/event-stream")
47
+ handler.send_header("cache-control", "no-cache")
48
+ handler.send_header("connection", "close")
49
+ handler.end_headers()
50
+ self.ports.log("INFO", f"codex_mcp_split_proxy_local_sse name={name} upstream_get=false")
51
+ deadline = time.time() + self.local_sse_hold_seconds()
52
+ try:
53
+ while time.time() < deadline:
54
+ handler.wfile.write(b": ciel-runtime owns upstream SSE for this MCP server\n\n")
55
+ handler.wfile.flush()
56
+ time.sleep(min(15.0, max(0.05, deadline - time.time())))
57
+ except (BrokenPipeError, ConnectionError, ConnectionResetError):
58
+ pass
59
+ return True
60
+
61
+ def handle_request(self, handler: Any, path: str, raw_body: bytes, method: str) -> bool:
62
+ resolved = self.ports.resolve_server(path)
63
+ if resolved is None:
64
+ return False
65
+ name, server = resolved
66
+ verb = method.upper()
67
+ query = urllib.parse.urlparse(handler.path).query
68
+ upstream_url = self.ports.upstream_url(server, query)
69
+ data = raw_body if verb in {"POST", "PUT", "PATCH"} else None
70
+ try:
71
+ request = urllib.request.Request(
72
+ upstream_url,
73
+ data=data,
74
+ headers=self._request_headers(handler, server),
75
+ method=verb,
76
+ )
77
+ with urllib.request.urlopen(request, timeout=120.0) as response:
78
+ handler.send_response(getattr(response, "status", 200))
79
+ self.ports.copy_response_headers(handler, response.headers)
80
+ handler.end_headers()
81
+ content_type = str(response.headers.get("Content-Type") or "").split(";", 1)[0].strip().lower()
82
+ if content_type == "text/event-stream":
83
+ self._forward_sse(handler, response, name)
84
+ else:
85
+ self._forward_body(handler, response)
86
+ self.ports.log(
87
+ "INFO",
88
+ f"codex_mcp_split_proxy_forwarded name={name} method={verb} upstream={upstream_url}",
89
+ )
90
+ except urllib.error.HTTPError as exc:
91
+ raw = exc.read()
92
+ handler.send_response(exc.code)
93
+ self.ports.copy_response_headers(handler, exc.headers)
94
+ handler.end_headers()
95
+ if raw:
96
+ handler.wfile.write(raw)
97
+ self.ports.log("WARN", f"codex_mcp_split_proxy_http_error name={name} method={verb} status={exc.code}")
98
+ except Exception as exc:
99
+ if self.ports.is_client_disconnect(exc):
100
+ return True
101
+ self.ports.write_json(handler, {"error": {"message": f"{type(exc).__name__}: {exc}"}}, status=502)
102
+ self.ports.log(
103
+ "WARN",
104
+ f"codex_mcp_split_proxy_failed name={name} method={verb} error={type(exc).__name__}: {exc}",
105
+ )
106
+ return True
107
+
108
+ def _request_headers(self, handler: Any, server: dict[str, Any]) -> dict[str, str]:
109
+ headers = self.ports.runtime_headers(server)
110
+ skipped = {"host", "content-length", "connection", "transfer-encoding", "content-encoding"}
111
+ for key, value in handler.headers.items():
112
+ if str(key).lower() not in skipped:
113
+ headers[str(key)] = str(value)
114
+ return headers
115
+
116
+ @staticmethod
117
+ def _forward_body(handler: Any, response: Any) -> None:
118
+ while True:
119
+ chunk = response.read(65536)
120
+ if not chunk:
121
+ break
122
+ handler.wfile.write(chunk)
123
+ handler.wfile.flush()
124
+
125
+ def _forward_sse(self, handler: Any, response: Any, server_name: str) -> None:
126
+ event = bytearray()
127
+ while True:
128
+ line = response.readline()
129
+ if line:
130
+ event.extend(line)
131
+ if not line or line in {b"\n", b"\r\n"}:
132
+ if event:
133
+ raw_event = bytes(event)
134
+ if self._is_channel_event(raw_event):
135
+ self.ports.log(
136
+ "INFO",
137
+ f"codex_mcp_split_proxy_channel_notification_suppressed "
138
+ f"name={server_name} source=post_sse",
139
+ )
140
+ else:
141
+ handler.wfile.write(raw_event)
142
+ handler.wfile.flush()
143
+ event.clear()
144
+ if not line:
145
+ break
146
+
147
+ def _is_channel_event(self, event: bytes) -> bool:
148
+ data_lines: list[str] = []
149
+ for raw_line in event.splitlines():
150
+ field, separator, value = raw_line.decode("utf-8", errors="replace").partition(":")
151
+ if separator and field == "data":
152
+ data_lines.append(value[1:] if value.startswith(" ") else value)
153
+ if not data_lines:
154
+ return False
155
+ try:
156
+ payload = json.loads("\n".join(data_lines))
157
+ except (json.JSONDecodeError, TypeError):
158
+ return False
159
+ return bool(
160
+ isinstance(payload, dict)
161
+ and str(payload.get("method") or "").strip() == self.channel_notification_method
162
+ )
163
+
164
+
165
+ __all__ = ["McpSplitProxyHttpAdapter", "McpSplitProxyHttpPorts"]
@@ -0,0 +1,240 @@
1
+ """Stdio MCP capability probe process adapter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ import os
7
+ import queue
8
+ import subprocess
9
+ import threading
10
+ import time
11
+ from typing import Any, Callable
12
+
13
+
14
+ @dataclass(frozen=True, slots=True)
15
+ class StdioProbeCodec:
16
+ initialize_payload: Callable[[], bytes]
17
+ strategy_for: Callable[..., str]
18
+ find_initialize_response: Callable[..., dict[str, Any] | None]
19
+ capability_present: Callable[..., bool]
20
+ decode_preview: Callable[..., str]
21
+
22
+
23
+ @dataclass(frozen=True, slots=True)
24
+ class StdioProbeProcess:
25
+ is_stdio: Callable[..., bool]
26
+ resolve_server_process: Callable[..., tuple[str, list[str]]]
27
+ popen: Callable[..., Any]
28
+
29
+
30
+ @dataclass(frozen=True, slots=True)
31
+ class StdioProbePolicy:
32
+ default_timeout: Callable[[], float]
33
+ stderr_cap_bytes: int
34
+ stderr_preview_chars: int
35
+ stdout_preview_bytes: int
36
+
37
+
38
+ @dataclass(frozen=True, slots=True)
39
+ class StdioProbeServices:
40
+ codec: StdioProbeCodec
41
+ process: StdioProbeProcess
42
+ policy: StdioProbePolicy
43
+ log: Callable[[str, str], Any]
44
+
45
+
46
+ def _empty_result(reason: str) -> dict[str, Any]:
47
+ return {
48
+ "capable": False,
49
+ "reason": reason,
50
+ "response_bytes": 0,
51
+ "response_received": False,
52
+ "exit_code": None,
53
+ "stderr_bytes": 0,
54
+ "stderr_preview": "",
55
+ "stdout_preview": "",
56
+ "elapsed_ms": 0,
57
+ }
58
+
59
+
60
+ def probe_stdio_mcp_for_channel_capability_detailed(
61
+ server_name: str,
62
+ server: dict[str, Any],
63
+ timeout: float | None = None,
64
+ *,
65
+ services: StdioProbeServices,
66
+ ) -> dict[str, Any]:
67
+ started = time.time()
68
+ codec = services.codec
69
+ process = services.process
70
+ policy = services.policy
71
+ if not process.is_stdio(server):
72
+ return _empty_result("not_stdio")
73
+ command = str(server.get("command") or "").strip()
74
+ args_raw = server.get("args", [])
75
+ args = [str(item) for item in args_raw] if isinstance(args_raw, list) else []
76
+ if not command:
77
+ return _empty_result("no_command")
78
+ command, args = process.resolve_server_process(command, args)
79
+ environment = os.environ.copy()
80
+ raw_environment = server.get("env")
81
+ if isinstance(raw_environment, dict):
82
+ environment.update({str(key): str(value) for key, value in raw_environment.items() if str(key)})
83
+ cwd_value = server.get("cwd") or server.get("workingDirectory")
84
+ cwd = str(cwd_value) if cwd_value else None
85
+ framed = codec.strategy_for(server) == "framed"
86
+ effective_timeout = timeout if timeout is not None else policy.default_timeout()
87
+ try:
88
+ child = process.popen(
89
+ [command, *args],
90
+ stdin=subprocess.PIPE,
91
+ stdout=subprocess.PIPE,
92
+ stderr=subprocess.PIPE,
93
+ cwd=cwd,
94
+ env=environment,
95
+ bufsize=0,
96
+ close_fds=True,
97
+ )
98
+ except Exception as exc:
99
+ services.log("DEBUG", f"channel_probe_spawn_failed server={server_name} error={type(exc).__name__}: {exc}")
100
+ result = _empty_result(f"spawn_failed:{type(exc).__name__}")
101
+ result["stderr_preview"] = str(exc)[:policy.stderr_preview_chars]
102
+ result["elapsed_ms"] = int((time.time() - started) * 1000)
103
+ return result
104
+
105
+ stdout_chunks: queue.Queue[bytes | None] = queue.Queue()
106
+ stderr_buffer = bytearray()
107
+ stderr_lock = threading.Lock()
108
+
109
+ def read_stdout() -> None:
110
+ try:
111
+ if child.stdout is None:
112
+ return
113
+ while True:
114
+ chunk = child.stdout.read(4096)
115
+ if not chunk:
116
+ break
117
+ stdout_chunks.put(chunk)
118
+ except Exception as exc:
119
+ services.log("DEBUG", f"channel_probe_stdout_reader_failed server={server_name} error={type(exc).__name__}: {exc}")
120
+ finally:
121
+ stdout_chunks.put(None)
122
+
123
+ def read_stderr() -> None:
124
+ try:
125
+ if child.stderr is None:
126
+ return
127
+ while True:
128
+ chunk = child.stderr.read(1024)
129
+ if not chunk:
130
+ break
131
+ with stderr_lock:
132
+ remaining = policy.stderr_cap_bytes - len(stderr_buffer)
133
+ if remaining > 0:
134
+ stderr_buffer.extend(chunk[:remaining])
135
+ except Exception as exc:
136
+ services.log("DEBUG", f"channel_probe_stderr_reader_failed server={server_name} error={type(exc).__name__}: {exc}")
137
+
138
+ stdout_thread = threading.Thread(target=read_stdout, daemon=True, name=f"channel-probe-stdout-{server_name}")
139
+ stderr_thread = threading.Thread(target=read_stderr, daemon=True, name=f"channel-probe-stderr-{server_name}")
140
+ stdout_thread.start()
141
+ stderr_thread.start()
142
+ body = codec.initialize_payload()
143
+ frame = b"Content-Length: " + str(len(body)).encode("ascii") + b"\r\n\r\n" + body if framed else body + b"\n"
144
+ try:
145
+ if child.stdin:
146
+ child.stdin.write(frame)
147
+ child.stdin.flush()
148
+ except Exception as exc:
149
+ services.log("WARN", f"channel_probe_initialize_write_failed server={server_name} error={type(exc).__name__}: {exc}")
150
+
151
+ deadline = time.time() + effective_timeout
152
+ stdout_buffer = bytearray()
153
+ capable = False
154
+ response_received = False
155
+ eof_seen = False
156
+ try:
157
+ while time.time() < deadline:
158
+ wait = min(0.2, max(0.001, deadline - time.time()))
159
+ try:
160
+ chunk = stdout_chunks.get(timeout=wait)
161
+ except queue.Empty:
162
+ continue
163
+ if chunk is None:
164
+ eof_seen = True
165
+ break
166
+ stdout_buffer.extend(chunk)
167
+ response = codec.find_initialize_response(bytes(stdout_buffer), framed)
168
+ if response is not None:
169
+ response_received = True
170
+ capable = codec.capability_present(response)
171
+ break
172
+ finally:
173
+ try:
174
+ if child.stdin:
175
+ child.stdin.close()
176
+ except Exception as exc:
177
+ services.log("DEBUG", f"channel_probe_stdin_close_failed server={server_name} error={type(exc).__name__}: {exc}")
178
+ try:
179
+ child.terminate()
180
+ child.wait(timeout=1.0)
181
+ except Exception as terminate_exc:
182
+ services.log("DEBUG", f"channel_probe_terminate_failed server={server_name} error={type(terminate_exc).__name__}: {terminate_exc}")
183
+ try:
184
+ child.kill()
185
+ child.wait(timeout=1.0)
186
+ except Exception as kill_exc:
187
+ services.log("WARN", f"channel_probe_kill_failed server={server_name} error={type(kill_exc).__name__}: {kill_exc}")
188
+ stdout_thread.join(timeout=1.0)
189
+ stderr_thread.join(timeout=1.0)
190
+ for stream in (child.stdout, child.stderr):
191
+ try:
192
+ if stream:
193
+ stream.close()
194
+ except Exception as exc:
195
+ services.log("DEBUG", f"channel_probe_stream_close_failed server={server_name} error={type(exc).__name__}: {exc}")
196
+
197
+ exit_code = child.returncode
198
+ reason = "capable" if capable else (
199
+ "no_experimental_claude_channel" if response_received else ("exited_without_response" if eof_seen else "timeout")
200
+ )
201
+ with stderr_lock:
202
+ stderr_bytes = len(stderr_buffer)
203
+ stderr_preview = codec.decode_preview(stderr_buffer, policy.stderr_preview_chars) if not capable else ""
204
+ stdout_preview = ""
205
+ if not capable and not response_received and stdout_buffer:
206
+ stdout_preview = codec.decode_preview(
207
+ bytes(stdout_buffer)[:policy.stdout_preview_bytes],
208
+ policy.stdout_preview_bytes,
209
+ )
210
+ elapsed_ms = int((time.time() - started) * 1000)
211
+ services.log(
212
+ "INFO",
213
+ "channel_probe_result server=%s channel_capable=%s reason=%s framed=%s bytes=%d stderr_bytes=%d exit_code=%s elapsed_ms=%d timeout_s=%.1f"
214
+ % (
215
+ server_name,
216
+ capable,
217
+ reason,
218
+ framed,
219
+ len(stdout_buffer),
220
+ stderr_bytes,
221
+ "None" if exit_code is None else str(exit_code),
222
+ elapsed_ms,
223
+ effective_timeout,
224
+ ),
225
+ )
226
+ if stderr_preview:
227
+ services.log("INFO", f"channel_probe_stderr server={server_name} preview={stderr_preview!r}")
228
+ if stdout_preview:
229
+ services.log("INFO", f"channel_probe_stdout server={server_name} preview={stdout_preview!r}")
230
+ return {
231
+ "capable": capable,
232
+ "reason": reason,
233
+ "response_bytes": len(stdout_buffer),
234
+ "response_received": response_received,
235
+ "exit_code": exit_code,
236
+ "stderr_bytes": stderr_bytes,
237
+ "stderr_preview": stderr_preview,
238
+ "stdout_preview": stdout_preview,
239
+ "elapsed_ms": elapsed_ms,
240
+ }
@@ -0,0 +1,146 @@
1
+ """MCP HTTP transport primitives with no channel or router state."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import urllib.parse
7
+ import urllib.request
8
+ from typing import Any
9
+
10
+
11
+ MCP_STREAMABLE_HTTP_PROTOCOL_VERSION = "2025-03-26"
12
+ MCP_LEGACY_SSE_PROTOCOL_VERSION = "2024-11-05"
13
+ CODEX_MCP_SPLIT_PROXY_PREFIX = "/ca/codex-mcp/"
14
+
15
+
16
+ def read_sse_json_response(response: Any, request_id: Any | None = None) -> Any:
17
+ """Read the first matching JSON object from an SSE response."""
18
+
19
+ data_lines: list[str] = []
20
+ while True:
21
+ raw = response.readline()
22
+ if raw == b"":
23
+ break
24
+ line = raw.decode("utf-8", errors="replace").rstrip("\r\n")
25
+ if not line:
26
+ message = _matching_json_message(data_lines, request_id)
27
+ if message is not None:
28
+ return message
29
+ data_lines = []
30
+ continue
31
+ if line.startswith(":"):
32
+ continue
33
+ field, _, value = line.partition(":")
34
+ if field == "data":
35
+ data_lines.append(value[1:] if value.startswith(" ") else value)
36
+ return _matching_json_message(data_lines, request_id)
37
+
38
+
39
+ def _matching_json_message(data_lines: list[str], request_id: Any | None) -> dict[str, Any] | None:
40
+ if not data_lines:
41
+ return None
42
+ try:
43
+ message = json.loads("\n".join(data_lines).strip())
44
+ except (TypeError, ValueError, json.JSONDecodeError):
45
+ return None
46
+ if not isinstance(message, dict):
47
+ return None
48
+ if request_id is not None and "id" in message and message.get("id") != request_id:
49
+ return None
50
+ return message
51
+
52
+
53
+ def post_json_with_response_headers(
54
+ endpoint: str,
55
+ headers: dict[str, str],
56
+ payload: dict[str, Any],
57
+ timeout: float,
58
+ ) -> tuple[Any, Any]:
59
+ request_headers = {**headers, "Content-Type": "application/json", "Accept": "application/json, text/event-stream"}
60
+ request = urllib.request.Request(
61
+ endpoint,
62
+ data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
63
+ headers=request_headers,
64
+ method="POST",
65
+ )
66
+ with urllib.request.urlopen(request, timeout=timeout) as response:
67
+ content_type = str(response.headers.get("Content-Type") or "").lower()
68
+ if "text/event-stream" in content_type:
69
+ return read_sse_json_response(response, payload.get("id")), response.headers
70
+ data = response.read()
71
+ if not data:
72
+ return None, response.headers
73
+ try:
74
+ return json.loads(data.decode("utf-8")), response.headers
75
+ except (UnicodeDecodeError, ValueError, json.JSONDecodeError):
76
+ return data.decode("utf-8", errors="replace"), response.headers
77
+
78
+
79
+ def sse_post_json(endpoint: str, headers: dict[str, str], payload: dict[str, Any], timeout: float) -> Any:
80
+ result, _headers = post_json_with_response_headers(endpoint, headers, payload, timeout)
81
+ return result
82
+
83
+
84
+ def streamable_headers(
85
+ headers: dict[str, str],
86
+ protocol_version: str,
87
+ session_id: str | None = None,
88
+ *,
89
+ accept: str = "application/json, text/event-stream",
90
+ ) -> dict[str, str]:
91
+ out = {**headers, "Accept": accept, "MCP-Protocol-Version": protocol_version}
92
+ if session_id:
93
+ out["Mcp-Session-Id"] = session_id
94
+ return out
95
+
96
+
97
+ def streamable_post_json(
98
+ endpoint: str,
99
+ headers: dict[str, str],
100
+ payload: dict[str, Any],
101
+ timeout: float,
102
+ protocol_version: str,
103
+ session_id: str | None = None,
104
+ ) -> tuple[Any, str | None]:
105
+ result, response_headers = post_json_with_response_headers(
106
+ endpoint,
107
+ streamable_headers(headers, protocol_version, session_id),
108
+ payload,
109
+ timeout,
110
+ )
111
+ returned_session = None
112
+ if response_headers is not None:
113
+ returned_session = response_headers.get("Mcp-Session-Id") or response_headers.get("MCP-Session-Id")
114
+ return result, str(returned_session).strip() if returned_session else None
115
+
116
+
117
+ def split_proxy_server_name(path: str, prefix: str = CODEX_MCP_SPLIT_PROXY_PREFIX) -> str | None:
118
+ if not path.startswith(prefix):
119
+ return None
120
+ suffix = path[len(prefix):]
121
+ if not suffix or "/" in suffix:
122
+ return None
123
+ name = urllib.parse.unquote(suffix).strip()
124
+ return name or None
125
+
126
+
127
+ def upstream_url(server: dict[str, Any], query: str = "") -> str:
128
+ url = str(server.get("url") or server.get("endpoint") or "").strip()
129
+ if query:
130
+ separator = "&" if urllib.parse.urlparse(url).query else "?"
131
+ url = f"{url}{separator}{query}"
132
+ return url
133
+
134
+
135
+ __all__ = [
136
+ "CODEX_MCP_SPLIT_PROXY_PREFIX",
137
+ "MCP_LEGACY_SSE_PROTOCOL_VERSION",
138
+ "MCP_STREAMABLE_HTTP_PROTOCOL_VERSION",
139
+ "post_json_with_response_headers",
140
+ "read_sse_json_response",
141
+ "split_proxy_server_name",
142
+ "sse_post_json",
143
+ "streamable_headers",
144
+ "streamable_post_json",
145
+ "upstream_url",
146
+ ]
@@ -0,0 +1,78 @@
1
+ """Model cache invalidation and launch-time hydration orchestration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+
11
+ @dataclass(frozen=True, slots=True)
12
+ class ModelCacheLifecyclePorts:
13
+ invalidate_config: Callable[[], None]
14
+ artifact_paths: Callable[[], tuple[Path, ...]]
15
+ read_list_cache: Callable[[str, dict[str, Any]], list[str] | None]
16
+ read_registry_models: Callable[[str, dict[str, Any], float], list[str] | None]
17
+ upstream_model_ids: Callable[[str, dict[str, Any]], list[str]]
18
+ catalog_model_ids: Callable[[str], list[str]]
19
+ normalize_model_id: Callable[[str, str], str]
20
+ unique_model_ids: Callable[[str, list[str]], list[str]]
21
+ sorted_model_ids: Callable[[list[str]], list[str]]
22
+ log: Callable[[str, str], None]
23
+
24
+
25
+ @dataclass(frozen=True, slots=True)
26
+ class ModelCacheLifecycleService:
27
+ ports: ModelCacheLifecyclePorts
28
+
29
+ def clear(self) -> None:
30
+ self.ports.invalidate_config()
31
+ for path in self.ports.artifact_paths():
32
+ try:
33
+ path.unlink()
34
+ except FileNotFoundError:
35
+ pass
36
+
37
+ def cached_or_configured_ids(
38
+ self,
39
+ provider: str,
40
+ config: dict[str, Any],
41
+ ) -> list[str]:
42
+ model_ids = self.ports.read_list_cache(provider, config) or []
43
+ if provider == "ollama-cloud":
44
+ model_ids.extend(self.ports.catalog_model_ids(provider))
45
+ for raw_model_id in config.get("custom_models", []) or []:
46
+ model_id = self.ports.normalize_model_id(provider, raw_model_id)
47
+ if model_id and model_id not in model_ids:
48
+ model_ids.append(model_id)
49
+ current = self.ports.normalize_model_id(provider, config.get("current_model") or "")
50
+ if current and current not in model_ids and not current.startswith(
51
+ f"ciel-runtime-{provider}-"
52
+ ):
53
+ model_ids.insert(0, current)
54
+ model_ids = self.ports.unique_model_ids(provider, model_ids)
55
+ return model_ids if provider == "anthropic" else self.ports.sorted_model_ids(model_ids)
56
+
57
+ def ensure_for_launch(self, provider: str, config: dict[str, Any]) -> None:
58
+ if self.ports.read_list_cache(provider, config):
59
+ return
60
+ if self.ports.read_registry_models(provider, config, 0):
61
+ return
62
+ try:
63
+ model_ids = self.ports.upstream_model_ids(provider, config)
64
+ except Exception as exc:
65
+ self.ports.log(
66
+ "WARN",
67
+ "launch_model_cache_refresh_failed "
68
+ f"provider={provider} error={type(exc).__name__}: {exc}",
69
+ )
70
+ return
71
+ if model_ids:
72
+ self.ports.log(
73
+ "INFO",
74
+ f"launch_model_cache_ready provider={provider} count={len(model_ids)}",
75
+ )
76
+
77
+
78
+ __all__ = ["ModelCacheLifecyclePorts", "ModelCacheLifecycleService"]
@@ -0,0 +1,61 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Callable, Mapping
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class ModelCatalogProjectionServices:
9
+ normalize_model_id: Callable[[str, str], str]
10
+ model_context: Callable[[Mapping[str, Any]], int | None]
11
+ positive_int: Callable[[Any], int | None]
12
+ project_metadata: Callable[[Mapping[str, Any]], Mapping[str, Any]]
13
+
14
+
15
+ def project_model_info(
16
+ provider: str,
17
+ data: Any,
18
+ services: ModelCatalogProjectionServices,
19
+ ) -> dict[str, dict[str, Any]]:
20
+ candidates = _model_candidates(data)
21
+ if candidates is None:
22
+ return {}
23
+ output: dict[str, dict[str, Any]] = {}
24
+ for item in candidates:
25
+ if isinstance(item, str):
26
+ model_id = item
27
+ raw: Mapping[str, Any] = {}
28
+ elif isinstance(item, Mapping):
29
+ model_id = str(
30
+ item.get("id")
31
+ or item.get("key")
32
+ or item.get("name")
33
+ or item.get("model")
34
+ or ""
35
+ )
36
+ raw = item
37
+ else:
38
+ continue
39
+ normalized_id = services.normalize_model_id(provider, model_id.strip())
40
+ if not normalized_id:
41
+ continue
42
+ info = dict(services.project_metadata(raw))
43
+ max_context = services.positive_int(raw.get("max_context_length")) or services.model_context(raw)
44
+ if max_context:
45
+ info["max_model_len"] = max_context
46
+ if info:
47
+ output[normalized_id] = info
48
+ return output
49
+
50
+
51
+ def _model_candidates(data: Any) -> list[Any] | None:
52
+ candidates = data
53
+ if isinstance(data, Mapping):
54
+ candidates = data.get("data")
55
+ if candidates is None:
56
+ candidates = data.get("models")
57
+ if candidates is None:
58
+ candidates = data.get("model")
59
+ if isinstance(candidates, Mapping) or isinstance(candidates, str):
60
+ candidates = [candidates]
61
+ return candidates if isinstance(candidates, list) else None