@oneciel-ai/ciel-runtime 0.2.2 → 0.2.4

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 (136) hide show
  1. package/ciel_runtime.py +2555 -9635
  2. package/ciel_runtime_support/advisor_request_builder.py +8 -21
  3. package/ciel_runtime_support/anthropic_tool_turns.py +13 -8
  4. package/ciel_runtime_support/architecture.py +68 -0
  5. package/ciel_runtime_support/architecture_budget.py +1 -1
  6. package/ciel_runtime_support/channel_connection_context.py +233 -0
  7. package/ciel_runtime_support/channel_delivery_context.py +332 -0
  8. package/ciel_runtime_support/channel_mcp_context.py +313 -0
  9. package/ciel_runtime_support/channel_mcp_discovery.py +47 -0
  10. package/ciel_runtime_support/channel_mcp_transport.py +5 -1
  11. package/ciel_runtime_support/channel_message_context.py +212 -0
  12. package/ciel_runtime_support/channel_message_repository.py +14 -3
  13. package/ciel_runtime_support/channel_pending_injection.py +9 -0
  14. package/ciel_runtime_support/channel_probe_launch_context.py +213 -0
  15. package/ciel_runtime_support/channel_replay_policy.py +38 -0
  16. package/ciel_runtime_support/channel_runtime_environment.py +8 -0
  17. package/ciel_runtime_support/channel_session_context.py +236 -0
  18. package/ciel_runtime_support/channel_terminal_context.py +350 -0
  19. package/ciel_runtime_support/channel_wake_context.py +532 -0
  20. package/ciel_runtime_support/claude_environment.py +60 -0
  21. package/ciel_runtime_support/claude_launch_assembly.py +249 -0
  22. package/ciel_runtime_support/claude_router.py +62 -12
  23. package/ciel_runtime_support/cli_application_context.py +132 -0
  24. package/ciel_runtime_support/cli_assembly.py +50 -0
  25. package/ciel_runtime_support/codex_backend_context.py +363 -0
  26. package/ciel_runtime_support/codex_config.py +13 -1
  27. package/ciel_runtime_support/codex_launch_assembly.py +213 -0
  28. package/ciel_runtime_support/codex_launch_configuration.py +30 -1
  29. package/ciel_runtime_support/codex_mcp_integration.py +90 -8
  30. package/ciel_runtime_support/codex_model_catalog.py +4 -1
  31. package/ciel_runtime_support/codex_reasoning_rejects.py +225 -0
  32. package/ciel_runtime_support/codex_router.py +38 -8
  33. package/ciel_runtime_support/codex_turn_recovery.py +154 -0
  34. package/ciel_runtime_support/config_migrations.py +103 -0
  35. package/ciel_runtime_support/config_repository.py +30 -0
  36. package/ciel_runtime_support/configuration_cli.py +38 -0
  37. package/ciel_runtime_support/context_compaction.py +9 -4
  38. package/ciel_runtime_support/credential_management.py +12 -0
  39. package/ciel_runtime_support/credentials.py +12 -0
  40. package/ciel_runtime_support/github_copilot_oauth.py +2 -2
  41. package/ciel_runtime_support/hosted_formula_tools.py +216 -0
  42. package/ciel_runtime_support/kimi_runtime_context.py +208 -0
  43. package/ciel_runtime_support/llm_preset_context.py +338 -0
  44. package/ciel_runtime_support/managed_mcp_config.py +8 -4
  45. package/ciel_runtime_support/mcp_configuration_context.py +291 -0
  46. package/ciel_runtime_support/mcp_http_proxy.py +14 -8
  47. package/ciel_runtime_support/mcp_probe_transport.py +47 -15
  48. package/ciel_runtime_support/mcp_transport.py +14 -1
  49. package/ciel_runtime_support/native_context_recovery.py +72 -0
  50. package/ciel_runtime_support/ollama_catalog_context.py +213 -0
  51. package/ciel_runtime_support/ollama_stream_collection.py +103 -0
  52. package/ciel_runtime_support/ollama_thinking.py +6 -1
  53. package/ciel_runtime_support/ollama_wire_projection.py +157 -0
  54. package/ciel_runtime_support/openai_forwarding.py +32 -10
  55. package/ciel_runtime_support/openai_responses_router.py +12 -0
  56. package/ciel_runtime_support/package_lifecycle.py +39 -0
  57. package/ciel_runtime_support/prelaunch_assembly.py +37 -0
  58. package/ciel_runtime_support/prelaunch_panel_context.py +418 -0
  59. package/ciel_runtime_support/prelaunch_shell_context.py +394 -0
  60. package/ciel_runtime_support/prompt_compaction.py +144 -0
  61. package/ciel_runtime_support/prompt_injection.py +45 -0
  62. package/ciel_runtime_support/protocols/anthropic_thinking_policy.py +1 -1
  63. package/ciel_runtime_support/protocols/chat_projection.py +85 -5
  64. package/ciel_runtime_support/protocols/conversation_turn_policy.py +43 -0
  65. package/ciel_runtime_support/protocols/ollama_chat.py +31 -0
  66. package/ciel_runtime_support/protocols/ollama_response.py +57 -5
  67. package/ciel_runtime_support/protocols/openai_reasoning.py +5 -2
  68. package/ciel_runtime_support/protocols/openai_responses.py +61 -15
  69. package/ciel_runtime_support/provider_adapters.py +26 -0
  70. package/ciel_runtime_support/provider_administration_context.py +207 -0
  71. package/ciel_runtime_support/provider_config_mutations.py +3 -0
  72. package/ciel_runtime_support/provider_model_catalog_context.py +137 -0
  73. package/ciel_runtime_support/provider_model_context.py +107 -0
  74. package/ciel_runtime_support/provider_model_metadata_context.py +197 -0
  75. package/ciel_runtime_support/provider_model_selection.py +10 -3
  76. package/ciel_runtime_support/provider_models.py +45 -2
  77. package/ciel_runtime_support/provider_option_cli.py +19 -0
  78. package/ciel_runtime_support/provider_policy.py +1 -1
  79. package/ciel_runtime_support/provider_readiness_context.py +189 -0
  80. package/ciel_runtime_support/provider_request_builder.py +64 -28
  81. package/ciel_runtime_support/provider_responses_passthrough.py +21 -2
  82. package/ciel_runtime_support/provider_timeout_policy.py +54 -0
  83. package/ciel_runtime_support/provider_tool_policy.py +9 -1
  84. package/ciel_runtime_support/providers/__init__.py +6 -0
  85. package/ciel_runtime_support/providers/alibaba.py +634 -0
  86. package/ciel_runtime_support/providers/catalog.py +24 -16
  87. package/ciel_runtime_support/providers/deepseek.py +73 -0
  88. package/ciel_runtime_support/providers/github_copilot_oauth.py +22 -1
  89. package/ciel_runtime_support/providers/kimi.py +69 -9
  90. package/ciel_runtime_support/providers/ollama.py +8 -0
  91. package/ciel_runtime_support/providers/ollama_context.py +21 -2
  92. package/ciel_runtime_support/providers/vllm.py +7 -1
  93. package/ciel_runtime_support/response_collection.py +68 -18
  94. package/ciel_runtime_support/response_collection_context.py +391 -0
  95. package/ciel_runtime_support/response_stream_context.py +555 -0
  96. package/ciel_runtime_support/responses_input_compatibility.py +121 -0
  97. package/ciel_runtime_support/responses_usage_observer.py +83 -0
  98. package/ciel_runtime_support/router_client_lifecycle.py +1 -0
  99. package/ciel_runtime_support/router_http.py +245 -3
  100. package/ciel_runtime_support/router_observability_context.py +251 -0
  101. package/ciel_runtime_support/router_process_context.py +200 -0
  102. package/ciel_runtime_support/router_process_lifecycle.py +2 -0
  103. package/ciel_runtime_support/router_request_assembly.py +399 -0
  104. package/ciel_runtime_support/router_request_context.py +215 -0
  105. package/ciel_runtime_support/router_server_context.py +84 -0
  106. package/ciel_runtime_support/runaway_output_guard.py +488 -0
  107. package/ciel_runtime_support/runtime_asset_assembly.py +147 -0
  108. package/ciel_runtime_support/runtime_asset_context.py +297 -0
  109. package/ciel_runtime_support/runtime_constants.py +16 -1
  110. package/ciel_runtime_support/runtime_launch.py +9 -5
  111. package/ciel_runtime_support/runtime_launch_context.py +130 -0
  112. package/ciel_runtime_support/runtime_maintenance_assembly.py +60 -0
  113. package/ciel_runtime_support/runtime_maintenance_context.py +309 -0
  114. package/ciel_runtime_support/runtime_maintenance_services.py +265 -0
  115. package/ciel_runtime_support/runtime_paths.py +60 -40
  116. package/ciel_runtime_support/runtime_primitives.py +78 -0
  117. package/ciel_runtime_support/speech_http_controller.py +335 -0
  118. package/ciel_runtime_support/sse_stream_collection.py +236 -0
  119. package/ciel_runtime_support/statusline_script.py +57 -8
  120. package/ciel_runtime_support/streaming_anthropic.py +361 -24
  121. package/ciel_runtime_support/tool_schema.py +40 -2
  122. package/ciel_runtime_support/tool_side_effect_dedupe.py +117 -12
  123. package/ciel_runtime_support/upstream_dump.py +68 -0
  124. package/ciel_runtime_support/upstream_retry_context.py +259 -0
  125. package/ciel_runtime_support/web_ui.py +248 -1
  126. package/ciel_runtime_support/workspace_router_selection.py +86 -0
  127. package/docs/COLAB_SPEECH.md +32 -0
  128. package/docs/Configuration.md +50 -0
  129. package/docs/Test-Suite.md +1 -0
  130. package/package.json +4 -1
  131. package/scripts/colab/__pycache__/bootstrap_moss_tts.cpython-311.pyc +0 -0
  132. package/scripts/colab/__pycache__/bootstrap_qwen_asr.cpython-311.pyc +0 -0
  133. package/scripts/colab/bootstrap_moss_tts.py +106 -0
  134. package/scripts/colab/bootstrap_qwen_asr.py +106 -0
  135. package/scripts/configure_speech_workers.py +37 -0
  136. package/scripts/deploy_colab_speech.ps1 +47 -0
