@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.
- package/ciel_runtime.py +2555 -9635
- package/ciel_runtime_support/advisor_request_builder.py +8 -21
- package/ciel_runtime_support/anthropic_tool_turns.py +13 -8
- package/ciel_runtime_support/architecture.py +68 -0
- package/ciel_runtime_support/architecture_budget.py +1 -1
- package/ciel_runtime_support/channel_connection_context.py +233 -0
- package/ciel_runtime_support/channel_delivery_context.py +332 -0
- package/ciel_runtime_support/channel_mcp_context.py +313 -0
- package/ciel_runtime_support/channel_mcp_discovery.py +47 -0
- package/ciel_runtime_support/channel_mcp_transport.py +5 -1
- package/ciel_runtime_support/channel_message_context.py +212 -0
- package/ciel_runtime_support/channel_message_repository.py +14 -3
- package/ciel_runtime_support/channel_pending_injection.py +9 -0
- package/ciel_runtime_support/channel_probe_launch_context.py +213 -0
- package/ciel_runtime_support/channel_replay_policy.py +38 -0
- package/ciel_runtime_support/channel_runtime_environment.py +8 -0
- package/ciel_runtime_support/channel_session_context.py +236 -0
- package/ciel_runtime_support/channel_terminal_context.py +350 -0
- package/ciel_runtime_support/channel_wake_context.py +532 -0
- package/ciel_runtime_support/claude_environment.py +60 -0
- package/ciel_runtime_support/claude_launch_assembly.py +249 -0
- package/ciel_runtime_support/claude_router.py +62 -12
- package/ciel_runtime_support/cli_application_context.py +132 -0
- package/ciel_runtime_support/cli_assembly.py +50 -0
- package/ciel_runtime_support/codex_backend_context.py +363 -0
- package/ciel_runtime_support/codex_config.py +13 -1
- package/ciel_runtime_support/codex_launch_assembly.py +213 -0
- package/ciel_runtime_support/codex_launch_configuration.py +30 -1
- package/ciel_runtime_support/codex_mcp_integration.py +90 -8
- package/ciel_runtime_support/codex_model_catalog.py +4 -1
- package/ciel_runtime_support/codex_reasoning_rejects.py +225 -0
- package/ciel_runtime_support/codex_router.py +38 -8
- package/ciel_runtime_support/codex_turn_recovery.py +154 -0
- package/ciel_runtime_support/config_migrations.py +103 -0
- package/ciel_runtime_support/config_repository.py +30 -0
- package/ciel_runtime_support/configuration_cli.py +38 -0
- package/ciel_runtime_support/context_compaction.py +9 -4
- package/ciel_runtime_support/credential_management.py +12 -0
- package/ciel_runtime_support/credentials.py +12 -0
- package/ciel_runtime_support/github_copilot_oauth.py +2 -2
- package/ciel_runtime_support/hosted_formula_tools.py +216 -0
- package/ciel_runtime_support/kimi_runtime_context.py +208 -0
- package/ciel_runtime_support/llm_preset_context.py +338 -0
- package/ciel_runtime_support/managed_mcp_config.py +8 -4
- package/ciel_runtime_support/mcp_configuration_context.py +291 -0
- package/ciel_runtime_support/mcp_http_proxy.py +14 -8
- package/ciel_runtime_support/mcp_probe_transport.py +47 -15
- package/ciel_runtime_support/mcp_transport.py +14 -1
- package/ciel_runtime_support/native_context_recovery.py +72 -0
- package/ciel_runtime_support/ollama_catalog_context.py +213 -0
- package/ciel_runtime_support/ollama_stream_collection.py +103 -0
- package/ciel_runtime_support/ollama_thinking.py +6 -1
- package/ciel_runtime_support/ollama_wire_projection.py +157 -0
- package/ciel_runtime_support/openai_forwarding.py +32 -10
- package/ciel_runtime_support/openai_responses_router.py +12 -0
- package/ciel_runtime_support/package_lifecycle.py +39 -0
- package/ciel_runtime_support/prelaunch_assembly.py +37 -0
- package/ciel_runtime_support/prelaunch_panel_context.py +418 -0
- package/ciel_runtime_support/prelaunch_shell_context.py +394 -0
- package/ciel_runtime_support/prompt_compaction.py +144 -0
- package/ciel_runtime_support/prompt_injection.py +45 -0
- package/ciel_runtime_support/protocols/anthropic_thinking_policy.py +1 -1
- package/ciel_runtime_support/protocols/chat_projection.py +85 -5
- package/ciel_runtime_support/protocols/conversation_turn_policy.py +43 -0
- package/ciel_runtime_support/protocols/ollama_chat.py +31 -0
- package/ciel_runtime_support/protocols/ollama_response.py +57 -5
- package/ciel_runtime_support/protocols/openai_reasoning.py +5 -2
- package/ciel_runtime_support/protocols/openai_responses.py +61 -15
- package/ciel_runtime_support/provider_adapters.py +26 -0
- package/ciel_runtime_support/provider_administration_context.py +207 -0
- package/ciel_runtime_support/provider_config_mutations.py +3 -0
- package/ciel_runtime_support/provider_model_catalog_context.py +137 -0
- package/ciel_runtime_support/provider_model_context.py +107 -0
- package/ciel_runtime_support/provider_model_metadata_context.py +197 -0
- package/ciel_runtime_support/provider_model_selection.py +10 -3
- package/ciel_runtime_support/provider_models.py +45 -2
- package/ciel_runtime_support/provider_option_cli.py +19 -0
- package/ciel_runtime_support/provider_policy.py +1 -1
- package/ciel_runtime_support/provider_readiness_context.py +189 -0
- package/ciel_runtime_support/provider_request_builder.py +64 -28
- package/ciel_runtime_support/provider_responses_passthrough.py +21 -2
- package/ciel_runtime_support/provider_timeout_policy.py +54 -0
- package/ciel_runtime_support/provider_tool_policy.py +9 -1
- package/ciel_runtime_support/providers/__init__.py +6 -0
- package/ciel_runtime_support/providers/alibaba.py +634 -0
- package/ciel_runtime_support/providers/catalog.py +24 -16
- package/ciel_runtime_support/providers/deepseek.py +73 -0
- package/ciel_runtime_support/providers/github_copilot_oauth.py +22 -1
- package/ciel_runtime_support/providers/kimi.py +69 -9
- package/ciel_runtime_support/providers/ollama.py +8 -0
- package/ciel_runtime_support/providers/ollama_context.py +21 -2
- package/ciel_runtime_support/providers/vllm.py +7 -1
- package/ciel_runtime_support/response_collection.py +68 -18
- package/ciel_runtime_support/response_collection_context.py +391 -0
- package/ciel_runtime_support/response_stream_context.py +555 -0
- package/ciel_runtime_support/responses_input_compatibility.py +121 -0
- package/ciel_runtime_support/responses_usage_observer.py +83 -0
- package/ciel_runtime_support/router_client_lifecycle.py +1 -0
- package/ciel_runtime_support/router_http.py +245 -3
- package/ciel_runtime_support/router_observability_context.py +251 -0
- package/ciel_runtime_support/router_process_context.py +200 -0
- package/ciel_runtime_support/router_process_lifecycle.py +2 -0
- package/ciel_runtime_support/router_request_assembly.py +399 -0
- package/ciel_runtime_support/router_request_context.py +215 -0
- package/ciel_runtime_support/router_server_context.py +84 -0
- package/ciel_runtime_support/runaway_output_guard.py +488 -0
- package/ciel_runtime_support/runtime_asset_assembly.py +147 -0
- package/ciel_runtime_support/runtime_asset_context.py +297 -0
- package/ciel_runtime_support/runtime_constants.py +16 -1
- package/ciel_runtime_support/runtime_launch.py +9 -5
- package/ciel_runtime_support/runtime_launch_context.py +130 -0
- package/ciel_runtime_support/runtime_maintenance_assembly.py +60 -0
- package/ciel_runtime_support/runtime_maintenance_context.py +309 -0
- package/ciel_runtime_support/runtime_maintenance_services.py +265 -0
- package/ciel_runtime_support/runtime_paths.py +60 -40
- package/ciel_runtime_support/runtime_primitives.py +78 -0
- package/ciel_runtime_support/speech_http_controller.py +335 -0
- package/ciel_runtime_support/sse_stream_collection.py +236 -0
- package/ciel_runtime_support/statusline_script.py +57 -8
- package/ciel_runtime_support/streaming_anthropic.py +361 -24
- package/ciel_runtime_support/tool_schema.py +40 -2
- package/ciel_runtime_support/tool_side_effect_dedupe.py +117 -12
- package/ciel_runtime_support/upstream_dump.py +68 -0
- package/ciel_runtime_support/upstream_retry_context.py +259 -0
- package/ciel_runtime_support/web_ui.py +248 -1
- package/ciel_runtime_support/workspace_router_selection.py +86 -0
- package/docs/COLAB_SPEECH.md +32 -0
- package/docs/Configuration.md +50 -0
- package/docs/Test-Suite.md +1 -0
- package/package.json +4 -1
- package/scripts/colab/__pycache__/bootstrap_moss_tts.cpython-311.pyc +0 -0
- package/scripts/colab/__pycache__/bootstrap_qwen_asr.cpython-311.pyc +0 -0
- package/scripts/colab/bootstrap_moss_tts.py +106 -0
- package/scripts/colab/bootstrap_qwen_asr.py +106 -0
- package/scripts/configure_speech_workers.py +37 -0
- 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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
266
|
-
|
|
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-
|
|
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"]
|