@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,462 @@
|
|
|
1
|
+
"""Pure Ollama catalog parsing and update policies.
|
|
2
|
+
|
|
3
|
+
Network and filesystem effects stay in the composition root. This module is
|
|
4
|
+
the functional core used by those imperative-shell wrappers.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import html
|
|
10
|
+
import math
|
|
11
|
+
import re
|
|
12
|
+
import time
|
|
13
|
+
from copy import deepcopy
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Any, Callable, Iterable
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
OLLAMA_MODEL_CATALOG_URL = "https://ollama.com/api/tags"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class OllamaCatalogRefreshServices:
|
|
23
|
+
load_catalog: Callable[[], dict[str, Any]]
|
|
24
|
+
fetch_catalog: Callable[..., dict[str, Any]]
|
|
25
|
+
fetch_context_map: Callable[..., tuple[dict[str, int], str | None]]
|
|
26
|
+
save_catalog: Callable[[dict[str, Any]], None]
|
|
27
|
+
positive_int: Callable[[Any], int | None]
|
|
28
|
+
now: Callable[[], float] = time.time
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def refresh_model_catalog(
|
|
32
|
+
services: OllamaCatalogRefreshServices,
|
|
33
|
+
*,
|
|
34
|
+
include_contexts: bool = True,
|
|
35
|
+
timeout: float = 10.0,
|
|
36
|
+
catalog_url: str = OLLAMA_MODEL_CATALOG_URL,
|
|
37
|
+
) -> dict[str, Any]:
|
|
38
|
+
old_catalog = services.load_catalog()
|
|
39
|
+
old_models = old_catalog.get("models") if isinstance(old_catalog.get("models"), dict) else {}
|
|
40
|
+
data = services.fetch_catalog(catalog_url, timeout=timeout)
|
|
41
|
+
raw_models = data.get("models") if isinstance(data, dict) else None
|
|
42
|
+
if raw_models is None and isinstance(data, dict):
|
|
43
|
+
raw_models = data.get("data")
|
|
44
|
+
if not isinstance(raw_models, list):
|
|
45
|
+
raw_models = []
|
|
46
|
+
models: dict[str, dict[str, Any]] = {}
|
|
47
|
+
for item in raw_models:
|
|
48
|
+
if not isinstance(item, dict):
|
|
49
|
+
continue
|
|
50
|
+
name = str(item.get("name") or item.get("model") or item.get("id") or "").strip()
|
|
51
|
+
parts = model_catalog_key(name)
|
|
52
|
+
if not parts:
|
|
53
|
+
continue
|
|
54
|
+
key, base, tag = parts
|
|
55
|
+
entry = models.setdefault(
|
|
56
|
+
key,
|
|
57
|
+
{
|
|
58
|
+
"id": base,
|
|
59
|
+
"models": [],
|
|
60
|
+
"tags": [],
|
|
61
|
+
"raw": [],
|
|
62
|
+
"context_windows": {},
|
|
63
|
+
"context_source": None,
|
|
64
|
+
},
|
|
65
|
+
)
|
|
66
|
+
if name not in entry["models"]:
|
|
67
|
+
entry["models"].append(name)
|
|
68
|
+
if tag not in entry["tags"]:
|
|
69
|
+
entry["tags"].append(tag)
|
|
70
|
+
entry["raw"].append(item)
|
|
71
|
+
if not include_contexts and isinstance(old_models, dict):
|
|
72
|
+
_restore_cached_contexts(models, old_models, services.positive_int)
|
|
73
|
+
if include_contexts:
|
|
74
|
+
_refresh_context_maps(models, timeout, services)
|
|
75
|
+
catalog = {
|
|
76
|
+
"schema": 1,
|
|
77
|
+
"source": catalog_url,
|
|
78
|
+
"updated_at": services.now(),
|
|
79
|
+
"model_count": len(raw_models),
|
|
80
|
+
"base_model_count": len(models),
|
|
81
|
+
"models": models,
|
|
82
|
+
}
|
|
83
|
+
services.save_catalog(catalog)
|
|
84
|
+
return catalog
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _restore_cached_contexts(
|
|
88
|
+
models: dict[str, dict[str, Any]],
|
|
89
|
+
old_models: dict[str, Any],
|
|
90
|
+
positive_int: Callable[[Any], int | None],
|
|
91
|
+
) -> None:
|
|
92
|
+
for key, entry in models.items():
|
|
93
|
+
old_entry = old_models.get(key)
|
|
94
|
+
if not isinstance(old_entry, dict):
|
|
95
|
+
continue
|
|
96
|
+
old_windows = old_entry.get("context_windows")
|
|
97
|
+
if not isinstance(old_windows, dict) or not old_windows:
|
|
98
|
+
continue
|
|
99
|
+
entry["context_windows"] = old_windows
|
|
100
|
+
entry["context_window"] = positive_int(old_entry.get("context_window")) or max(
|
|
101
|
+
positive_int(value) or 0 for value in old_windows.values()
|
|
102
|
+
)
|
|
103
|
+
if isinstance(old_entry.get("recommended_timeout_ms_by_tag"), dict):
|
|
104
|
+
entry["recommended_timeout_ms_by_tag"] = old_entry["recommended_timeout_ms_by_tag"]
|
|
105
|
+
if positive_int(old_entry.get("recommended_timeout_ms")):
|
|
106
|
+
entry["recommended_timeout_ms"] = positive_int(old_entry.get("recommended_timeout_ms"))
|
|
107
|
+
entry["context_source"] = old_entry.get("context_source")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _refresh_context_maps(
|
|
111
|
+
models: dict[str, dict[str, Any]],
|
|
112
|
+
timeout: float,
|
|
113
|
+
services: OllamaCatalogRefreshServices,
|
|
114
|
+
) -> None:
|
|
115
|
+
for key in sorted(models):
|
|
116
|
+
entry = models[key]
|
|
117
|
+
context_map, source = services.fetch_context_map(str(entry["id"]), timeout=timeout)
|
|
118
|
+
if not context_map:
|
|
119
|
+
continue
|
|
120
|
+
entry["context_windows"] = context_map
|
|
121
|
+
entry["context_window"] = max(context_map.values())
|
|
122
|
+
entry["recommended_timeout_ms_by_tag"] = {
|
|
123
|
+
tag: recommended_timeout_ms(tokens)
|
|
124
|
+
for tag, tokens in context_map.items()
|
|
125
|
+
if services.positive_int(tokens)
|
|
126
|
+
}
|
|
127
|
+
entry["recommended_timeout_ms"] = recommended_timeout_ms(entry["context_window"])
|
|
128
|
+
entry["context_source"] = source
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _positive_int(value: Any) -> int | None:
|
|
132
|
+
try:
|
|
133
|
+
parsed = int(value)
|
|
134
|
+
except Exception:
|
|
135
|
+
return None
|
|
136
|
+
return parsed if parsed > 0 else None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def library_model_parts(model_id: str) -> tuple[str, str] | None:
|
|
140
|
+
model = str(model_id or "").strip()
|
|
141
|
+
if not model:
|
|
142
|
+
return None
|
|
143
|
+
base, tag = (model.split(":", 1) + ["latest"])[:2] if ":" in model else (model, "latest")
|
|
144
|
+
base = base.strip()
|
|
145
|
+
tag = tag.strip() or "latest"
|
|
146
|
+
if "/" in base or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", base):
|
|
147
|
+
return None
|
|
148
|
+
return base, tag
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def fetch_library_context_limit(
|
|
152
|
+
model_id: str,
|
|
153
|
+
*,
|
|
154
|
+
timeout: float,
|
|
155
|
+
fetch_context_map: Callable[
|
|
156
|
+
[str, float],
|
|
157
|
+
tuple[dict[str, int], str | None],
|
|
158
|
+
],
|
|
159
|
+
positive_int: Callable[[Any], int | None],
|
|
160
|
+
) -> tuple[int | None, str | None, str | None]:
|
|
161
|
+
parts = library_model_parts(model_id)
|
|
162
|
+
if not parts:
|
|
163
|
+
return None, None, None
|
|
164
|
+
base, tag = parts
|
|
165
|
+
context_map, source_url = fetch_context_map(base, timeout)
|
|
166
|
+
limit = positive_int(context_map.get(tag.lower()))
|
|
167
|
+
if not limit and tag == "latest":
|
|
168
|
+
cloud_limit = positive_int(context_map.get("cloud"))
|
|
169
|
+
if cloud_limit:
|
|
170
|
+
return cloud_limit, f"{base}:cloud", source_url
|
|
171
|
+
return limit, f"{base}:{tag}", source_url
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def context_label_to_tokens(number: str, unit: str | None) -> int | None:
|
|
175
|
+
try:
|
|
176
|
+
value = float(number)
|
|
177
|
+
except Exception:
|
|
178
|
+
return None
|
|
179
|
+
if value <= 0 or not math.isfinite(value):
|
|
180
|
+
return None
|
|
181
|
+
multiplier = {"k": 1024, "m": 1024**2, "g": 1024**3}.get(str(unit or "").strip().lower(), 1)
|
|
182
|
+
return int(round(value * multiplier))
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def recommended_timeout_ms(context_tokens: int | None, default_timeout_ms: int = 120_000) -> int:
|
|
186
|
+
tokens = _positive_int(context_tokens)
|
|
187
|
+
if not tokens:
|
|
188
|
+
return default_timeout_ms
|
|
189
|
+
if tokens >= 1024**2:
|
|
190
|
+
return 300_000
|
|
191
|
+
if tokens >= 512 * 1024:
|
|
192
|
+
return 180_000
|
|
193
|
+
return 120_000
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def model_catalog_key(model_id: str) -> tuple[str, str, str] | None:
|
|
197
|
+
parts = library_model_parts(model_id)
|
|
198
|
+
if not parts:
|
|
199
|
+
return None
|
|
200
|
+
base, tag = parts
|
|
201
|
+
return base.lower(), base, tag.lower()
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def model_lookup_ids(model_id: str) -> list[str]:
|
|
205
|
+
raw = str(model_id or "").strip()
|
|
206
|
+
if not raw:
|
|
207
|
+
return []
|
|
208
|
+
candidates = [raw]
|
|
209
|
+
normalized = raw.casefold().replace("_", "-")
|
|
210
|
+
compact = re.sub(r"[^a-z0-9]+", "", normalized)
|
|
211
|
+
|
|
212
|
+
def add(value: str) -> None:
|
|
213
|
+
if value and value not in candidates:
|
|
214
|
+
candidates.append(value)
|
|
215
|
+
|
|
216
|
+
if ("qwen3.6" in normalized or "qwen36" in compact) and "27b" in normalized:
|
|
217
|
+
add("qwen3.6:27b")
|
|
218
|
+
if ("qwen3.6" in normalized or "qwen36" in compact) and "35b" in normalized:
|
|
219
|
+
add("qwen3.6:35b-a3b")
|
|
220
|
+
add("qwen3.6:35b")
|
|
221
|
+
return candidates
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def catalog_model_ids(
|
|
225
|
+
catalog: dict[str, Any],
|
|
226
|
+
provider: str,
|
|
227
|
+
*,
|
|
228
|
+
normalize_model_id: Callable[[str, str], str],
|
|
229
|
+
unique_model_ids: Callable[[str, Iterable[str]], list[str]],
|
|
230
|
+
sorted_model_ids: Callable[[Iterable[str]], list[str]],
|
|
231
|
+
) -> list[str]:
|
|
232
|
+
models = catalog.get("models") if isinstance(catalog, dict) else None
|
|
233
|
+
if not isinstance(models, dict):
|
|
234
|
+
return []
|
|
235
|
+
ids: list[str] = []
|
|
236
|
+
for entry in models.values():
|
|
237
|
+
if not isinstance(entry, dict):
|
|
238
|
+
continue
|
|
239
|
+
raw_models = entry.get("models")
|
|
240
|
+
if isinstance(raw_models, list):
|
|
241
|
+
ids.extend(normalize_model_id(provider, str(item)) for item in raw_models)
|
|
242
|
+
base = str(entry.get("id") or "").strip()
|
|
243
|
+
if not base:
|
|
244
|
+
continue
|
|
245
|
+
ids.append(normalize_model_id(provider, base))
|
|
246
|
+
tags = entry.get("tags")
|
|
247
|
+
if isinstance(tags, list):
|
|
248
|
+
ids.extend(
|
|
249
|
+
normalize_model_id(provider, f"{base}:{tag}")
|
|
250
|
+
for tag in (str(item or "").strip() for item in tags)
|
|
251
|
+
if tag and tag.lower() != "latest"
|
|
252
|
+
)
|
|
253
|
+
return sorted_model_ids(unique_model_ids(provider, [item for item in ids if item]))
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def catalog_is_stale(catalog: dict[str, Any], ttl_seconds: int, now: float | None = None) -> bool:
|
|
257
|
+
if not isinstance(catalog, dict) or not isinstance(catalog.get("models"), dict):
|
|
258
|
+
return True
|
|
259
|
+
try:
|
|
260
|
+
updated_at = float(catalog.get("updated_at") or 0)
|
|
261
|
+
except Exception:
|
|
262
|
+
updated_at = 0.0
|
|
263
|
+
current = time.time() if now is None else now
|
|
264
|
+
return updated_at <= 0 or current - updated_at > ttl_seconds
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def context_tokens_from_snippet(snippet: str, table_fallback: bool = True) -> int | None:
|
|
268
|
+
match = re.search(r"\b(\d+(?:\.\d+)?)\s*([KMG])\s+context\s+window\b", snippet, re.IGNORECASE)
|
|
269
|
+
if match:
|
|
270
|
+
return context_label_to_tokens(match.group(1), match.group(2))
|
|
271
|
+
if table_fallback:
|
|
272
|
+
match = re.search(r">\s*(\d+(?:\.\d+)?)\s*([KM])\s*</p>", snippet, re.IGNORECASE)
|
|
273
|
+
if match:
|
|
274
|
+
return context_label_to_tokens(match.group(1), match.group(2))
|
|
275
|
+
return None
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def parse_library_context_map(page_html: str, base_model: str) -> dict[str, int]:
|
|
279
|
+
text = html.unescape(page_html or "")
|
|
280
|
+
base = str(base_model or "").strip()
|
|
281
|
+
if not text or not base:
|
|
282
|
+
return {}
|
|
283
|
+
contexts: dict[str, int] = {}
|
|
284
|
+
pattern = re.compile(
|
|
285
|
+
r"(?<![A-Za-z0-9._/-])" + re.escape(base) + r":([A-Za-z0-9][A-Za-z0-9._-]*)",
|
|
286
|
+
re.IGNORECASE,
|
|
287
|
+
)
|
|
288
|
+
for match in pattern.finditer(text):
|
|
289
|
+
tag = match.group(1).strip().lower()
|
|
290
|
+
# A tags page contains several rows. Prefer content following the
|
|
291
|
+
# matched tag so a previous row's context label cannot leak forward.
|
|
292
|
+
following = text[match.end(): match.end() + 3000]
|
|
293
|
+
tokens = context_tokens_from_snippet(following)
|
|
294
|
+
if not tokens:
|
|
295
|
+
surrounding = text[max(0, match.start() - 700): match.end() + 3000]
|
|
296
|
+
tokens = context_tokens_from_snippet(surrounding)
|
|
297
|
+
if tag and tokens:
|
|
298
|
+
contexts[tag] = max(contexts.get(tag, 0), tokens)
|
|
299
|
+
if not contexts:
|
|
300
|
+
match = re.search(r"\b(\d+(?:\.\d+)?)\s*([KMG])\s+context\s+window\b", text, re.IGNORECASE)
|
|
301
|
+
tokens = context_label_to_tokens(match.group(1), match.group(2)) if match else None
|
|
302
|
+
if tokens:
|
|
303
|
+
contexts["latest"] = tokens
|
|
304
|
+
return contexts
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def catalog_context_for_model(
|
|
308
|
+
catalog: dict[str, Any],
|
|
309
|
+
model_id: str,
|
|
310
|
+
model_lookup_ids: Callable[[str], Iterable[str]],
|
|
311
|
+
) -> tuple[int | None, str | None, str | None]:
|
|
312
|
+
models = catalog.get("models", {}) if isinstance(catalog, dict) else {}
|
|
313
|
+
if not isinstance(models, dict):
|
|
314
|
+
return None, None, None
|
|
315
|
+
for candidate_model in model_lookup_ids(model_id):
|
|
316
|
+
parts = model_catalog_key(candidate_model)
|
|
317
|
+
if not parts:
|
|
318
|
+
continue
|
|
319
|
+
key, base, tag = parts
|
|
320
|
+
entry = models.get(key)
|
|
321
|
+
if not isinstance(entry, dict):
|
|
322
|
+
continue
|
|
323
|
+
source = str(entry.get("context_source") or catalog.get("source") or "")
|
|
324
|
+
windows = entry.get("context_windows")
|
|
325
|
+
if isinstance(windows, dict):
|
|
326
|
+
candidates = [tag, "cloud", "latest"]
|
|
327
|
+
for candidate in dict.fromkeys(candidates):
|
|
328
|
+
value = _positive_int(windows.get(candidate))
|
|
329
|
+
if value:
|
|
330
|
+
return value, f"{base}:{candidate}", source or None
|
|
331
|
+
value = _positive_int(entry.get("context_window"))
|
|
332
|
+
if value:
|
|
333
|
+
return value, base, source or None
|
|
334
|
+
return None, None, None
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def catalog_timeout_for_model(
|
|
338
|
+
catalog: dict[str, Any],
|
|
339
|
+
model_id: str,
|
|
340
|
+
model_lookup_ids: Callable[[str], Iterable[str]],
|
|
341
|
+
) -> int | None:
|
|
342
|
+
models = catalog.get("models", {}) if isinstance(catalog, dict) else {}
|
|
343
|
+
if not isinstance(models, dict):
|
|
344
|
+
return None
|
|
345
|
+
for candidate_model in model_lookup_ids(model_id):
|
|
346
|
+
parts = model_catalog_key(candidate_model)
|
|
347
|
+
if not parts:
|
|
348
|
+
continue
|
|
349
|
+
key, _, tag = parts
|
|
350
|
+
entry = models.get(key)
|
|
351
|
+
if not isinstance(entry, dict):
|
|
352
|
+
continue
|
|
353
|
+
per_tag = entry.get("recommended_timeout_ms_by_tag")
|
|
354
|
+
if isinstance(per_tag, dict):
|
|
355
|
+
for candidate in dict.fromkeys([tag, "cloud", "latest"]):
|
|
356
|
+
value = _positive_int(per_tag.get(candidate))
|
|
357
|
+
if value:
|
|
358
|
+
return value
|
|
359
|
+
value = _positive_int(entry.get("recommended_timeout_ms"))
|
|
360
|
+
if value:
|
|
361
|
+
return value
|
|
362
|
+
return None
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def with_updated_context(
|
|
366
|
+
catalog: dict[str, Any],
|
|
367
|
+
model_id: str,
|
|
368
|
+
limit: int,
|
|
369
|
+
matched_model: str | None,
|
|
370
|
+
source_url: str | None,
|
|
371
|
+
*,
|
|
372
|
+
now: float | None = None,
|
|
373
|
+
) -> dict[str, Any]:
|
|
374
|
+
parts = model_catalog_key(model_id)
|
|
375
|
+
if not parts or not _positive_int(limit):
|
|
376
|
+
return catalog
|
|
377
|
+
result = deepcopy(catalog)
|
|
378
|
+
if not isinstance(result.get("models"), dict):
|
|
379
|
+
result = {
|
|
380
|
+
"schema": 1,
|
|
381
|
+
"source": OLLAMA_MODEL_CATALOG_URL,
|
|
382
|
+
"updated_at": now or time.time(),
|
|
383
|
+
"model_count": 0,
|
|
384
|
+
"base_model_count": 0,
|
|
385
|
+
"models": {},
|
|
386
|
+
}
|
|
387
|
+
key, base, tag = parts
|
|
388
|
+
entry = result["models"].setdefault(
|
|
389
|
+
key,
|
|
390
|
+
{"id": base, "models": [], "tags": [], "raw": [], "context_windows": {}, "context_source": None},
|
|
391
|
+
)
|
|
392
|
+
if model_id not in entry["models"]:
|
|
393
|
+
entry["models"].append(model_id)
|
|
394
|
+
matched_parts = model_catalog_key(matched_model or "")
|
|
395
|
+
stored_tag = matched_parts[2] if matched_parts else tag
|
|
396
|
+
if stored_tag not in entry.setdefault("tags", []):
|
|
397
|
+
entry["tags"].append(stored_tag)
|
|
398
|
+
entry.setdefault("context_windows", {})[stored_tag] = int(limit)
|
|
399
|
+
entry.setdefault("recommended_timeout_ms_by_tag", {})[stored_tag] = recommended_timeout_ms(limit)
|
|
400
|
+
entry["context_window"] = max(
|
|
401
|
+
[_positive_int(value) or 0 for value in entry["context_windows"].values()] + [int(limit)]
|
|
402
|
+
)
|
|
403
|
+
entry["recommended_timeout_ms"] = recommended_timeout_ms(entry["context_window"])
|
|
404
|
+
entry["context_source"] = source_url or entry.get("context_source")
|
|
405
|
+
result["updated_at"] = time.time() if now is None else now
|
|
406
|
+
result["base_model_count"] = len(result["models"])
|
|
407
|
+
return result
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def parse_library_context_limit(tags_html: str, full_model_id: str) -> int | None:
|
|
411
|
+
text = html.unescape(tags_html or "")
|
|
412
|
+
target = str(full_model_id or "").strip()
|
|
413
|
+
if not text or not target:
|
|
414
|
+
return None
|
|
415
|
+
lower = text.lower()
|
|
416
|
+
target_lower = target.lower()
|
|
417
|
+
start = 0
|
|
418
|
+
while True:
|
|
419
|
+
index = lower.find(target_lower, start)
|
|
420
|
+
if index < 0:
|
|
421
|
+
return None
|
|
422
|
+
tokens = context_tokens_from_snippet(text[max(0, index - 500): index + 2500])
|
|
423
|
+
if tokens:
|
|
424
|
+
return tokens
|
|
425
|
+
start = index + len(target_lower)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def context_model_matches(current_model: str, cached_model: str | None) -> bool:
|
|
429
|
+
current = str(current_model or "").strip().lower()
|
|
430
|
+
cached = str(cached_model or "").strip().lower()
|
|
431
|
+
if not current or not cached:
|
|
432
|
+
return False
|
|
433
|
+
|
|
434
|
+
def aliases(value: str) -> set[str]:
|
|
435
|
+
result = {value}
|
|
436
|
+
if ":" not in value:
|
|
437
|
+
result.update({f"{value}:latest", f"{value}:cloud"})
|
|
438
|
+
if value.endswith((":latest", ":cloud")):
|
|
439
|
+
result.add(value.rsplit(":", 1)[0])
|
|
440
|
+
return result
|
|
441
|
+
|
|
442
|
+
return bool(aliases(current) & aliases(cached))
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
__all__ = [
|
|
446
|
+
"OLLAMA_MODEL_CATALOG_URL",
|
|
447
|
+
"OllamaCatalogRefreshServices",
|
|
448
|
+
"catalog_context_for_model",
|
|
449
|
+
"catalog_is_stale",
|
|
450
|
+
"catalog_model_ids",
|
|
451
|
+
"catalog_timeout_for_model",
|
|
452
|
+
"context_label_to_tokens",
|
|
453
|
+
"context_model_matches",
|
|
454
|
+
"context_tokens_from_snippet",
|
|
455
|
+
"library_model_parts",
|
|
456
|
+
"model_catalog_key",
|
|
457
|
+
"parse_library_context_limit",
|
|
458
|
+
"parse_library_context_map",
|
|
459
|
+
"recommended_timeout_ms",
|
|
460
|
+
"refresh_model_catalog",
|
|
461
|
+
"with_updated_context",
|
|
462
|
+
]
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Ollama catalog refresh command application controller."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Callable
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True, slots=True)
|
|
11
|
+
class OllamaCatalogCliController:
|
|
12
|
+
refresh: Callable[..., dict[str, Any]]
|
|
13
|
+
catalog_path: Path
|
|
14
|
+
output: Callable[[str], None]
|
|
15
|
+
|
|
16
|
+
def refresh_command(
|
|
17
|
+
self,
|
|
18
|
+
*,
|
|
19
|
+
include_contexts: bool = True,
|
|
20
|
+
timeout: float = 10.0,
|
|
21
|
+
) -> None:
|
|
22
|
+
catalog = self.refresh(
|
|
23
|
+
include_contexts=include_contexts,
|
|
24
|
+
timeout=timeout,
|
|
25
|
+
)
|
|
26
|
+
raw_models = catalog.get("models")
|
|
27
|
+
models = raw_models if isinstance(raw_models, dict) else {}
|
|
28
|
+
context_count = sum(
|
|
29
|
+
1
|
|
30
|
+
for entry in models.values()
|
|
31
|
+
if isinstance(entry, dict)
|
|
32
|
+
and isinstance(entry.get("context_windows"), dict)
|
|
33
|
+
and bool(entry["context_windows"])
|
|
34
|
+
)
|
|
35
|
+
self.output(f"Ollama catalog saved: {self.catalog_path}")
|
|
36
|
+
self.output(f"API models: {catalog.get('model_count', 0)}")
|
|
37
|
+
self.output(f"Base models: {len(models)}")
|
|
38
|
+
self.output(f"Context windows: {context_count}/{len(models)}")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
__all__ = ["OllamaCatalogCliController"]
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Filesystem and HTTP adapters for the Ollama model catalog."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
import urllib.parse
|
|
9
|
+
import urllib.request
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Callable
|
|
13
|
+
|
|
14
|
+
from ciel_runtime_support.ollama_catalog import library_model_parts, parse_library_context_map
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True, slots=True)
|
|
18
|
+
class OllamaCatalogRepository:
|
|
19
|
+
path: Path
|
|
20
|
+
log: Callable[[str, str], None]
|
|
21
|
+
request_headers: Callable[[], dict[str, str]]
|
|
22
|
+
|
|
23
|
+
def load(self) -> dict[str, Any]:
|
|
24
|
+
try:
|
|
25
|
+
data = json.loads(self.path.read_text(encoding="utf-8"))
|
|
26
|
+
except Exception:
|
|
27
|
+
return {}
|
|
28
|
+
return data if isinstance(data, dict) else {}
|
|
29
|
+
|
|
30
|
+
def save(self, catalog: dict[str, Any]) -> None:
|
|
31
|
+
try:
|
|
32
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
temporary = self.path.with_name(f"{self.path.name}.{os.getpid()}.{time.time_ns()}.tmp")
|
|
34
|
+
temporary.write_text(
|
|
35
|
+
json.dumps(catalog, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
|
36
|
+
encoding="utf-8",
|
|
37
|
+
)
|
|
38
|
+
os.chmod(temporary, 0o600)
|
|
39
|
+
temporary.replace(self.path)
|
|
40
|
+
except Exception as error:
|
|
41
|
+
self.log("WARN", f"ollama catalog: failed to save cache: {error}")
|
|
42
|
+
|
|
43
|
+
def fetch_json(self, url: str, timeout: float = 12.0) -> Any:
|
|
44
|
+
request = urllib.request.Request(url, headers=self.request_headers())
|
|
45
|
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
46
|
+
return json.loads(response.read(5_000_000).decode("utf-8", errors="replace"))
|
|
47
|
+
|
|
48
|
+
def fetch_library_context_map(
|
|
49
|
+
self,
|
|
50
|
+
base_model: str,
|
|
51
|
+
timeout: float = 10.0,
|
|
52
|
+
) -> tuple[dict[str, int], str | None]:
|
|
53
|
+
parts = library_model_parts(base_model)
|
|
54
|
+
if not parts:
|
|
55
|
+
return {}, None
|
|
56
|
+
base, _tag = parts
|
|
57
|
+
urls = (
|
|
58
|
+
f"https://ollama.com/library/{urllib.parse.quote(base, safe='')}/tags",
|
|
59
|
+
f"https://ollama.com/library/{urllib.parse.quote(base, safe='')}",
|
|
60
|
+
)
|
|
61
|
+
merged: dict[str, int] = {}
|
|
62
|
+
source_url: str | None = None
|
|
63
|
+
for url in urls:
|
|
64
|
+
request = urllib.request.Request(url, headers=self.request_headers())
|
|
65
|
+
try:
|
|
66
|
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
67
|
+
raw = response.read(3_000_000)
|
|
68
|
+
except Exception:
|
|
69
|
+
continue
|
|
70
|
+
page_map = parse_library_context_map(raw.decode("utf-8", errors="replace"), base)
|
|
71
|
+
for tag, tokens in page_map.items():
|
|
72
|
+
merged[tag] = max(merged.get(tag, 0), tokens)
|
|
73
|
+
if page_map and not source_url:
|
|
74
|
+
source_url = url
|
|
75
|
+
return merged, source_url
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Application service for synchronizing Ollama model context capacity."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any, Callable
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True, slots=True)
|
|
10
|
+
class OllamaContextSources:
|
|
11
|
+
fetch_api_specs: Callable[[str, dict[str, Any], str], dict[str, Any]]
|
|
12
|
+
load_catalog: Callable[[], dict[str, Any]]
|
|
13
|
+
catalog_is_stale: Callable[[dict[str, Any]], bool]
|
|
14
|
+
refresh_catalog: Callable[..., dict[str, Any]]
|
|
15
|
+
catalog_context: Callable[[str], tuple[int | None, str | None, str | None]]
|
|
16
|
+
fetch_library_context: Callable[[str], tuple[int | None, str | None, str | None]]
|
|
17
|
+
update_catalog_context: Callable[[str, int, str | None, str | None], None]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True, slots=True)
|
|
21
|
+
class OllamaContextPolicy:
|
|
22
|
+
positive_int: Callable[[Any], int | None]
|
|
23
|
+
normalize_model_id: Callable[[str, str], str]
|
|
24
|
+
model_context_hint: Callable[[str], int | None]
|
|
25
|
+
context_model_matches: Callable[[str, str | None], bool]
|
|
26
|
+
preserve_configured_cap: Callable[[dict[str, Any]], bool]
|
|
27
|
+
log: Callable[[str, str], None]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def sync_ollama_context_limit(
|
|
31
|
+
provider: str,
|
|
32
|
+
config: dict[str, Any],
|
|
33
|
+
model_id: str,
|
|
34
|
+
sources: OllamaContextSources,
|
|
35
|
+
policy: OllamaContextPolicy,
|
|
36
|
+
) -> list[str]:
|
|
37
|
+
if provider not in ("ollama", "ollama-cloud"):
|
|
38
|
+
return []
|
|
39
|
+
try:
|
|
40
|
+
api_specs = sources.fetch_api_specs(provider, config, model_id)
|
|
41
|
+
except Exception as error:
|
|
42
|
+
policy.log(
|
|
43
|
+
"DEBUG",
|
|
44
|
+
f"{provider} /api/show model specs unavailable for {model_id}: "
|
|
45
|
+
f"{type(error).__name__}: {error}",
|
|
46
|
+
)
|
|
47
|
+
api_specs = {}
|
|
48
|
+
limit = policy.positive_int(api_specs.get("max_model_len"))
|
|
49
|
+
matched_model = policy.normalize_model_id(provider, model_id) if limit else ""
|
|
50
|
+
source_url = "/api/show" if limit else ""
|
|
51
|
+
if not limit:
|
|
52
|
+
catalog = sources.load_catalog()
|
|
53
|
+
if sources.catalog_is_stale(catalog):
|
|
54
|
+
try:
|
|
55
|
+
sources.refresh_catalog(include_contexts=False)
|
|
56
|
+
except Exception as error:
|
|
57
|
+
policy.log("WARN", f"ollama catalog: api refresh failed: {error}")
|
|
58
|
+
limit, matched_model, source_url = sources.catalog_context(model_id)
|
|
59
|
+
if not limit:
|
|
60
|
+
limit, matched_model, source_url = sources.fetch_library_context(model_id)
|
|
61
|
+
if limit:
|
|
62
|
+
sources.update_catalog_context(model_id, limit, matched_model, source_url)
|
|
63
|
+
if not limit:
|
|
64
|
+
limit = policy.model_context_hint(model_id)
|
|
65
|
+
if limit:
|
|
66
|
+
matched_model = policy.normalize_model_id(provider, model_id)
|
|
67
|
+
else:
|
|
68
|
+
if not policy.context_model_matches(model_id, str(config.get("model_context_model") or "")):
|
|
69
|
+
config.pop("model_context_max", None)
|
|
70
|
+
config.pop("model_context_model", None)
|
|
71
|
+
return []
|
|
72
|
+
old_max = policy.positive_int(config.get("num_ctx_max"))
|
|
73
|
+
config["model_context_max"] = limit
|
|
74
|
+
config["model_context_model"] = matched_model
|
|
75
|
+
if not (old_max and old_max <= limit and policy.preserve_configured_cap(config)):
|
|
76
|
+
config["num_ctx_max"] = min(old_max, limit) if old_max and old_max > limit else limit
|
|
77
|
+
minimum = policy.positive_int(config.get("num_ctx_min"))
|
|
78
|
+
if minimum and minimum > limit:
|
|
79
|
+
config["num_ctx_min"] = limit
|
|
80
|
+
fixed_context = policy.positive_int(config.get("num_ctx"))
|
|
81
|
+
if fixed_context and fixed_context > limit:
|
|
82
|
+
config["num_ctx"] = limit
|
|
83
|
+
label = f"{limit:,}"
|
|
84
|
+
if old_max and old_max == limit:
|
|
85
|
+
return [f"Ollama library context verified: {matched_model} -> {label} tokens."]
|
|
86
|
+
detail = f" from {source_url}" if source_url else ""
|
|
87
|
+
return [f"Ollama library context detected: {matched_model} -> {label} tokens{detail}."]
|