@@ -0,0 +1,291 @@
1
+ """MCP configuration discovery and native-config projection context."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Any, Callable, Protocol
10
+
11
+ from .mcp_config_reader import ClaudeMcpConfigPathPolicy
12
+ from .managed_mcp_discovery import (
13
+ ManagedMcpDiscoveryPaths,
14
+ ManagedMcpDiscoveryPorts,
15
+ ManagedMcpDiscoveryService,
16
+ NativeMcpConfigWriter,
17
+ NativeMcpConfigWriterPorts,
18
+ )
19
+
20
+
21
+ class JsonArtifactRepository(Protocol):
22
+ def save(self, data: Any, operation: str) -> None: ...
23
+
24
+
25
+ @dataclass(frozen=True, slots=True)
26
+ class McpConfigurationFilePorts:
27
+ read_items: Callable[..., list[Any]]
28
+ names_from_mapping: Callable[..., Any]
29
+ servers_from_mapping: Callable[..., Any]
30
+ log: Callable[[str, str], None]
31
+
32
+
33
+ @dataclass(frozen=True, slots=True)
34
+ class McpConfigurationPaths:
35
+ home: Path
36
+ web_tools: Path
37
+ proxy: Path
38
+ native_config: Path
39
+
40
+
41
+ @dataclass(frozen=True, slots=True)
42
+ class McpConfigurationRuntimePorts:
43
+ resolve_executable: Callable[[str], str]
44
+ parse_bool: Callable[..., bool]
45
+ artifact_repository: Callable[[Path], JsonArtifactRepository]
46
+ discover_channel_specs: Callable[..., list[str]]
47
+ is_channel_tagged: Callable[[str], bool]
48
+
49
+
50
+ @dataclass(frozen=True, slots=True)
51
+ class McpConfigurationContext:
52
+ files: McpConfigurationFilePorts
53
+ paths: McpConfigurationPaths
54
+ runtime: McpConfigurationRuntimePorts
55
+ native_channel_names: frozenset[str]
56
+
57
+ def read_server_names(self, path: Path, cwd: Path) -> list[str]:
58
+ return self.files.read_items(
59
+ path,
60
+ cwd,
61
+ self.files.names_from_mapping,
62
+ str,
63
+ self.files.log,
64
+ )
65
+
66
+ def read_servers(
67
+ self, path: Path, cwd: Path
68
+ ) -> list[tuple[str, dict[str, Any]]]:
69
+ return self.files.read_items(
70
+ path,
71
+ cwd,
72
+ self.files.servers_from_mapping,
73
+ lambda item: item[0],
74
+ self.files.log,
75
+ )
76
+
77
+ def server_is_stdio(self, server: dict[str, Any]) -> bool:
78
+ if not isinstance(server, dict):
79
+ return False
80
+ server_type = str(server.get("type") or "").strip().lower()
81
+ if server_type and server_type not in ("stdio", "command"):
82
+ return False
83
+ command = self.runtime.resolve_executable(
84
+ str(server.get("command") or "").strip()
85
+ )
86
+ if not command:
87
+ return False
88
+ raw_args = server.get("args", [])
89
+ args = (
90
+ [str(item) for item in raw_args if item is not None]
91
+ if isinstance(raw_args, list)
92
+ else []
93
+ )
94
+ return "mcp-proxy" not in args
95
+
96
+ @staticmethod
97
+ def server_is_streamable_http(server: dict[str, Any]) -> bool:
98
+ if not isinstance(server, dict):
99
+ return False
100
+ server_type = str(
101
+ server.get("type") or server.get("transport") or ""
102
+ ).strip().lower()
103
+ if server_type not in {"http", "streamable-http"}:
104
+ return False
105
+ url = str(server.get("url") or server.get("endpoint") or "").strip()
106
+ return url.startswith(("http://", "https://"))
107
+
108
+ def server_force_proxy(self, server: dict[str, Any]) -> bool:
109
+ if not isinstance(server, dict):
110
+ return False
111
+ return self.runtime.parse_bool(
112
+ server.get(
113
+ "ciel_runtime_mcp_proxy",
114
+ server.get(
115
+ "ciel_runtime_force_mcp_proxy",
116
+ server.get("force_mcp_proxy", False),
117
+ ),
118
+ ),
119
+ False,
120
+ )
121
+
122
+ def server_disables_proxy_notifications(self, server: dict[str, Any]) -> bool:
123
+ if not isinstance(server, dict):
124
+ return False
125
+ return self.runtime.parse_bool(
126
+ server.get(
127
+ "ciel_runtime_disable_notification_stream",
128
+ server.get("ciel_runtime_disable_mcp_notifications", False),
129
+ ),
130
+ False,
131
+ )
132
+
133
+ @staticmethod
134
+ def safe_proxy_name(name: str) -> str:
135
+ safe = re.sub(r"[^A-Za-z0-9_.-]+", "_", name.strip())
136
+ return safe[:80] or "server"
137
+
138
+ def config_paths(
139
+ self,
140
+ passthrough: list[str] | None = None,
141
+ cwd: Path | None = None,
142
+ home: Path | None = None,
143
+ ) -> list[Path]:
144
+ return ClaudeMcpConfigPathPolicy.paths(
145
+ passthrough or [], cwd or Path.cwd(), home or self.paths.home
146
+ )
147
+
148
+ def existing_config_paths(
149
+ self,
150
+ passthrough: list[str] | None = None,
151
+ cwd: Path | None = None,
152
+ home: Path | None = None,
153
+ ) -> list[Path]:
154
+ return ClaudeMcpConfigPathPolicy.existing_paths(
155
+ passthrough or [], cwd or Path.cwd(), home or self.paths.home
156
+ )
157
+
158
+ def discover_user_servers(
159
+ self,
160
+ passthrough: list[str] | None = None,
161
+ cwd: Path | None = None,
162
+ home: Path | None = None,
163
+ ) -> dict[str, dict[str, Any]]:
164
+ working_directory = cwd or Path.cwd()
165
+ servers: dict[str, dict[str, Any]] = {}
166
+ for path in self.existing_config_paths(
167
+ passthrough, working_directory, home
168
+ ):
169
+ for name, server in self.read_servers(path, working_directory):
170
+ servers.setdefault(name, server)
171
+ return servers
172
+
173
+ def read_generated_servers(
174
+ self, path: Path, cwd: Path
175
+ ) -> dict[str, dict[str, Any]]:
176
+ if not path.exists() or not path.is_file():
177
+ return {}
178
+ servers: dict[str, dict[str, Any]] = {}
179
+ for name, server in self.read_servers(path, cwd):
180
+ if name.strip().lower() in self.native_channel_names:
181
+ continue
182
+ servers.setdefault(name, server)
183
+ return servers
184
+
185
+ def discover_managed_servers(
186
+ self, cwd: Path | None = None
187
+ ) -> dict[str, dict[str, Any]]:
188
+ service = ManagedMcpDiscoveryService(
189
+ paths=ManagedMcpDiscoveryPaths(
190
+ web_tools=self.paths.web_tools,
191
+ proxy=self.paths.proxy,
192
+ ),
193
+ ports=ManagedMcpDiscoveryPorts(
194
+ read_generated=self.read_generated_servers,
195
+ load_json=lambda path: json.loads(path.read_text(encoding="utf-8")),
196
+ log=self.files.log,
197
+ ),
198
+ native_channel_names=frozenset(
199
+ name.casefold() for name in self.native_channel_names
200
+ ),
201
+ )
202
+ return service.discover(cwd or Path.cwd())
203
+
204
+ def write_native_config(
205
+ self,
206
+ passthrough: list[str] | None = None,
207
+ cwd: Path | None = None,
208
+ home: Path | None = None,
209
+ ) -> Path | None:
210
+ writer = NativeMcpConfigWriter(
211
+ self.paths.native_config,
212
+ NativeMcpConfigWriterPorts(
213
+ self.discover_user_servers,
214
+ self.discover_managed_servers,
215
+ lambda path, data, operation: self.runtime.artifact_repository(
216
+ path
217
+ ).save(data, operation),
218
+ self.files.log,
219
+ ),
220
+ )
221
+ return writer.write(passthrough, cwd, home)
222
+
223
+ def auto_discovered_channel_specs(
224
+ self,
225
+ passthrough: list[str] | None = None,
226
+ cwd: Path | None = None,
227
+ home: Path | None = None,
228
+ ) -> list[str]:
229
+ working_directory = cwd or Path.cwd()
230
+ return self.runtime.discover_channel_specs(
231
+ self.config_paths(passthrough, working_directory, home),
232
+ working_directory,
233
+ self.read_server_names,
234
+ self.runtime.is_channel_tagged,
235
+ )
236
+
237
+
238
+ @dataclass(frozen=True, slots=True)
239
+ class McpConfigurationCompatibilityApi:
240
+ context: Callable[[], McpConfigurationContext]
241
+
242
+ def read_server_names(self, *args: Any, **kwargs: Any) -> list[str]:
243
+ return self.context().read_server_names(*args, **kwargs)
244
+
245
+ def read_servers(self, *args: Any, **kwargs: Any) -> list[Any]:
246
+ return self.context().read_servers(*args, **kwargs)
247
+
248
+ def server_is_stdio(self, server: dict[str, Any]) -> bool:
249
+ return self.context().server_is_stdio(server)
250
+
251
+ def server_is_streamable_http(self, server: dict[str, Any]) -> bool:
252
+ return self.context().server_is_streamable_http(server)
253
+
254
+ def server_force_proxy(self, server: dict[str, Any]) -> bool:
255
+ return self.context().server_force_proxy(server)
256
+
257
+ def server_disables_proxy_notifications(self, server: dict[str, Any]) -> bool:
258
+ return self.context().server_disables_proxy_notifications(server)
259
+
260
+ def safe_proxy_name(self, name: str) -> str:
261
+ return self.context().safe_proxy_name(name)
262
+
263
+ def config_paths(self, *args: Any, **kwargs: Any) -> list[Path]:
264
+ return self.context().config_paths(*args, **kwargs)
265
+
266
+ def existing_config_paths(self, *args: Any, **kwargs: Any) -> list[Path]:
267
+ return self.context().existing_config_paths(*args, **kwargs)
268
+
269
+ def discover_user_servers(self, *args: Any, **kwargs: Any) -> dict[str, dict[str, Any]]:
270
+ return self.context().discover_user_servers(*args, **kwargs)
271
+
272
+ def read_generated_servers(self, *args: Any, **kwargs: Any) -> dict[str, dict[str, Any]]:
273
+ return self.context().read_generated_servers(*args, **kwargs)
274
+
275
+ def discover_managed_servers(self, *args: Any, **kwargs: Any) -> dict[str, dict[str, Any]]:
276
+ return self.context().discover_managed_servers(*args, **kwargs)
277
+
278
+ def write_native_config(self, *args: Any, **kwargs: Any) -> Path | None:
279
+ return self.context().write_native_config(*args, **kwargs)
280
+
281
+ def auto_discovered_channel_specs(self, *args: Any, **kwargs: Any) -> list[str]:
282
+ return self.context().auto_discovered_channel_specs(*args, **kwargs)
283
+
284
+
285
+ __all__ = [
286
+ "McpConfigurationCompatibilityApi",
287
+ "McpConfigurationContext",
288
+ "McpConfigurationFilePorts",
289
+ "McpConfigurationPaths",
290
+ "McpConfigurationRuntimePorts",
291
+ ]
@@ -13,6 +13,8 @@ from typing import Any, Callable
13
13
  import urllib.error
14
14
  import urllib.request
15
15
 
16
+ from .mcp_transport import negotiated_protocol_version
17
+
16
18
 
17
19
  @dataclass(frozen=True, slots=True)
18
20
  class McpHttpProxyCodec:
@@ -101,6 +103,7 @@ def run_mcp_streamable_http_proxy(
101
103
  endpoint = str(server.get("url") or server.get("endpoint") or "").strip()
102
104
  headers = mcp_server_runtime_headers(server)
103
105
  protocol_version = str(server.get("mcp_protocol_version") or server.get("protocolVersion") or server.get("protocol_version") or MCP_STREAMABLE_HTTP_PROTOCOL_VERSION)
106
+ active_protocol_version = protocol_version
104
107
  timeout = max(5.0, min(120.0, float(server.get("mcp_timeout_seconds") or server.get("timeout") or 20.0)))
105
108
  requires_session = parse_bool(server.get("streamable_requires_session", server.get("require_session", server.get("mcp_session_required", True))), True)
106
109
  notification_stream_enabled = not _mcp_server_disable_proxy_notification_stream(server)
@@ -236,19 +239,22 @@ def run_mcp_streamable_http_proxy(
236
239
  Publishes the initialize result (for the stdin initialize reply) and the
237
240
  new session id under session_cond, waking any waiter.
238
241
  """
