@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,401 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any, Callable
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class LmStudioRuntimeServices:
|
|
9
|
+
api_base: Callable[[dict[str, Any]], str | None]
|
|
10
|
+
current_model: Callable[[str, dict[str, Any]], str]
|
|
11
|
+
http_json: Callable[..., Any]
|
|
12
|
+
join_url: Callable[[str, str], str]
|
|
13
|
+
model_list_headers: Callable[[str, dict[str, Any]], dict[str, str]]
|
|
14
|
+
model_id_matches: Callable[[str, str], bool]
|
|
15
|
+
positive_int: Callable[[Any], int | None]
|
|
16
|
+
model_context: Callable[[dict[str, Any]], int | None]
|
|
17
|
+
log: Callable[[str, str], None]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True, slots=True)
|
|
21
|
+
class LmStudioLifecyclePolicy:
|
|
22
|
+
recommended_preset: Callable[[str, dict[str, Any]], str]
|
|
23
|
+
required_context: Callable[[str, str], int]
|
|
24
|
+
model_context_hint: Callable[[str], int | None]
|
|
25
|
+
default_context: int
|
|
26
|
+
minimum_context: int
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class LmStudioModelLifecycle:
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
runtime: LmStudioRuntimeServices,
|
|
33
|
+
policy: LmStudioLifecyclePolicy,
|
|
34
|
+
post_json: Callable[..., Any],
|
|
35
|
+
) -> None:
|
|
36
|
+
self.runtime = runtime
|
|
37
|
+
self.policy = policy
|
|
38
|
+
self.post_json = post_json
|
|
39
|
+
|
|
40
|
+
def v1_model_info(
|
|
41
|
+
self, config: dict[str, Any], timeout: float = 3.0
|
|
42
|
+
) -> dict[str, Any] | None:
|
|
43
|
+
base = self.runtime.api_base(config)
|
|
44
|
+
current = self.runtime.current_model("lm-studio", config)
|
|
45
|
+
if not base or not current:
|
|
46
|
+
return None
|
|
47
|
+
data = self.runtime.http_json(
|
|
48
|
+
self.runtime.join_url(base, "/api/v1/models"),
|
|
49
|
+
headers=self.runtime.model_list_headers("lm-studio", config),
|
|
50
|
+
timeout=timeout,
|
|
51
|
+
)
|
|
52
|
+
items = data.get("models") if isinstance(data, dict) else None
|
|
53
|
+
if not isinstance(items, list):
|
|
54
|
+
return None
|
|
55
|
+
return next(
|
|
56
|
+
(
|
|
57
|
+
item
|
|
58
|
+
for item in items
|
|
59
|
+
if isinstance(item, dict)
|
|
60
|
+
and self.runtime.model_id_matches(str(item.get("key") or ""), current)
|
|
61
|
+
),
|
|
62
|
+
None,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
def loaded_instance_ids(
|
|
66
|
+
self, config: dict[str, Any], timeout: float = 3.0
|
|
67
|
+
) -> list[str]:
|
|
68
|
+
try:
|
|
69
|
+
item = self.v1_model_info(config, timeout)
|
|
70
|
+
except Exception:
|
|
71
|
+
return []
|
|
72
|
+
instances = item.get("loaded_instances") if isinstance(item, dict) else None
|
|
73
|
+
if not isinstance(instances, list):
|
|
74
|
+
return []
|
|
75
|
+
return [
|
|
76
|
+
str(instance["id"])
|
|
77
|
+
for instance in instances
|
|
78
|
+
if isinstance(instance, dict) and instance.get("id")
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
def target_context(
|
|
82
|
+
self, config: dict[str, Any], info: dict[str, Any] | None = None
|
|
83
|
+
) -> int | None:
|
|
84
|
+
target = self.runtime.positive_int(config.get("context_window"))
|
|
85
|
+
if not target:
|
|
86
|
+
preset = self.policy.recommended_preset("lm-studio", config)
|
|
87
|
+
target = self.policy.required_context(preset, "lm-studio")
|
|
88
|
+
target = target or self.policy.default_context
|
|
89
|
+
max_length = self.runtime.positive_int(
|
|
90
|
+
(info or {}).get("max_model_len")
|
|
91
|
+
) or self.policy.model_context_hint(str(config.get("current_model") or ""))
|
|
92
|
+
return min(target, max_length) if max_length else target
|
|
93
|
+
|
|
94
|
+
def load_timeout_seconds(self, config: dict[str, Any]) -> float:
|
|
95
|
+
configured = self.runtime.positive_int(config.get("request_timeout_ms"))
|
|
96
|
+
return max(60.0, min(600.0, configured / 1000.0)) if configured else 300.0
|
|
97
|
+
|
|
98
|
+
def load_model(
|
|
99
|
+
self,
|
|
100
|
+
config: dict[str, Any],
|
|
101
|
+
context_length: int,
|
|
102
|
+
timeout: float | None = None,
|
|
103
|
+
) -> dict[str, Any]:
|
|
104
|
+
base = self.runtime.api_base(config)
|
|
105
|
+
model = self.runtime.current_model("lm-studio", config)
|
|
106
|
+
if not base or not model:
|
|
107
|
+
raise RuntimeError("LM Studio model or base URL is not configured")
|
|
108
|
+
return self.post_json(
|
|
109
|
+
self.runtime.join_url(base, "/api/v1/models/load"),
|
|
110
|
+
{
|
|
111
|
+
"model": model,
|
|
112
|
+
"context_length": context_length,
|
|
113
|
+
"flash_attention": True,
|
|
114
|
+
"echo_load_config": True,
|
|
115
|
+
},
|
|
116
|
+
headers=self.runtime.model_list_headers("lm-studio", config),
|
|
117
|
+
timeout=timeout or self.load_timeout_seconds(config),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
def unload_loaded_instances(
|
|
121
|
+
self, config: dict[str, Any], timeout: float = 20.0
|
|
122
|
+
) -> list[str]:
|
|
123
|
+
base = self.runtime.api_base(config)
|
|
124
|
+
if not base:
|
|
125
|
+
return []
|
|
126
|
+
unloaded: list[str] = []
|
|
127
|
+
for instance_id in self.loaded_instance_ids(config):
|
|
128
|
+
try:
|
|
129
|
+
self.post_json(
|
|
130
|
+
self.runtime.join_url(base, "/api/v1/models/unload"),
|
|
131
|
+
{"instance_id": instance_id},
|
|
132
|
+
headers=self.runtime.model_list_headers("lm-studio", config),
|
|
133
|
+
timeout=timeout,
|
|
134
|
+
)
|
|
135
|
+
unloaded.append(instance_id)
|
|
136
|
+
except Exception:
|
|
137
|
+
continue
|
|
138
|
+
return unloaded
|
|
139
|
+
|
|
140
|
+
def load_response_context(self, response: dict[str, Any], fallback: int) -> int:
|
|
141
|
+
load_config = response.get("load_config") if isinstance(response, dict) else None
|
|
142
|
+
if isinstance(load_config, dict):
|
|
143
|
+
value = self.runtime.positive_int(load_config.get("context_length"))
|
|
144
|
+
if value:
|
|
145
|
+
return value
|
|
146
|
+
return fallback
|
|
147
|
+
|
|
148
|
+
def ensure_loaded_context(
|
|
149
|
+
self, config: dict[str, Any], timeout: float = 3.0
|
|
150
|
+
) -> list[str]:
|
|
151
|
+
info = discover_lm_studio_runtime(config, self.runtime, timeout=timeout)
|
|
152
|
+
target = self.target_context(config, info)
|
|
153
|
+
if not target:
|
|
154
|
+
return []
|
|
155
|
+
messages: list[str] = []
|
|
156
|
+
loaded = self.runtime.positive_int((info or {}).get("loaded_context_len"))
|
|
157
|
+
state = str((info or {}).get("state") or "")
|
|
158
|
+
max_length = self.runtime.positive_int((info or {}).get("max_model_len"))
|
|
159
|
+
if max_length:
|
|
160
|
+
messages.append(f"LM Studio model max context: {max_length:,} tokens.")
|
|
161
|
+
if loaded:
|
|
162
|
+
messages.append(f"LM Studio loaded context: {loaded:,} tokens.")
|
|
163
|
+
if loaded and state == "loaded" and loaded >= target:
|
|
164
|
+
config["native_compat"] = True
|
|
165
|
+
if not self.runtime.positive_int(config.get("context_window")):
|
|
166
|
+
config["context_window"] = min(loaded, target)
|
|
167
|
+
return messages
|
|
168
|
+
if max_length and max_length < self.policy.minimum_context:
|
|
169
|
+
config["native_compat"] = False
|
|
170
|
+
config["context_window"] = max_length
|
|
171
|
+
messages.append(
|
|
172
|
+
"LM Studio selected model cannot provide enough context for Claude Code "
|
|
173
|
+
f"({max_length:,} < {self.policy.minimum_context:,})."
|
|
174
|
+
)
|
|
175
|
+
return messages
|
|
176
|
+
action = "reloading" if loaded else "loading"
|
|
177
|
+
messages.append(
|
|
178
|
+
f"LM Studio auto-{action} selected model with {target:,} context tokens."
|
|
179
|
+
)
|
|
180
|
+
try:
|
|
181
|
+
response = self.load_model(config, target)
|
|
182
|
+
except Exception:
|
|
183
|
+
if not loaded:
|
|
184
|
+
raise
|
|
185
|
+
self.unload_loaded_instances(config)
|
|
186
|
+
response = self.load_model(config, target)
|
|
187
|
+
actual = self.load_response_context(response, target)
|
|
188
|
+
config["context_window"] = actual
|
|
189
|
+
if actual >= self.policy.minimum_context:
|
|
190
|
+
config["native_compat"] = True
|
|
191
|
+
messages.append(
|
|
192
|
+
f"LM Studio loaded selected model with {actual:,} context tokens."
|
|
193
|
+
)
|
|
194
|
+
else:
|
|
195
|
+
config["native_compat"] = False
|
|
196
|
+
current_output = self.runtime.positive_int(config.get("max_output_tokens")) or 4096
|
|
197
|
+
config["max_output_tokens"] = min(current_output, max(512, actual // 4))
|
|
198
|
+
messages.append(
|
|
199
|
+
"LM Studio loaded the model, but the applied context is still too small "
|
|
200
|
+
f"for Claude Code ({actual:,} < {self.policy.minimum_context:,})."
|
|
201
|
+
)
|
|
202
|
+
return messages
|
|
203
|
+
|
|
204
|
+
def context_guard(
|
|
205
|
+
self,
|
|
206
|
+
config: dict[str, Any],
|
|
207
|
+
*,
|
|
208
|
+
load: bool = False,
|
|
209
|
+
) -> list[str]:
|
|
210
|
+
if load:
|
|
211
|
+
try:
|
|
212
|
+
return self.ensure_loaded_context(config, timeout=1.5)
|
|
213
|
+
except Exception as exc:
|
|
214
|
+
config["native_compat"] = False
|
|
215
|
+
return [
|
|
216
|
+
"LM Studio could not automatically load the selected model with the recommended context.",
|
|
217
|
+
f"LM Studio load error: {type(exc).__name__}: {exc}",
|
|
218
|
+
]
|
|
219
|
+
|
|
220
|
+
info: dict[str, Any] | None = None
|
|
221
|
+
target = self.target_context(config, info)
|
|
222
|
+
loaded = self.runtime.positive_int((info or {}).get("loaded_context_len"))
|
|
223
|
+
state = str((info or {}).get("state") or "")
|
|
224
|
+
max_length = self.runtime.positive_int((info or {}).get("max_model_len"))
|
|
225
|
+
messages: list[str] = []
|
|
226
|
+
if max_length:
|
|
227
|
+
messages.append(f"LM Studio model max context: {max_length:,} tokens.")
|
|
228
|
+
if target:
|
|
229
|
+
messages.append(f"LM Studio target context: {target:,} tokens.")
|
|
230
|
+
if max_length and max_length < self.policy.minimum_context:
|
|
231
|
+
config["native_compat"] = False
|
|
232
|
+
config["context_window"] = max_length
|
|
233
|
+
messages.append(
|
|
234
|
+
"LM Studio selected model cannot provide enough context for Claude Code "
|
|
235
|
+
f"({max_length:,} < {self.policy.minimum_context:,})."
|
|
236
|
+
)
|
|
237
|
+
return messages
|
|
238
|
+
config["native_compat"] = True
|
|
239
|
+
if loaded:
|
|
240
|
+
messages.append(
|
|
241
|
+
f"LM Studio currently loaded context: {loaded:,} tokens."
|
|
242
|
+
)
|
|
243
|
+
if target and loaded < target:
|
|
244
|
+
messages.append(
|
|
245
|
+
"LM Studio will reload this model with the target context when you launch or test."
|
|
246
|
+
)
|
|
247
|
+
elif state and state != "loaded":
|
|
248
|
+
messages.append(
|
|
249
|
+
"LM Studio will load this model with the target context when you launch or test."
|
|
250
|
+
)
|
|
251
|
+
elif target:
|
|
252
|
+
messages.append(
|
|
253
|
+
"LM Studio will prepare this model with the target context when you launch or test."
|
|
254
|
+
)
|
|
255
|
+
return messages
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
@dataclass(frozen=True, slots=True)
|
|
259
|
+
class LmStudioLifecycleApi:
|
|
260
|
+
"""Explicit public adapter for late-bound LM Studio lifecycle services."""
|
|
261
|
+
|
|
262
|
+
lifecycle_factory: Callable[[], LmStudioModelLifecycle]
|
|
263
|
+
|
|
264
|
+
def v1_model_info(self, pcfg: dict[str, Any], timeout: float = 3.0) -> dict[str, Any] | None:
|
|
265
|
+
return self.lifecycle_factory().v1_model_info(pcfg, timeout)
|
|
266
|
+
|
|
267
|
+
def loaded_instance_ids(self, pcfg: dict[str, Any], timeout: float = 3.0) -> list[str]:
|
|
268
|
+
return self.lifecycle_factory().loaded_instance_ids(pcfg, timeout)
|
|
269
|
+
|
|
270
|
+
def target_context(self, pcfg: dict[str, Any], info: dict[str, Any] | None = None) -> int | None:
|
|
271
|
+
return self.lifecycle_factory().target_context(pcfg, info)
|
|
272
|
+
|
|
273
|
+
def load_timeout_seconds(self, pcfg: dict[str, Any]) -> float:
|
|
274
|
+
return self.lifecycle_factory().load_timeout_seconds(pcfg)
|
|
275
|
+
|
|
276
|
+
def load_model(self, pcfg: dict[str, Any], context_length: int, timeout: float | None = None) -> dict[str, Any]:
|
|
277
|
+
return self.lifecycle_factory().load_model(pcfg, context_length, timeout)
|
|
278
|
+
|
|
279
|
+
def unload_loaded_instances(self, pcfg: dict[str, Any], timeout: float = 20.0) -> list[str]:
|
|
280
|
+
return self.lifecycle_factory().unload_loaded_instances(pcfg, timeout)
|
|
281
|
+
|
|
282
|
+
def load_response_context(self, response: dict[str, Any], fallback: int) -> int:
|
|
283
|
+
return self.lifecycle_factory().load_response_context(response, fallback)
|
|
284
|
+
|
|
285
|
+
def ensure_loaded_for_context(self, pcfg: dict[str, Any], timeout: float = 3.0) -> list[str]:
|
|
286
|
+
return self.lifecycle_factory().ensure_loaded_context(pcfg, timeout)
|
|
287
|
+
|
|
288
|
+
def apply_loaded_context_guard(self, pcfg: dict[str, Any], load: bool = False) -> list[str]:
|
|
289
|
+
return self.lifecycle_factory().context_guard(pcfg, load=load)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def discover_lm_studio_runtime(
|
|
293
|
+
provider_config: dict[str, Any],
|
|
294
|
+
services: LmStudioRuntimeServices,
|
|
295
|
+
*,
|
|
296
|
+
timeout: float = 3.0,
|
|
297
|
+
) -> dict[str, Any] | None:
|
|
298
|
+
base = services.api_base(provider_config)
|
|
299
|
+
if not base:
|
|
300
|
+
return None
|
|
301
|
+
current = services.current_model("lm-studio", provider_config)
|
|
302
|
+
headers = services.model_list_headers("lm-studio", provider_config)
|
|
303
|
+
v0_url = services.join_url(base, "/api/v0/models")
|
|
304
|
+
try:
|
|
305
|
+
data = services.http_json(v0_url, headers=headers, timeout=timeout)
|
|
306
|
+
runtime = _runtime_from_v0(data, v0_url, current, services)
|
|
307
|
+
if runtime is not None:
|
|
308
|
+
return runtime
|
|
309
|
+
except Exception as exc:
|
|
310
|
+
services.log(
|
|
311
|
+
"WARN",
|
|
312
|
+
f"lm_studio_runtime_discovery_failed api=v0 error={type(exc).__name__}: {exc}",
|
|
313
|
+
)
|
|
314
|
+
v1_url = services.join_url(base, "/api/v1/models")
|
|
315
|
+
try:
|
|
316
|
+
data = services.http_json(v1_url, headers=headers, timeout=timeout)
|
|
317
|
+
return _runtime_from_v1(data, v1_url, current, services)
|
|
318
|
+
except Exception as exc:
|
|
319
|
+
services.log(
|
|
320
|
+
"WARN",
|
|
321
|
+
f"lm_studio_runtime_discovery_failed api=v1 error={type(exc).__name__}: {exc}",
|
|
322
|
+
)
|
|
323
|
+
return None
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _select_model(
|
|
327
|
+
items: Any,
|
|
328
|
+
current: str,
|
|
329
|
+
id_key: str,
|
|
330
|
+
matches: Callable[[str, str], bool],
|
|
331
|
+
) -> dict[str, Any] | None:
|
|
332
|
+
if not isinstance(items, list):
|
|
333
|
+
return None
|
|
334
|
+
fallback: dict[str, Any] | None = None
|
|
335
|
+
for item in items:
|
|
336
|
+
if not isinstance(item, dict):
|
|
337
|
+
continue
|
|
338
|
+
if fallback is None:
|
|
339
|
+
fallback = item
|
|
340
|
+
if matches(str(item.get(id_key) or ""), current):
|
|
341
|
+
return item
|
|
342
|
+
return fallback if not current else None
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _runtime_from_v0(
|
|
346
|
+
data: Any,
|
|
347
|
+
models_url: str,
|
|
348
|
+
current: str,
|
|
349
|
+
services: LmStudioRuntimeServices,
|
|
350
|
+
) -> dict[str, Any] | None:
|
|
351
|
+
items = data.get("data") if isinstance(data, dict) else None
|
|
352
|
+
selected = _select_model(items, current, "id", services.model_id_matches)
|
|
353
|
+
if selected is None:
|
|
354
|
+
return None
|
|
355
|
+
return {
|
|
356
|
+
"models_url": models_url,
|
|
357
|
+
"requested_model": current,
|
|
358
|
+
"runtime_model": str(selected.get("id") or ""),
|
|
359
|
+
"max_model_len": services.positive_int(selected.get("max_context_length"))
|
|
360
|
+
or services.model_context(selected),
|
|
361
|
+
"loaded_context_len": services.positive_int(selected.get("loaded_context_length")),
|
|
362
|
+
"state": selected.get("state"),
|
|
363
|
+
"capabilities": selected.get("capabilities"),
|
|
364
|
+
"type": selected.get("type"),
|
|
365
|
+
"root": selected.get("arch"),
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _runtime_from_v1(
|
|
370
|
+
data: Any,
|
|
371
|
+
models_url: str,
|
|
372
|
+
current: str,
|
|
373
|
+
services: LmStudioRuntimeServices,
|
|
374
|
+
) -> dict[str, Any] | None:
|
|
375
|
+
items = data.get("models") if isinstance(data, dict) else None
|
|
376
|
+
selected = _select_model(items, current, "key", services.model_id_matches)
|
|
377
|
+
if selected is None:
|
|
378
|
+
return None
|
|
379
|
+
loaded_context = None
|
|
380
|
+
instance_ids: list[str] = []
|
|
381
|
+
instances = selected.get("loaded_instances")
|
|
382
|
+
if isinstance(instances, list) and instances:
|
|
383
|
+
for instance in instances:
|
|
384
|
+
if isinstance(instance, dict) and instance.get("id"):
|
|
385
|
+
instance_ids.append(str(instance["id"]))
|
|
386
|
+
config = instances[0].get("config") if isinstance(instances[0], dict) else None
|
|
387
|
+
if isinstance(config, dict):
|
|
388
|
+
loaded_context = services.positive_int(config.get("context_length"))
|
|
389
|
+
return {
|
|
390
|
+
"models_url": models_url,
|
|
391
|
+
"requested_model": current,
|
|
392
|
+
"runtime_model": str(selected.get("key") or ""),
|
|
393
|
+
"max_model_len": services.positive_int(selected.get("max_context_length"))
|
|
394
|
+
or services.model_context(selected),
|
|
395
|
+
"loaded_context_len": loaded_context,
|
|
396
|
+
"state": "loaded" if loaded_context else "not-loaded",
|
|
397
|
+
"instance_ids": instance_ids,
|
|
398
|
+
"capabilities": selected.get("capabilities"),
|
|
399
|
+
"type": selected.get("type"),
|
|
400
|
+
"root": selected.get("architecture"),
|
|
401
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Managed MCP configuration generation for web, Z.AI, and channel servers."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
import importlib.util
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import sys
|
|
9
|
+
from typing import Any, Callable
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True, slots=True)
|
|
13
|
+
class ManagedMcpConfigPaths:
|
|
14
|
+
web_tools: Path
|
|
15
|
+
duckduckgo_compat: Path
|
|
16
|
+
zai: Path
|
|
17
|
+
channel: Path
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True, slots=True)
|
|
21
|
+
class ManagedMcpConfigPolicy:
|
|
22
|
+
router_base: str
|
|
23
|
+
zai_servers: tuple[tuple[str, str], ...]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True, slots=True)
|
|
27
|
+
class ManagedMcpConfigPorts:
|
|
28
|
+
find_executable: Callable[[str], str | None]
|
|
29
|
+
save_json: Callable[[Path, dict[str, Any], str], None]
|
|
30
|
+
primary_api_key: Callable[[str, dict[str, Any]], str]
|
|
31
|
+
meaningful_key: Callable[[str], bool]
|
|
32
|
+
initialize_channel_cursor: Callable[[], Any]
|
|
33
|
+
log: Callable[[str, str], None]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True, slots=True)
|
|
37
|
+
class ManagedMcpConfigService:
|
|
38
|
+
paths: ManagedMcpConfigPaths
|
|
39
|
+
policy: ManagedMcpConfigPolicy
|
|
40
|
+
ports: ManagedMcpConfigPorts
|
|
41
|
+
|
|
42
|
+
def write_web_tools(self, config: dict[str, Any]) -> Path:
|
|
43
|
+
web = config.get("web_search", {})
|
|
44
|
+
package = web.get("package") or "ddg-mcp-search"
|
|
45
|
+
npx = self.ports.find_executable("npx") or ("npx.cmd" if os.name == "nt" else "npx")
|
|
46
|
+
servers: dict[str, Any] = {"duckduckgo": {"command": npx, "args": ["-y", package]}}
|
|
47
|
+
if web.get("fetch_enabled", True):
|
|
48
|
+
command, arguments = self._fetch_command(web)
|
|
49
|
+
if command:
|
|
50
|
+
servers["web_fetch"] = {
|
|
51
|
+
"command": command,
|
|
52
|
+
"args": arguments,
|
|
53
|
+
"ciel_runtime_stdio": "jsonl",
|
|
54
|
+
}
|
|
55
|
+
else:
|
|
56
|
+
self.ports.log("WARN", "web_fetch_disabled_missing_runner install=uvx_or_uv")
|
|
57
|
+
self.ports.save_json(self.paths.web_tools, {"mcpServers": servers}, "web_tools_mcp_config")
|
|
58
|
+
return self.paths.web_tools
|
|
59
|
+
|
|
60
|
+
def write_duckduckgo_compat(self, config: dict[str, Any]) -> Path:
|
|
61
|
+
path = self.write_web_tools(config)
|
|
62
|
+
try:
|
|
63
|
+
self.paths.duckduckgo_compat.write_text(path.read_text(encoding="utf-8"), encoding="utf-8")
|
|
64
|
+
except (OSError, UnicodeError) as exc:
|
|
65
|
+
self.ports.log(
|
|
66
|
+
"WARN",
|
|
67
|
+
f"duckduckgo_mcp_compat_config_write_failed error={type(exc).__name__}: {exc}",
|
|
68
|
+
)
|
|
69
|
+
return path
|
|
70
|
+
|
|
71
|
+
def write_zai(self, provider: str, provider_config: dict[str, Any]) -> Path | None:
|
|
72
|
+
if provider != "zai" or not bool(provider_config.get("managed_mcp", True)):
|
|
73
|
+
return None
|
|
74
|
+
key = self.ports.primary_api_key(provider, provider_config)
|
|
75
|
+
if not self.ports.meaningful_key(key):
|
|
76
|
+
self.ports.log("WARN", "zai_mcp_config_skipped_missing_api_key")
|
|
77
|
+
return None
|
|
78
|
+
npx = self.ports.find_executable("npx") or ("npx.cmd" if os.name == "nt" else "npx")
|
|
79
|
+
servers: dict[str, Any] = {
|
|
80
|
+
"zai-mcp-server": {
|
|
81
|
+
"type": "stdio",
|
|
82
|
+
"command": npx,
|
|
83
|
+
"args": ["-y", "@z_ai/mcp-server@latest"],
|
|
84
|
+
"env": {"Z_AI_API_KEY": key, "Z_AI_MODE": "ZAI"},
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
for name, url in self.policy.zai_servers:
|
|
88
|
+
servers[name] = {
|
|
89
|
+
"type": "http",
|
|
90
|
+
"url": url,
|
|
91
|
+
"headers": {"Authorization": f"Bearer {key}"},
|
|
92
|
+
}
|
|
93
|
+
self.ports.save_json(self.paths.zai, {"mcpServers": servers}, "zai_mcp_config")
|
|
94
|
+
self.ports.log("INFO", f"zai_mcp_config_written servers={','.join(sorted(servers))}")
|
|
95
|
+
return self.paths.zai
|
|
96
|
+
|
|
97
|
+
def reset_zai_if_inactive(self, provider: str) -> None:
|
|
98
|
+
if provider == "zai":
|
|
99
|
+
return
|
|
100
|
+
try:
|
|
101
|
+
self.paths.zai.unlink()
|
|
102
|
+
self.ports.log("INFO", "zai_mcp_config_removed inactive_provider")
|
|
103
|
+
except FileNotFoundError:
|
|
104
|
+
return
|
|
105
|
+
except OSError as exc:
|
|
106
|
+
self.ports.log("WARN", f"zai_mcp_config_remove_failed error={type(exc).__name__}: {exc}")
|
|
107
|
+
|
|
108
|
+
def write_channel(self) -> Path:
|
|
109
|
+
data = {
|
|
110
|
+
"mcpServers": {
|
|
111
|
+
"ciel-runtime-router": {
|
|
112
|
+
"type": "sse",
|
|
113
|
+
"url": f"{self.policy.router_base}/ca/mcp/sse",
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
self.ports.save_json(self.paths.channel, data, "channel_mcp_config")
|
|
118
|
+
self.ports.initialize_channel_cursor()
|
|
119
|
+
return self.paths.channel
|
|
120
|
+
|
|
121
|
+
def _fetch_command(self, web: dict[str, Any]) -> tuple[str | None, list[Any]]:
|
|
122
|
+
arguments: list[Any] = [web.get("fetch_package") or "mcp-server-fetch"]
|
|
123
|
+
if web.get("fetch_user_agent"):
|
|
124
|
+
arguments.extend(["--user-agent", str(web["fetch_user_agent"])])
|
|
125
|
+
if web.get("fetch_ignore_robots_txt", False):
|
|
126
|
+
arguments.append("--ignore-robots-txt")
|
|
127
|
+
command = self.ports.find_executable("uvx")
|
|
128
|
+
if command:
|
|
129
|
+
return command, arguments
|
|
130
|
+
uv = self.ports.find_executable("uv")
|
|
131
|
+
if uv:
|
|
132
|
+
return uv, ["tool", "run", *arguments]
|
|
133
|
+
if importlib.util.find_spec("uv") is not None:
|
|
134
|
+
return sys.executable, ["-m", "uv", "tool", "run", *arguments]
|
|
135
|
+
pipx = self.ports.find_executable("pipx")
|
|
136
|
+
return (pipx, ["run", *arguments]) if pipx else (None, arguments)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
__all__ = [
|
|
140
|
+
"ManagedMcpConfigPaths",
|
|
141
|
+
"ManagedMcpConfigPolicy",
|
|
142
|
+
"ManagedMcpConfigPorts",
|
|
143
|
+
"ManagedMcpConfigService",
|
|
144
|
+
]
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Discovery of MCP servers restored from runtime-managed artifacts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .mcp_inventory import McpInventoryService
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True, slots=True)
|
|
15
|
+
class ManagedMcpDiscoveryPaths:
|
|
16
|
+
web_tools: Path
|
|
17
|
+
proxy: Path
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True, slots=True)
|
|
21
|
+
class ManagedMcpDiscoveryPorts:
|
|
22
|
+
read_generated: Callable[[Path, Path], dict[str, dict[str, Any]]]
|
|
23
|
+
load_json: Callable[[Path], Any]
|
|
24
|
+
log: Callable[[str, str], None]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True, slots=True)
|
|
28
|
+
class ManagedMcpDiscoveryService:
|
|
29
|
+
paths: ManagedMcpDiscoveryPaths
|
|
30
|
+
ports: ManagedMcpDiscoveryPorts
|
|
31
|
+
native_channel_names: frozenset[str]
|
|
32
|
+
|
|
33
|
+
def discover(self, cwd: Path) -> dict[str, dict[str, Any]]:
|
|
34
|
+
servers = self.ports.read_generated(self.paths.web_tools, cwd)
|
|
35
|
+
if not self.paths.proxy.exists() or not self.paths.proxy.is_file():
|
|
36
|
+
return servers
|
|
37
|
+
try:
|
|
38
|
+
proxy_data = self.ports.load_json(self.paths.proxy)
|
|
39
|
+
except (OSError, UnicodeError, json.JSONDecodeError, TypeError) as exc:
|
|
40
|
+
self.ports.log(
|
|
41
|
+
"WARN",
|
|
42
|
+
f"managed_mcp_proxy_config_read_failed path={self.paths.proxy} "
|
|
43
|
+
f"error={type(exc).__name__}: {exc}",
|
|
44
|
+
)
|
|
45
|
+
return servers
|
|
46
|
+
proxy_servers = (
|
|
47
|
+
proxy_data.get("mcpServers")
|
|
48
|
+
if isinstance(proxy_data, dict)
|
|
49
|
+
else None
|
|
50
|
+
)
|
|
51
|
+
if not isinstance(proxy_servers, dict):
|
|
52
|
+
return servers
|
|
53
|
+
for raw_name, raw_entry in proxy_servers.items():
|
|
54
|
+
name = str(raw_name or "").strip()
|
|
55
|
+
if (
|
|
56
|
+
not name
|
|
57
|
+
or name.casefold() in self.native_channel_names
|
|
58
|
+
or not isinstance(raw_entry, dict)
|
|
59
|
+
):
|
|
60
|
+
continue
|
|
61
|
+
restored = self._restore_proxy_entry(name, raw_entry)
|
|
62
|
+
if restored is None:
|
|
63
|
+
continue
|
|
64
|
+
restored_name, server = restored
|
|
65
|
+
if restored_name.casefold() not in self.native_channel_names:
|
|
66
|
+
servers.setdefault(restored_name, server)
|
|
67
|
+
return servers
|
|
68
|
+
|
|
69
|
+
def _restore_proxy_entry(
|
|
70
|
+
self, name: str, entry: dict[str, Any]
|
|
71
|
+
) -> tuple[str, dict[str, Any]] | None:
|
|
72
|
+
args = entry.get("args")
|
|
73
|
+
if not isinstance(args, list):
|
|
74
|
+
return name, dict(entry)
|
|
75
|
+
arguments = [str(item) for item in args]
|
|
76
|
+
if "mcp-proxy" not in arguments or "--server-config" not in arguments:
|
|
77
|
+
return name, dict(entry)
|
|
78
|
+
try:
|
|
79
|
+
config_path = Path(
|
|
80
|
+
arguments[arguments.index("--server-config") + 1]
|
|
81
|
+
).expanduser()
|
|
82
|
+
wrapped_name = (
|
|
83
|
+
arguments[arguments.index("--server-name") + 1].strip()
|
|
84
|
+
if "--server-name" in arguments
|
|
85
|
+
and arguments.index("--server-name") + 1 < len(arguments)
|
|
86
|
+
else name
|
|
87
|
+
)
|
|
88
|
+
wrapped_server = self.ports.load_json(config_path)
|
|
89
|
+
except (
|
|
90
|
+
OSError,
|
|
91
|
+
UnicodeError,
|
|
92
|
+
json.JSONDecodeError,
|
|
93
|
+
TypeError,
|
|
94
|
+
ValueError,
|
|
95
|
+
) as exc:
|
|
96
|
+
self.ports.log(
|
|
97
|
+
"WARN",
|
|
98
|
+
f"managed_mcp_wrapped_config_read_failed server={name} "
|
|
99
|
+
f"error={type(exc).__name__}: {exc}",
|
|
100
|
+
)
|
|
101
|
+
return None
|
|
102
|
+
if not wrapped_name or not isinstance(wrapped_server, dict):
|
|
103
|
+
return None
|
|
104
|
+
restored = dict(wrapped_server)
|
|
105
|
+
restored.pop("ciel_runtime_disable_notification_stream", None)
|
|
106
|
+
return wrapped_name, restored
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass(frozen=True, slots=True)
|
|
110
|
+
class NativeMcpConfigWriterPorts:
|
|
111
|
+
discover_user: Callable[..., dict[str, dict[str, Any]]]
|
|
112
|
+
discover_managed: Callable[[Path], dict[str, dict[str, Any]]]
|
|
113
|
+
save_json: Callable[[Path, dict[str, Any], str], None]
|
|
114
|
+
log: Callable[[str, str], None]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass(frozen=True, slots=True)
|
|
118
|
+
class NativeMcpConfigWriter:
|
|
119
|
+
"""Merge normalized user/managed MCP discovery into a native CLI artifact."""
|
|
120
|
+
|
|
121
|
+
path: Path
|
|
122
|
+
ports: NativeMcpConfigWriterPorts
|
|
123
|
+
|
|
124
|
+
def write(
|
|
125
|
+
self,
|
|
126
|
+
passthrough: list[str] | None = None,
|
|
127
|
+
cwd: Path | None = None,
|
|
128
|
+
home: Path | None = None,
|
|
129
|
+
) -> Path | None:
|
|
130
|
+
working_directory = cwd or Path.cwd()
|
|
131
|
+
servers = self.ports.discover_user(
|
|
132
|
+
passthrough, working_directory, home
|
|
133
|
+
)
|
|
134
|
+
merged = McpInventoryService.merge(
|
|
135
|
+
servers, self.ports.discover_managed(working_directory)
|
|
136
|
+
)
|
|
137
|
+
for name in merged.duplicates:
|
|
138
|
+
self.ports.log(
|
|
139
|
+
"INFO", f"native_mcp_managed_duplicate_skipped server={name}"
|
|
140
|
+
)
|
|
141
|
+
servers = merged.servers
|
|
142
|
+
if not servers:
|
|
143
|
+
return None
|
|
144
|
+
self.ports.save_json(
|
|
145
|
+
self.path, {"mcpServers": servers}, "native_mcp_config"
|
|
146
|
+
)
|
|
147
|
+
self.ports.log(
|
|
148
|
+
"INFO",
|
|
149
|
+
f"native_mcp_config_written servers={','.join(sorted(servers))}",
|
|
150
|
+
)
|
|
151
|
+
return self.path
|