@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.
Files changed (287) hide show
  1. package/README.md +132 -0
  2. package/ciel-runtime-menu.py +56 -6
  3. package/ciel_runtime.py +9379 -35909
  4. package/ciel_runtime_support/advisor_client.py +193 -0
  5. package/ciel_runtime_support/advisor_policy.py +320 -0
  6. package/ciel_runtime_support/advisor_refinement.py +160 -0
  7. package/ciel_runtime_support/advisor_request_builder.py +261 -0
  8. package/ciel_runtime_support/agy_installer.py +169 -0
  9. package/ciel_runtime_support/agy_mcp_restore.py +182 -0
  10. package/ciel_runtime_support/anthropic_model_policy.py +186 -0
  11. package/ciel_runtime_support/anthropic_response_writer.py +255 -0
  12. package/ciel_runtime_support/anthropic_tool_turns.py +130 -0
  13. package/ciel_runtime_support/api_key_cooldown.py +159 -0
  14. package/ciel_runtime_support/architecture.py +488 -1
  15. package/ciel_runtime_support/architecture_budget.py +42 -0
  16. package/ciel_runtime_support/channel_backlog.py +90 -0
  17. package/ciel_runtime_support/channel_cli.py +119 -0
  18. package/ciel_runtime_support/channel_compact_injection.py +82 -0
  19. package/ciel_runtime_support/channel_compact_poll.py +67 -0
  20. package/ciel_runtime_support/channel_compact_request_repository.py +113 -0
  21. package/ciel_runtime_support/channel_config_service.py +281 -0
  22. package/ciel_runtime_support/channel_connection_lifecycle.py +180 -0
  23. package/ciel_runtime_support/channel_connection_registry.py +128 -0
  24. package/ciel_runtime_support/channel_connection_worker.py +284 -0
  25. package/ciel_runtime_support/channel_cursor_recovery.py +92 -0
  26. package/ciel_runtime_support/channel_cursor_repository.py +89 -0
  27. package/ciel_runtime_support/channel_cursor_service.py +178 -0
  28. package/ciel_runtime_support/channel_event_identity.py +212 -0
  29. package/ciel_runtime_support/channel_event_projection.py +315 -0
  30. package/ciel_runtime_support/channel_inflight.py +127 -0
  31. package/ciel_runtime_support/channel_injection.py +115 -0
  32. package/ciel_runtime_support/channel_launch_guard_repository.py +58 -0
  33. package/ciel_runtime_support/channel_launch_policy.py +180 -0
  34. package/ciel_runtime_support/channel_llm_context.py +156 -0
  35. package/ciel_runtime_support/channel_mcp_discovery.py +186 -0
  36. package/ciel_runtime_support/channel_mcp_http_controller.py +239 -0
  37. package/ciel_runtime_support/channel_mcp_ownership.py +148 -0
  38. package/ciel_runtime_support/channel_mcp_tools.py +240 -0
  39. package/ciel_runtime_support/channel_mcp_transport.py +394 -0
  40. package/ciel_runtime_support/channel_message_dedupe.py +65 -0
  41. package/ciel_runtime_support/channel_message_policy.py +256 -0
  42. package/ciel_runtime_support/channel_message_prompt.py +305 -0
  43. package/ciel_runtime_support/channel_message_repository.py +234 -0
  44. package/ciel_runtime_support/channel_notification_projection.py +217 -0
  45. package/ciel_runtime_support/channel_panel.py +162 -0
  46. package/ciel_runtime_support/channel_pending_injection.py +209 -0
  47. package/ciel_runtime_support/channel_pending_poll.py +109 -0
  48. package/ciel_runtime_support/channel_probe_cache.py +433 -0
  49. package/ciel_runtime_support/channel_probe_report.py +101 -0
  50. package/ciel_runtime_support/channel_runtime_environment.py +181 -0
  51. package/ciel_runtime_support/channel_session_lifecycle.py +114 -0
  52. package/ciel_runtime_support/channel_session_repository.py +90 -0
  53. package/ciel_runtime_support/channel_terminal_dispatch.py +115 -0
  54. package/ciel_runtime_support/channel_terminal_input.py +277 -0
  55. package/ciel_runtime_support/channel_terminal_proxy.py +447 -0
  56. package/ciel_runtime_support/channel_tool_context.py +166 -0
  57. package/ciel_runtime_support/channel_transcript.py +414 -0
  58. package/ciel_runtime_support/channel_transcript_repository.py +96 -0
  59. package/ciel_runtime_support/channel_wake_claim_repository.py +126 -0
  60. package/ciel_runtime_support/channel_wake_delivery_repository.py +88 -0
  61. package/ciel_runtime_support/chat_files.py +138 -0
  62. package/ciel_runtime_support/chat_http_controller.py +235 -0
  63. package/ciel_runtime_support/claude_environment.py +375 -0
  64. package/ciel_runtime_support/claude_router.py +247 -193
  65. package/ciel_runtime_support/cli_dispatch.py +792 -0
  66. package/ciel_runtime_support/cli_parser.py +165 -0
  67. package/ciel_runtime_support/cli_usage.py +100 -0
  68. package/ciel_runtime_support/codex_app_server.py +20 -5
  69. package/ciel_runtime_support/codex_channel_sse_launch.py +87 -0
  70. package/ciel_runtime_support/codex_cli.py +42 -6
  71. package/ciel_runtime_support/codex_config.py +323 -0
  72. package/ciel_runtime_support/codex_launch_configuration.py +240 -0
  73. package/ciel_runtime_support/codex_launch_policy.py +66 -0
  74. package/ciel_runtime_support/codex_mcp_integration.py +195 -0
  75. package/ciel_runtime_support/codex_mcp_restore.py +304 -0
  76. package/ciel_runtime_support/codex_model_catalog.py +133 -0
  77. package/ciel_runtime_support/codex_process_lifecycle.py +271 -0
  78. package/ciel_runtime_support/codex_router.py +147 -1
  79. package/ciel_runtime_support/codex_session_repository.py +115 -0
  80. package/ciel_runtime_support/codex_session_selection.py +114 -0
  81. package/ciel_runtime_support/command_asset_installer.py +103 -0
  82. package/ciel_runtime_support/compatibility_probe.py +295 -0
  83. package/ciel_runtime_support/compatibility_protocol.py +251 -0
  84. package/ciel_runtime_support/compatibility_runtime.py +166 -0
  85. package/ciel_runtime_support/compatibility_test.py +370 -0
  86. package/ciel_runtime_support/config_migrations.py +307 -0
  87. package/ciel_runtime_support/config_repository.py +175 -0
  88. package/ciel_runtime_support/config_value_codec.py +64 -0
  89. package/ciel_runtime_support/configuration_cli.py +374 -0
  90. package/ciel_runtime_support/context_compaction.py +280 -0
  91. package/ciel_runtime_support/context_setup.py +208 -0
  92. package/ciel_runtime_support/context_summary_policy.py +392 -0
  93. package/ciel_runtime_support/credential_cli.py +104 -0
  94. package/ciel_runtime_support/credential_management.py +261 -0
  95. package/ciel_runtime_support/credentials.py +269 -0
  96. package/ciel_runtime_support/executable_discovery.py +141 -0
  97. package/ciel_runtime_support/github_copilot_oauth.py +335 -0
  98. package/ciel_runtime_support/github_copilot_oauth_runtime.py +213 -0
  99. package/ciel_runtime_support/header_forwarding.py +73 -0
  100. package/ciel_runtime_support/headless_config.py +221 -0
  101. package/ciel_runtime_support/http_response.py +129 -0
  102. package/ciel_runtime_support/install_diagnostics.py +149 -0
  103. package/ciel_runtime_support/kimi_identity.py +123 -0
  104. package/ciel_runtime_support/launch_diagnostics.py +204 -0
  105. package/ciel_runtime_support/launch_state.py +127 -0
  106. package/ciel_runtime_support/live_api_key_controller.py +58 -0
  107. package/ciel_runtime_support/llm_config_http.py +148 -0
  108. package/ciel_runtime_support/llm_option_config.py +259 -0
  109. package/ciel_runtime_support/llm_presentation_data.py +447 -0
  110. package/ciel_runtime_support/llm_presets.py +773 -0
  111. package/ciel_runtime_support/lm_studio_runtime.py +401 -0
  112. package/ciel_runtime_support/managed_mcp_config.py +144 -0
  113. package/ciel_runtime_support/managed_mcp_discovery.py +151 -0
  114. package/ciel_runtime_support/managed_service_cleanup.py +89 -0
  115. package/ciel_runtime_support/mcp_config_reader.py +230 -0
  116. package/ciel_runtime_support/mcp_http_proxy.py +607 -0
  117. package/ciel_runtime_support/mcp_inventory.py +59 -0
  118. package/ciel_runtime_support/mcp_notification_wait_policy.py +159 -0
  119. package/ciel_runtime_support/mcp_probe_codec.py +136 -0
  120. package/ciel_runtime_support/mcp_probe_transport.py +328 -0
  121. package/ciel_runtime_support/mcp_proxy_codec.py +345 -0
  122. package/ciel_runtime_support/mcp_proxy_config.py +107 -0
  123. package/ciel_runtime_support/mcp_proxy_notifications.py +261 -0
  124. package/ciel_runtime_support/mcp_proxy_process.py +560 -0
  125. package/ciel_runtime_support/mcp_split_proxy_http.py +165 -0
  126. package/ciel_runtime_support/mcp_stdio_probe.py +240 -0
  127. package/ciel_runtime_support/mcp_transport.py +146 -0
  128. package/ciel_runtime_support/model_cache_lifecycle.py +78 -0
  129. package/ciel_runtime_support/model_catalog_projection.py +61 -0
  130. package/ciel_runtime_support/model_context_hints.py +109 -0
  131. package/ciel_runtime_support/model_panel.py +147 -0
  132. package/ciel_runtime_support/model_registry_repository.py +231 -0
  133. package/ciel_runtime_support/npm_runtime.py +191 -0
  134. package/ciel_runtime_support/ollama_catalog.py +462 -0
  135. package/ciel_runtime_support/ollama_catalog_cli.py +41 -0
  136. package/ciel_runtime_support/ollama_catalog_repository.py +75 -0
  137. package/ciel_runtime_support/ollama_context_sync.py +87 -0
  138. package/ciel_runtime_support/ollama_forwarding.py +449 -0
  139. package/ciel_runtime_support/openai_chat_passthrough.py +67 -0
  140. package/ciel_runtime_support/openai_chat_router.py +64 -0
  141. package/ciel_runtime_support/openai_forwarding.py +194 -0
  142. package/ciel_runtime_support/openai_responses_router.py +291 -0
  143. package/ciel_runtime_support/openai_responses_stream.py +135 -0
  144. package/ciel_runtime_support/output_budget.py +89 -0
  145. package/ciel_runtime_support/package_lifecycle.py +217 -0
  146. package/ciel_runtime_support/plan_artifact_controller.py +104 -0
  147. package/ciel_runtime_support/prelaunch.py +959 -0
  148. package/ciel_runtime_support/prelaunch_launch_preference.py +75 -0
  149. package/ciel_runtime_support/prelaunch_panel_projection.py +396 -0
  150. package/ciel_runtime_support/prelaunch_terminal.py +764 -0
  151. package/ciel_runtime_support/process_control.py +708 -0
  152. package/ciel_runtime_support/prompt_compaction.py +322 -0
  153. package/ciel_runtime_support/prompt_injection.py +176 -0
  154. package/ciel_runtime_support/protocols/__init__.py +24 -0
  155. package/ciel_runtime_support/protocols/anthropic_content.py +29 -0
  156. package/ciel_runtime_support/protocols/anthropic_thinking_policy.py +336 -0
  157. package/ciel_runtime_support/protocols/chat_projection.py +315 -0
  158. package/ciel_runtime_support/protocols/conversation_policy.py +217 -0
  159. package/ciel_runtime_support/protocols/conversation_turn_policy.py +972 -0
  160. package/ciel_runtime_support/protocols/ollama_chat.py +111 -0
  161. package/ciel_runtime_support/protocols/ollama_response.py +231 -0
  162. package/ciel_runtime_support/protocols/openai_reasoning.py +75 -0
  163. package/ciel_runtime_support/protocols/openai_responses.py +271 -0
  164. package/ciel_runtime_support/protocols/pseudo_tool_history.py +248 -0
  165. package/ciel_runtime_support/protocols/tool_result_projection.py +112 -0
  166. package/ciel_runtime_support/provider_adapters.py +160 -0
  167. package/ciel_runtime_support/provider_catalog_sources.py +316 -0
  168. package/ciel_runtime_support/provider_choice.py +201 -0
  169. package/ciel_runtime_support/provider_compatibility.py +165 -0
  170. package/ciel_runtime_support/provider_config_mutations.py +361 -0
  171. package/ciel_runtime_support/provider_configuration_service.py +162 -0
  172. package/ciel_runtime_support/provider_context.py +308 -0
  173. package/ciel_runtime_support/provider_contract_projection.py +73 -0
  174. package/ciel_runtime_support/provider_descriptor.py +82 -0
  175. package/ciel_runtime_support/provider_endpoint_policy.py +130 -0
  176. package/ciel_runtime_support/provider_endpoint_probe.py +165 -0
  177. package/ciel_runtime_support/provider_launch_endpoint.py +109 -0
  178. package/ciel_runtime_support/provider_limits.py +457 -0
  179. package/ciel_runtime_support/provider_model_identity.py +139 -0
  180. package/ciel_runtime_support/provider_model_selection.py +431 -0
  181. package/ciel_runtime_support/provider_model_specs.py +142 -0
  182. package/ciel_runtime_support/provider_models.py +263 -0
  183. package/ciel_runtime_support/provider_network.py +176 -0
  184. package/ciel_runtime_support/provider_option_cli.py +238 -0
  185. package/ciel_runtime_support/provider_option_panel.py +275 -0
  186. package/ciel_runtime_support/provider_option_status.py +192 -0
  187. package/ciel_runtime_support/provider_policy.py +101 -0
  188. package/ciel_runtime_support/provider_query_policy.py +67 -0
  189. package/ciel_runtime_support/provider_readiness.py +112 -0
  190. package/ciel_runtime_support/provider_request_access.py +131 -0
  191. package/ciel_runtime_support/provider_request_builder.py +250 -0
  192. package/ciel_runtime_support/provider_responses_passthrough.py +81 -0
  193. package/ciel_runtime_support/provider_runtime_info.py +113 -0
  194. package/ciel_runtime_support/provider_runtime_modes.py +150 -0
  195. package/ciel_runtime_support/provider_sampling_policy.py +46 -0
  196. package/ciel_runtime_support/provider_status.py +145 -0
  197. package/ciel_runtime_support/provider_timeout_policy.py +184 -0
  198. package/ciel_runtime_support/provider_tool_policy.py +145 -0
  199. package/ciel_runtime_support/providers/__init__.py +65 -0
  200. package/ciel_runtime_support/providers/anthropic.py +160 -0
  201. package/ciel_runtime_support/providers/anthropic_catalog.py +119 -0
  202. package/ciel_runtime_support/providers/base.py +232 -0
  203. package/ciel_runtime_support/providers/catalog.py +326 -0
  204. package/ciel_runtime_support/providers/cloud.py +194 -0
  205. package/ciel_runtime_support/providers/constants.py +54 -0
  206. package/ciel_runtime_support/providers/deepseek.py +115 -0
  207. package/ciel_runtime_support/providers/fireworks.py +158 -0
  208. package/ciel_runtime_support/providers/github_copilot_oauth.py +199 -0
  209. package/ciel_runtime_support/providers/kimi.py +297 -0
  210. package/ciel_runtime_support/providers/lm_studio.py +76 -0
  211. package/ciel_runtime_support/providers/meta.py +257 -0
  212. package/ciel_runtime_support/providers/native.py +194 -0
  213. package/ciel_runtime_support/providers/nim.py +69 -0
  214. package/ciel_runtime_support/providers/nvidia.py +158 -0
  215. package/ciel_runtime_support/providers/nvidia_runtime.py +285 -0
  216. package/ciel_runtime_support/providers/ollama.py +181 -0
  217. package/ciel_runtime_support/providers/ollama_context.py +195 -0
  218. package/ciel_runtime_support/providers/ollama_runtime.py +213 -0
  219. package/ciel_runtime_support/providers/opencode.py +220 -0
  220. package/ciel_runtime_support/providers/opencode_go.py +37 -0
  221. package/ciel_runtime_support/providers/openrouter.py +57 -0
  222. package/ciel_runtime_support/providers/vllm.py +65 -0
  223. package/ciel_runtime_support/providers/zai.py +119 -0
  224. package/ciel_runtime_support/pseudo_tool_parser.py +115 -0
  225. package/ciel_runtime_support/rate_limit_policy.py +117 -0
  226. package/ciel_runtime_support/rate_limit_repository.py +154 -0
  227. package/ciel_runtime_support/registry.py +46 -0
  228. package/ciel_runtime_support/request_shortcuts.py +253 -0
  229. package/ciel_runtime_support/request_trace.py +323 -0
  230. package/ciel_runtime_support/response_collection.py +209 -0
  231. package/ciel_runtime_support/router_access.py +238 -0
  232. package/ciel_runtime_support/router_client_lifecycle.py +366 -0
  233. package/ciel_runtime_support/router_health_policy.py +101 -0
  234. package/ciel_runtime_support/router_http.py +513 -0
  235. package/ciel_runtime_support/router_process_lifecycle.py +401 -0
  236. package/ciel_runtime_support/router_rate_limit_service.py +285 -0
  237. package/ciel_runtime_support/router_server_runtime.py +103 -0
  238. package/ciel_runtime_support/router_shortcuts.py +201 -0
  239. package/ciel_runtime_support/routing_fallback.py +73 -0
  240. package/ciel_runtime_support/runtime_activity_repository.py +143 -0
  241. package/ciel_runtime_support/runtime_adapters.py +104 -0
  242. package/ciel_runtime_support/runtime_command_factory.py +73 -0
  243. package/ciel_runtime_support/runtime_compatibility.py +50 -0
  244. package/ciel_runtime_support/runtime_constants.py +178 -0
  245. package/ciel_runtime_support/runtime_launch.py +1602 -0
  246. package/ciel_runtime_support/runtime_llm_options.py +312 -0
  247. package/ciel_runtime_support/runtime_logging.py +161 -0
  248. package/ciel_runtime_support/runtime_paths.py +157 -0
  249. package/ciel_runtime_support/runtime_restart.py +84 -0
  250. package/ciel_runtime_support/runtime_upgrade.py +149 -0
  251. package/ciel_runtime_support/secure_json_repository.py +55 -0
  252. package/ciel_runtime_support/session_import.py +356 -0
  253. package/ciel_runtime_support/settings_repository.py +8 -0
  254. package/ciel_runtime_support/slash_command_assets.py +211 -0
  255. package/ciel_runtime_support/sse_stream.py +57 -0
  256. package/ciel_runtime_support/sse_trace.py +225 -0
  257. package/ciel_runtime_support/statusline_script.py +593 -0
  258. package/ciel_runtime_support/statusline_settings.py +53 -0
  259. package/ciel_runtime_support/stream_chunk_policy.py +18 -0
  260. package/ciel_runtime_support/streaming_anthropic.py +1955 -0
  261. package/ciel_runtime_support/synthetic_tool_policy.py +105 -0
  262. package/ciel_runtime_support/terminal_platform_io.py +127 -0
  263. package/ciel_runtime_support/timeout_profile.py +196 -0
  264. package/ciel_runtime_support/tool_dialects.py +85 -0
  265. package/ciel_runtime_support/tool_exposure_policy.py +63 -0
  266. package/ciel_runtime_support/tool_guard_hooks.py +218 -0
  267. package/ciel_runtime_support/tool_request_projection.py +96 -0
  268. package/ciel_runtime_support/tool_schema.py +483 -0
  269. package/ciel_runtime_support/tool_side_effect_dedupe.py +95 -0
  270. package/ciel_runtime_support/ui_text.py +266 -0
  271. package/ciel_runtime_support/upstream_error_policy.py +104 -0
  272. package/ciel_runtime_support/upstream_retry.py +419 -0
  273. package/ciel_runtime_support/upstream_stream_io.py +106 -0
  274. package/ciel_runtime_support/usage_events.py +96 -0
  275. package/ciel_runtime_support/visible_stream_filters.py +130 -0
  276. package/ciel_runtime_support/web_endpoints.py +447 -0
  277. package/ciel_runtime_support/web_ui.py +915 -0
  278. package/ciel_runtime_support/web_ui_controller.py +189 -0
  279. package/ciel_runtime_support/windows_console_input.py +137 -0
  280. package/ciel_runtime_support/windows_console_mode.py +112 -0
  281. package/docs/Architecture.md +54 -0
  282. package/docs/Configuration.md +17 -0
  283. package/docs/Module-Map.md +1093 -26
  284. package/docs/Providers.md +46 -1
  285. package/docs/adr/0001-runtime-bounded-contexts.md +295 -0
  286. package/npm-bin/run-ciel-runtime.js +22 -2
  287. 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}."]