239
- nonlocal session_id, initialize_result
242
+ nonlocal session_id, initialize_result, active_protocol_version
240
243
  init_payload = initialize_payload
241
244
  if not init_payload:
242
245
  return None
246
+ params = init_payload.get("params") if isinstance(init_payload.get("params"), dict) else {}
247
+ requested_protocol = str(params.get("protocolVersion") or protocol_version)
243
248
  try:
244
249
  result, returned_session = _mcp_proxy_streamable_http_request(
245
- endpoint, headers, init_payload, timeout, protocol_version, None,
250
+ endpoint, headers, init_payload, timeout, requested_protocol, None,
246
251
  )
247
252
  except Exception as exc:
248
253
  router_log("WARN", f"mcp_http_proxy_session_init_failed server={server_name} error={type(exc).__name__}: {exc}")
249
254
  return None
250
255
  if isinstance(result, dict):
251
256
  _mcp_proxy_observe_json_message(server_name, result)
257
+ active_protocol_version = negotiated_protocol_version(result, requested_protocol)
252
258
  with session_cond:
253
259
  session_id = returned_session or session_id
254
260
  if isinstance(result, dict):
@@ -258,7 +264,7 @@ def run_mcp_streamable_http_proxy(
258
264
  if new_session and initialized_payload:
259
265
  try:
260
266
  _mcp_proxy_streamable_http_request(
261
- endpoint, headers, initialized_payload, timeout, protocol_version, new_session,
267
+ endpoint, headers, initialized_payload, timeout, active_protocol_version, new_session,
262
268
  )
263
269
  except Exception as exc:
264
270
  router_log(
@@ -347,7 +353,7 @@ def run_mcp_streamable_http_proxy(
347
353
  reopen_after_close = False
348
354
  try:
349
355
  request_headers = _mcp_streamable_headers(
350
- headers, protocol_version, worker_session, accept="text/event-stream",
356
+ headers, active_protocol_version, worker_session, accept="text/event-stream",
351
357
  )
352
358
  if last_event_id:
353
359
  request_headers["Last-Event-ID"] = last_event_id
@@ -463,7 +469,7 @@ def run_mcp_streamable_http_proxy(
463
469
  active = session_id
464
470
  if active:
465
471
  try:
466
- _mcp_proxy_streamable_http_request(endpoint, headers, payload, timeout, protocol_version, active)
472
+ _mcp_proxy_streamable_http_request(endpoint, headers, payload, timeout, active_protocol_version, active)
467
473
  router_log("INFO", f"mcp_http_proxy_initialized_forwarded server={server_name} session={active}")
468
474
  except urllib.error.HTTPError as exc:
469
475
  body_text = _http_error_body_text(exc)
@@ -519,7 +525,7 @@ def run_mcp_streamable_http_proxy(
519
525
  if not active_session:
520
526
  raise RuntimeError("Streamable HTTP MCP session is not initialized")
521
527
  result, _returned = _mcp_proxy_streamable_http_request(
522
- endpoint, headers, payload, timeout, protocol_version, active_session,
528
+ endpoint, headers, payload, timeout, active_protocol_version, active_session,
523
529
  )
524
530
  tool_name = _mcp_proxy_tool_call_name(payload)
525
531
  if isinstance(result, dict):
@@ -549,7 +555,7 @@ def run_mcp_streamable_http_proxy(
549
555
  if active_session:
550
556
  try:
551
557
  result, _r = _mcp_proxy_streamable_http_request(
552
- endpoint, headers, payload, timeout, protocol_version, active_session,
558
+ endpoint, headers, payload, timeout, active_protocol_version, active_session,
553
559
  )
554
560
  tool_name = _mcp_proxy_tool_call_name(payload)
555
561
  if isinstance(result, dict):
@@ -572,7 +578,7 @@ def run_mcp_streamable_http_proxy(
572
578
  if _mcp_proxy_tool_is_notification_wait(_mcp_proxy_tool_call_name(payload)):
573
579
  _mcp_proxy_write_json_response(wait_for_proxy_notifications(payload))
574
580
  continue
575
- result, _r = _mcp_proxy_streamable_http_request(endpoint, headers, payload, timeout, protocol_version, current_session_id())
581
+ result, _r = _mcp_proxy_streamable_http_request(endpoint, headers, payload, timeout, active_protocol_version, current_session_id())
576
582
  tool_name = _mcp_proxy_tool_call_name(payload)
577
583
  if isinstance(result, dict):
578
584
  result = _mcp_proxy_compact_tool_result_response(server_name, tool_name, result)
@@ -248,23 +248,34 @@ def probe_streamable_http_mcp_for_channel_capability_detailed(
248
248
  initialized_failed = False
249
249
  session_id: str | None = None
250
250
  try:
251
- response, session_id = http.streamable_post_json(
252
- url,
253
- headers,
254
- codec.initialize_dict(protocol_version),
255
- max(1.0, min(120.0, effective_timeout)),
256
- protocol_version,
257
- )
258
- preview_source = json.dumps(response, ensure_ascii=False) if isinstance(response, (dict, list)) else str(response or "")
259
- bytes_seen = len(preview_source.encode("utf-8", errors="replace"))
260
- if isinstance(response, dict) and response.get("id") == 1 and "result" in response:
261
- response_received = True
262
- capable = codec.capability_present(response)
251
+ # A channel-capable server can occasionally lose a freshly-created
252
+ # session between ``initialize`` and ``notifications/initialized``
253
+ # (for example when a load balancer routes the two requests to
254
+ # different workers). Treating that one handshake failure as a
255
+ # durable negative capability result leaves the cached record false
256
+ # on every later launch, so Ciel never owns the notification stream.
257
+ # Retry the complete handshake once, but only after the server has
258
+ # already advertised the channel capability.
259
+ for handshake_attempt in range(2):
260
+ response, session_id = http.streamable_post_json(
261
+ url,
262
+ headers,
263
+ codec.initialize_dict(protocol_version),
264
+ max(1.0, min(120.0, effective_timeout)),
265
+ protocol_version,
266
+ )
267
+ preview_source = json.dumps(response, ensure_ascii=False) if isinstance(response, (dict, list)) else str(response or "")
268
+ bytes_seen = len(preview_source.encode("utf-8", errors="replace"))
269
+ response_received = bool(
270
+ isinstance(response, dict)
271
+ and response.get("id") == 1
272
+ and "result" in response
273
+ )
274
+ capable = response_received and codec.capability_present(response)
263
275
  if not capable:
264
276
  stdout_preview = codec.decode_preview(preview_source.encode("utf-8"), policy.stdout_preview_bytes)
265
- else:
266
- stdout_preview = codec.decode_preview(preview_source.encode("utf-8"), policy.stdout_preview_bytes)
267
- if response_received:
277
+ if not response_received:
278
+ break
268
279
  try:
269
280
  http.streamable_post_json(
270
281
  url,
@@ -274,10 +285,31 @@ def probe_streamable_http_mcp_for_channel_capability_detailed(
274
285
  protocol_version,
275
286
  session_id,
276
287
  )
288
+ initialized_failed = False
289
+ stderr_preview = ""
290
+ break
277
291
  except Exception as exc:
278
292
  initialized_failed = True
279
293
  capable = False
280
294
  stderr_preview = f"{type(exc).__name__}: {exc}"[:policy.stderr_preview_chars]
295
+ if handshake_attempt != 0 or not codec.capability_present(response):
296
+ break
297
+ services.log(
298
+ "INFO",
299
+ "channel_probe_streamable_http_handshake_retry "
300
+ f"server={server_name} error={type(exc).__name__}: {exc}",
301
+ )
302
+ if session_id:
303
+ http.delete_streamable_session(
304
+ f"probe-{server_name}",
305
+ url,
306
+ headers,
307
+ protocol_version,
308
+ session_id,
309
+ "channel_probe_retry_cleanup",
310
+ timeout=min(10.0, max(1.0, effective_timeout)),
311
+ )
312
+ session_id = None
281
313
  reason = "streamable_http_initialized_post_failed" if initialized_failed else (
282
314
  "capable" if capable else ("no_experimental_claude_channel" if response_received else "no_initialize_response")
283
315
  )
@@ -8,7 +8,7 @@ import urllib.request
8
8
  from typing import Any
9
9
 
10
10
 
11
- MCP_STREAMABLE_HTTP_PROTOCOL_VERSION = "2025-03-26"
11
+ MCP_STREAMABLE_HTTP_PROTOCOL_VERSION = "2025-11-25"
12
12
  MCP_LEGACY_SSE_PROTOCOL_VERSION = "2024-11-05"
13
13
  CODEX_MCP_SPLIT_PROXY_PREFIX = "/ca/codex-mcp/"
14
14
 
@@ -114,6 +114,18 @@ def streamable_post_json(
114
114
  return result, str(returned_session).strip() if returned_session else None
115
115
 
116
116
 
117
+ def negotiated_protocol_version(result: Any, requested: str) -> str:
118
+ """Return the protocol revision selected by a legacy initialize exchange."""
119
+
120
+ if isinstance(result, dict):
121
+ payload = result.get("result")
122
+ if isinstance(payload, dict):
123
+ selected = str(payload.get("protocolVersion") or "").strip()
124
+ if selected:
125
+ return selected
126
+ return str(requested or MCP_STREAMABLE_HTTP_PROTOCOL_VERSION).strip()
127
+
128
+
117
129
  def split_proxy_server_name(path: str, prefix: str = CODEX_MCP_SPLIT_PROXY_PREFIX) -> str | None:
118
130
  if not path.startswith(prefix):
119
131
  return None
@@ -137,6 +149,7 @@ __all__ = [
137
149
  "MCP_LEGACY_SSE_PROTOCOL_VERSION",
138
150
  "MCP_STREAMABLE_HTTP_PROTOCOL_VERSION",
139
151
  "post_json_with_response_headers",
152
+ "negotiated_protocol_version",
140
153
  "read_sse_json_response",
141
154
  "split_proxy_server_name",
142
155
  "sse_post_json",
@@ -0,0 +1,72 @@
1
+ """Recover Anthropic-compatible requests rejected by an upstream context limit."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+
9
+
10
+ @dataclass(frozen=True, slots=True)
11
+ class ContextOverflow:
12
+ context_limit: int
13
+ message_tokens: int
14
+ completion_tokens: int
15
+
16
+
17
+ _OVERFLOW_PATTERN = re.compile(
18
+ r"maximum context length is\s*(?P<limit>[\d,]+)\s*tokens?.*?"
19
+ r"requested\s*(?P<requested>[\d,]+)\s*tokens?\s*\(\s*"
20
+ r"(?P<messages>[\d,]+)\s*in the messages?,\s*"
21
+ r"(?P<completion>[\d,]+)\s*in the completion",
22
+ re.IGNORECASE | re.DOTALL,
23
+ )
24
+
25
+
26
+ def _integer(value: str) -> int:
27
+ try:
28
+ return int(value.replace(",", ""))
29
+ except (TypeError, ValueError):
30
+ return 0
31
+
32
+
33
+ def parse_context_overflow(raw: str | bytes | None) -> ContextOverflow | None:
34
+ text = raw.decode("utf-8", errors="ignore") if isinstance(raw, bytes) else str(raw or "")
35
+ match = _OVERFLOW_PATTERN.search(text)
36
+ if not match:
37
+ return None
38
+ limit = _integer(match.group("limit"))
39
+ messages = _integer(match.group("messages"))
40
+ completion = _integer(match.group("completion"))
41
+ if limit <= 0 or messages <= 0 or completion <= 0:
42
+ return None
43
+ return ContextOverflow(limit, messages, completion)
44
+
45
+
46
+ def recover_output_budget(
47
+ body: dict[str, Any],
48
+ raw: str | bytes | None,
49
+ *,
50
+ reserve_tokens: int = 0,
51
+ minimum_output_tokens: int = 256,
52
+ ) -> dict[str, Any] | None:
53
+ """Reduce only the output reservation when the prompt itself still fits."""
54
+
55
+ overflow = parse_context_overflow(raw)
56
+ if overflow is None:
57
+ return None
58
+ reserve = max(0, int(reserve_tokens or 0))
59
+ minimum = max(1, int(minimum_output_tokens or 1))
60
+ available = overflow.context_limit - overflow.message_tokens - reserve
61
+ if available < minimum:
62
+ return None
63
+ requested = int(body.get("max_tokens") or overflow.completion_tokens)
64
+ adjusted = min(requested, overflow.completion_tokens, available)
65
+ if adjusted >= requested:
66
+ return None
67
+ recovered = dict(body)
68
+ recovered["max_tokens"] = adjusted
69
+ return recovered
70
+
71
+
72
+ __all__ = ["ContextOverflow", "parse_context_overflow", "recover_output_budget"]