@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.
- package/README.md +132 -0
- package/ciel-runtime-menu.py +56 -6
- package/ciel_runtime.py +9379 -35909
- package/ciel_runtime_support/advisor_client.py +193 -0
- package/ciel_runtime_support/advisor_policy.py +320 -0
- package/ciel_runtime_support/advisor_refinement.py +160 -0
- package/ciel_runtime_support/advisor_request_builder.py +261 -0
- package/ciel_runtime_support/agy_installer.py +169 -0
- package/ciel_runtime_support/agy_mcp_restore.py +182 -0
- package/ciel_runtime_support/anthropic_model_policy.py +186 -0
- package/ciel_runtime_support/anthropic_response_writer.py +255 -0
- package/ciel_runtime_support/anthropic_tool_turns.py +130 -0
- package/ciel_runtime_support/api_key_cooldown.py +159 -0
- package/ciel_runtime_support/architecture.py +488 -1
- package/ciel_runtime_support/architecture_budget.py +42 -0
- package/ciel_runtime_support/channel_backlog.py +90 -0
- package/ciel_runtime_support/channel_cli.py +119 -0
- package/ciel_runtime_support/channel_compact_injection.py +82 -0
- package/ciel_runtime_support/channel_compact_poll.py +67 -0
- package/ciel_runtime_support/channel_compact_request_repository.py +113 -0
- package/ciel_runtime_support/channel_config_service.py +281 -0
- package/ciel_runtime_support/channel_connection_lifecycle.py +180 -0
- package/ciel_runtime_support/channel_connection_registry.py +128 -0
- package/ciel_runtime_support/channel_connection_worker.py +284 -0
- package/ciel_runtime_support/channel_cursor_recovery.py +92 -0
- package/ciel_runtime_support/channel_cursor_repository.py +89 -0
- package/ciel_runtime_support/channel_cursor_service.py +178 -0
- package/ciel_runtime_support/channel_event_identity.py +212 -0
- package/ciel_runtime_support/channel_event_projection.py +315 -0
- package/ciel_runtime_support/channel_inflight.py +127 -0
- package/ciel_runtime_support/channel_injection.py +115 -0
- package/ciel_runtime_support/channel_launch_guard_repository.py +58 -0
- package/ciel_runtime_support/channel_launch_policy.py +180 -0
- package/ciel_runtime_support/channel_llm_context.py +156 -0
- package/ciel_runtime_support/channel_mcp_discovery.py +186 -0
- package/ciel_runtime_support/channel_mcp_http_controller.py +239 -0
- package/ciel_runtime_support/channel_mcp_ownership.py +148 -0
- package/ciel_runtime_support/channel_mcp_tools.py +240 -0
- package/ciel_runtime_support/channel_mcp_transport.py +394 -0
- package/ciel_runtime_support/channel_message_dedupe.py +65 -0
- package/ciel_runtime_support/channel_message_policy.py +256 -0
- package/ciel_runtime_support/channel_message_prompt.py +305 -0
- package/ciel_runtime_support/channel_message_repository.py +234 -0
- package/ciel_runtime_support/channel_notification_projection.py +217 -0
- package/ciel_runtime_support/channel_panel.py +162 -0
- package/ciel_runtime_support/channel_pending_injection.py +209 -0
- package/ciel_runtime_support/channel_pending_poll.py +109 -0
- package/ciel_runtime_support/channel_probe_cache.py +433 -0
- package/ciel_runtime_support/channel_probe_report.py +101 -0
- package/ciel_runtime_support/channel_runtime_environment.py +181 -0
- package/ciel_runtime_support/channel_session_lifecycle.py +114 -0
- package/ciel_runtime_support/channel_session_repository.py +90 -0
- package/ciel_runtime_support/channel_terminal_dispatch.py +115 -0
- package/ciel_runtime_support/channel_terminal_input.py +277 -0
- package/ciel_runtime_support/channel_terminal_proxy.py +447 -0
- package/ciel_runtime_support/channel_tool_context.py +166 -0
- package/ciel_runtime_support/channel_transcript.py +414 -0
- package/ciel_runtime_support/channel_transcript_repository.py +96 -0
- package/ciel_runtime_support/channel_wake_claim_repository.py +126 -0
- package/ciel_runtime_support/channel_wake_delivery_repository.py +88 -0
- package/ciel_runtime_support/chat_files.py +138 -0
- package/ciel_runtime_support/chat_http_controller.py +235 -0
- package/ciel_runtime_support/claude_environment.py +375 -0
- package/ciel_runtime_support/claude_router.py +247 -193
- package/ciel_runtime_support/cli_dispatch.py +792 -0
- package/ciel_runtime_support/cli_parser.py +165 -0
- package/ciel_runtime_support/cli_usage.py +100 -0
- package/ciel_runtime_support/codex_app_server.py +20 -5
- package/ciel_runtime_support/codex_channel_sse_launch.py +87 -0
- package/ciel_runtime_support/codex_cli.py +42 -6
- package/ciel_runtime_support/codex_config.py +323 -0
- package/ciel_runtime_support/codex_launch_configuration.py +240 -0
- package/ciel_runtime_support/codex_launch_policy.py +66 -0
- package/ciel_runtime_support/codex_mcp_integration.py +195 -0
- package/ciel_runtime_support/codex_mcp_restore.py +304 -0
- package/ciel_runtime_support/codex_model_catalog.py +133 -0
- package/ciel_runtime_support/codex_process_lifecycle.py +271 -0
- package/ciel_runtime_support/codex_router.py +147 -1
- package/ciel_runtime_support/codex_session_repository.py +115 -0
- package/ciel_runtime_support/codex_session_selection.py +114 -0
- package/ciel_runtime_support/command_asset_installer.py +103 -0
- package/ciel_runtime_support/compatibility_probe.py +295 -0
- package/ciel_runtime_support/compatibility_protocol.py +251 -0
- package/ciel_runtime_support/compatibility_runtime.py +166 -0
- package/ciel_runtime_support/compatibility_test.py +370 -0
- package/ciel_runtime_support/config_migrations.py +307 -0
- package/ciel_runtime_support/config_repository.py +175 -0
- package/ciel_runtime_support/config_value_codec.py +64 -0
- package/ciel_runtime_support/configuration_cli.py +374 -0
- package/ciel_runtime_support/context_compaction.py +280 -0
- package/ciel_runtime_support/context_setup.py +208 -0
- package/ciel_runtime_support/context_summary_policy.py +392 -0
- package/ciel_runtime_support/credential_cli.py +104 -0
- package/ciel_runtime_support/credential_management.py +261 -0
- package/ciel_runtime_support/credentials.py +269 -0
- package/ciel_runtime_support/executable_discovery.py +141 -0
- package/ciel_runtime_support/github_copilot_oauth.py +335 -0
- package/ciel_runtime_support/github_copilot_oauth_runtime.py +213 -0
- package/ciel_runtime_support/header_forwarding.py +73 -0
- package/ciel_runtime_support/headless_config.py +221 -0
- package/ciel_runtime_support/http_response.py +129 -0
- package/ciel_runtime_support/install_diagnostics.py +149 -0
- package/ciel_runtime_support/kimi_identity.py +123 -0
- package/ciel_runtime_support/launch_diagnostics.py +204 -0
- package/ciel_runtime_support/launch_state.py +127 -0
- package/ciel_runtime_support/live_api_key_controller.py +58 -0
- package/ciel_runtime_support/llm_config_http.py +148 -0
- package/ciel_runtime_support/llm_option_config.py +259 -0
- package/ciel_runtime_support/llm_presentation_data.py +447 -0
- package/ciel_runtime_support/llm_presets.py +773 -0
- package/ciel_runtime_support/lm_studio_runtime.py +401 -0
- package/ciel_runtime_support/managed_mcp_config.py +144 -0
- package/ciel_runtime_support/managed_mcp_discovery.py +151 -0
- package/ciel_runtime_support/managed_service_cleanup.py +89 -0
- package/ciel_runtime_support/mcp_config_reader.py +230 -0
- package/ciel_runtime_support/mcp_http_proxy.py +607 -0
- package/ciel_runtime_support/mcp_inventory.py +59 -0
- package/ciel_runtime_support/mcp_notification_wait_policy.py +159 -0
- package/ciel_runtime_support/mcp_probe_codec.py +136 -0
- package/ciel_runtime_support/mcp_probe_transport.py +328 -0
- package/ciel_runtime_support/mcp_proxy_codec.py +345 -0
- package/ciel_runtime_support/mcp_proxy_config.py +107 -0
- package/ciel_runtime_support/mcp_proxy_notifications.py +261 -0
- package/ciel_runtime_support/mcp_proxy_process.py +560 -0
- package/ciel_runtime_support/mcp_split_proxy_http.py +165 -0
- package/ciel_runtime_support/mcp_stdio_probe.py +240 -0
- package/ciel_runtime_support/mcp_transport.py +146 -0
- package/ciel_runtime_support/model_cache_lifecycle.py +78 -0
- package/ciel_runtime_support/model_catalog_projection.py +61 -0
- package/ciel_runtime_support/model_context_hints.py +109 -0
- package/ciel_runtime_support/model_panel.py +147 -0
- package/ciel_runtime_support/model_registry_repository.py +231 -0
- package/ciel_runtime_support/npm_runtime.py +191 -0
- package/ciel_runtime_support/ollama_catalog.py +462 -0
- package/ciel_runtime_support/ollama_catalog_cli.py +41 -0
- package/ciel_runtime_support/ollama_catalog_repository.py +75 -0
- package/ciel_runtime_support/ollama_context_sync.py +87 -0
- package/ciel_runtime_support/ollama_forwarding.py +449 -0
- package/ciel_runtime_support/openai_chat_passthrough.py +67 -0
- package/ciel_runtime_support/openai_chat_router.py +64 -0
- package/ciel_runtime_support/openai_forwarding.py +194 -0
- package/ciel_runtime_support/openai_responses_router.py +291 -0
- package/ciel_runtime_support/openai_responses_stream.py +135 -0
- package/ciel_runtime_support/output_budget.py +89 -0
- package/ciel_runtime_support/package_lifecycle.py +217 -0
- package/ciel_runtime_support/plan_artifact_controller.py +104 -0
- package/ciel_runtime_support/prelaunch.py +959 -0
- package/ciel_runtime_support/prelaunch_launch_preference.py +75 -0
- package/ciel_runtime_support/prelaunch_panel_projection.py +396 -0
- package/ciel_runtime_support/prelaunch_terminal.py +764 -0
- package/ciel_runtime_support/process_control.py +708 -0
- package/ciel_runtime_support/prompt_compaction.py +322 -0
- package/ciel_runtime_support/prompt_injection.py +176 -0
- package/ciel_runtime_support/protocols/__init__.py +24 -0
- package/ciel_runtime_support/protocols/anthropic_content.py +29 -0
- package/ciel_runtime_support/protocols/anthropic_thinking_policy.py +336 -0
- package/ciel_runtime_support/protocols/chat_projection.py +315 -0
- package/ciel_runtime_support/protocols/conversation_policy.py +217 -0
- package/ciel_runtime_support/protocols/conversation_turn_policy.py +972 -0
- package/ciel_runtime_support/protocols/ollama_chat.py +111 -0
- package/ciel_runtime_support/protocols/ollama_response.py +231 -0
- package/ciel_runtime_support/protocols/openai_reasoning.py +75 -0
- package/ciel_runtime_support/protocols/openai_responses.py +271 -0
- package/ciel_runtime_support/protocols/pseudo_tool_history.py +248 -0
- package/ciel_runtime_support/protocols/tool_result_projection.py +112 -0
- package/ciel_runtime_support/provider_adapters.py +160 -0
- package/ciel_runtime_support/provider_catalog_sources.py +316 -0
- package/ciel_runtime_support/provider_choice.py +201 -0
- package/ciel_runtime_support/provider_compatibility.py +165 -0
- package/ciel_runtime_support/provider_config_mutations.py +361 -0
- package/ciel_runtime_support/provider_configuration_service.py +162 -0
- package/ciel_runtime_support/provider_context.py +308 -0
- package/ciel_runtime_support/provider_contract_projection.py +73 -0
- package/ciel_runtime_support/provider_descriptor.py +82 -0
- package/ciel_runtime_support/provider_endpoint_policy.py +130 -0
- package/ciel_runtime_support/provider_endpoint_probe.py +165 -0
- package/ciel_runtime_support/provider_launch_endpoint.py +109 -0
- package/ciel_runtime_support/provider_limits.py +457 -0
- package/ciel_runtime_support/provider_model_identity.py +139 -0
- package/ciel_runtime_support/provider_model_selection.py +431 -0
- package/ciel_runtime_support/provider_model_specs.py +142 -0
- package/ciel_runtime_support/provider_models.py +263 -0
- package/ciel_runtime_support/provider_network.py +176 -0
- package/ciel_runtime_support/provider_option_cli.py +238 -0
- package/ciel_runtime_support/provider_option_panel.py +275 -0
- package/ciel_runtime_support/provider_option_status.py +192 -0
- package/ciel_runtime_support/provider_policy.py +101 -0
- package/ciel_runtime_support/provider_query_policy.py +67 -0
- package/ciel_runtime_support/provider_readiness.py +112 -0
- package/ciel_runtime_support/provider_request_access.py +131 -0
- package/ciel_runtime_support/provider_request_builder.py +250 -0
- package/ciel_runtime_support/provider_responses_passthrough.py +81 -0
- package/ciel_runtime_support/provider_runtime_info.py +113 -0
- package/ciel_runtime_support/provider_runtime_modes.py +150 -0
- package/ciel_runtime_support/provider_sampling_policy.py +46 -0
- package/ciel_runtime_support/provider_status.py +145 -0
- package/ciel_runtime_support/provider_timeout_policy.py +184 -0
- package/ciel_runtime_support/provider_tool_policy.py +145 -0
- package/ciel_runtime_support/providers/__init__.py +65 -0
- package/ciel_runtime_support/providers/anthropic.py +160 -0
- package/ciel_runtime_support/providers/anthropic_catalog.py +119 -0
- package/ciel_runtime_support/providers/base.py +232 -0
- package/ciel_runtime_support/providers/catalog.py +326 -0
- package/ciel_runtime_support/providers/cloud.py +194 -0
- package/ciel_runtime_support/providers/constants.py +54 -0
- package/ciel_runtime_support/providers/deepseek.py +115 -0
- package/ciel_runtime_support/providers/fireworks.py +158 -0
- package/ciel_runtime_support/providers/github_copilot_oauth.py +199 -0
- package/ciel_runtime_support/providers/kimi.py +297 -0
- package/ciel_runtime_support/providers/lm_studio.py +76 -0
- package/ciel_runtime_support/providers/meta.py +257 -0
- package/ciel_runtime_support/providers/native.py +194 -0
- package/ciel_runtime_support/providers/nim.py +69 -0
- package/ciel_runtime_support/providers/nvidia.py +158 -0
- package/ciel_runtime_support/providers/nvidia_runtime.py +285 -0
- package/ciel_runtime_support/providers/ollama.py +181 -0
- package/ciel_runtime_support/providers/ollama_context.py +195 -0
- package/ciel_runtime_support/providers/ollama_runtime.py +213 -0
- package/ciel_runtime_support/providers/opencode.py +220 -0
- package/ciel_runtime_support/providers/opencode_go.py +37 -0
- package/ciel_runtime_support/providers/openrouter.py +57 -0
- package/ciel_runtime_support/providers/vllm.py +65 -0
- package/ciel_runtime_support/providers/zai.py +119 -0
- package/ciel_runtime_support/pseudo_tool_parser.py +115 -0
- package/ciel_runtime_support/rate_limit_policy.py +117 -0
- package/ciel_runtime_support/rate_limit_repository.py +154 -0
- package/ciel_runtime_support/registry.py +46 -0
- package/ciel_runtime_support/request_shortcuts.py +253 -0
- package/ciel_runtime_support/request_trace.py +323 -0
- package/ciel_runtime_support/response_collection.py +209 -0
- package/ciel_runtime_support/router_access.py +238 -0
- package/ciel_runtime_support/router_client_lifecycle.py +366 -0
- package/ciel_runtime_support/router_health_policy.py +101 -0
- package/ciel_runtime_support/router_http.py +513 -0
- package/ciel_runtime_support/router_process_lifecycle.py +401 -0
- package/ciel_runtime_support/router_rate_limit_service.py +285 -0
- package/ciel_runtime_support/router_server_runtime.py +103 -0
- package/ciel_runtime_support/router_shortcuts.py +201 -0
- package/ciel_runtime_support/routing_fallback.py +73 -0
- package/ciel_runtime_support/runtime_activity_repository.py +143 -0
- package/ciel_runtime_support/runtime_adapters.py +104 -0
- package/ciel_runtime_support/runtime_command_factory.py +73 -0
- package/ciel_runtime_support/runtime_compatibility.py +50 -0
- package/ciel_runtime_support/runtime_constants.py +178 -0
- package/ciel_runtime_support/runtime_launch.py +1602 -0
- package/ciel_runtime_support/runtime_llm_options.py +312 -0
- package/ciel_runtime_support/runtime_logging.py +161 -0
- package/ciel_runtime_support/runtime_paths.py +157 -0
- package/ciel_runtime_support/runtime_restart.py +84 -0
- package/ciel_runtime_support/runtime_upgrade.py +149 -0
- package/ciel_runtime_support/secure_json_repository.py +55 -0
- package/ciel_runtime_support/session_import.py +356 -0
- package/ciel_runtime_support/settings_repository.py +8 -0
- package/ciel_runtime_support/slash_command_assets.py +211 -0
- package/ciel_runtime_support/sse_stream.py +57 -0
- package/ciel_runtime_support/sse_trace.py +225 -0
- package/ciel_runtime_support/statusline_script.py +593 -0
- package/ciel_runtime_support/statusline_settings.py +53 -0
- package/ciel_runtime_support/stream_chunk_policy.py +18 -0
- package/ciel_runtime_support/streaming_anthropic.py +1955 -0
- package/ciel_runtime_support/synthetic_tool_policy.py +105 -0
- package/ciel_runtime_support/terminal_platform_io.py +127 -0
- package/ciel_runtime_support/timeout_profile.py +196 -0
- package/ciel_runtime_support/tool_dialects.py +85 -0
- package/ciel_runtime_support/tool_exposure_policy.py +63 -0
- package/ciel_runtime_support/tool_guard_hooks.py +218 -0
- package/ciel_runtime_support/tool_request_projection.py +96 -0
- package/ciel_runtime_support/tool_schema.py +483 -0
- package/ciel_runtime_support/tool_side_effect_dedupe.py +95 -0
- package/ciel_runtime_support/ui_text.py +266 -0
- package/ciel_runtime_support/upstream_error_policy.py +104 -0
- package/ciel_runtime_support/upstream_retry.py +419 -0
- package/ciel_runtime_support/upstream_stream_io.py +106 -0
- package/ciel_runtime_support/usage_events.py +96 -0
- package/ciel_runtime_support/visible_stream_filters.py +130 -0
- package/ciel_runtime_support/web_endpoints.py +447 -0
- package/ciel_runtime_support/web_ui.py +915 -0
- package/ciel_runtime_support/web_ui_controller.py +189 -0
- package/ciel_runtime_support/windows_console_input.py +137 -0
- package/ciel_runtime_support/windows_console_mode.py +112 -0
- package/docs/Architecture.md +54 -0
- package/docs/Configuration.md +17 -0
- package/docs/Module-Map.md +1093 -26
- package/docs/Providers.md +46 -1
- package/docs/adr/0001-runtime-bounded-contexts.md +295 -0
- package/npm-bin/run-ciel-runtime.js +22 -2
- 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
|