arkaos 4.1.1 → 4.3.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 (47) hide show
  1. package/README.md +1 -1
  2. package/VERSION +1 -1
  3. package/arka/SKILL.md +1 -1
  4. package/arka/skills/flow/SKILL.md +15 -0
  5. package/arka/skills/fusion/SKILL.md +91 -0
  6. package/config/constitution.yaml +15 -7
  7. package/config/hooks/session-start.sh +22 -0
  8. package/config/models.yaml +70 -0
  9. package/core/fusion/__init__.py +5 -0
  10. package/core/fusion/__pycache__/__init__.cpython-313.pyc +0 -0
  11. package/core/fusion/__pycache__/cli.cpython-313.pyc +0 -0
  12. package/core/fusion/__pycache__/engine.cpython-313.pyc +0 -0
  13. package/core/fusion/cli.py +37 -0
  14. package/core/fusion/engine.py +153 -0
  15. package/core/governance/__pycache__/redo_counter.cpython-313.pyc +0 -0
  16. package/core/governance/redo_counter.py +81 -0
  17. package/core/hooks/__pycache__/pre_tool_use.cpython-313.pyc +0 -0
  18. package/core/hooks/__pycache__/pre_tool_use.cpython-314.pyc +0 -0
  19. package/core/hooks/pre_tool_use.py +55 -3
  20. package/core/obsidian/__pycache__/relator.cpython-313.pyc +0 -0
  21. package/core/runtime/__pycache__/llm_provider.cpython-313.pyc +0 -0
  22. package/core/runtime/__pycache__/llm_provider.cpython-314.pyc +0 -0
  23. package/core/runtime/__pycache__/model_router.cpython-313.pyc +0 -0
  24. package/core/runtime/__pycache__/model_router_cli.cpython-313.pyc +0 -0
  25. package/core/runtime/__pycache__/ollama_discovery.cpython-313.pyc +0 -0
  26. package/core/runtime/__pycache__/openrouter_provider.cpython-313.pyc +0 -0
  27. package/core/runtime/__pycache__/openrouter_provider.cpython-314.pyc +0 -0
  28. package/core/runtime/llm_provider.py +10 -1
  29. package/core/runtime/model_router.py +204 -0
  30. package/core/runtime/model_router_cli.py +151 -0
  31. package/core/runtime/ollama_discovery.py +96 -0
  32. package/core/runtime/openrouter_provider.py +172 -0
  33. package/core/sync/__pycache__/__init__.cpython-312.pyc +0 -0
  34. package/core/sync/__pycache__/engine.cpython-312.pyc +0 -0
  35. package/core/sync/__pycache__/manifest.cpython-312.pyc +0 -0
  36. package/core/workflow/__pycache__/frontend_gate.cpython-313.pyc +0 -0
  37. package/core/workflow/frontend_gate.py +162 -0
  38. package/dashboard/app/layouts/default.vue +7 -0
  39. package/dashboard/app/pages/models.vue +404 -0
  40. package/installer/autostart.js +16 -2
  41. package/installer/cli.js +23 -0
  42. package/installer/keys.js +1 -0
  43. package/package.json +1 -1
  44. package/pyproject.toml +1 -1
  45. package/scripts/__pycache__/dashboard-api.cpython-313.pyc +0 -0
  46. package/scripts/dashboard-api.py +79 -0
  47. package/scripts/start-dashboard.sh +25 -1
@@ -5,10 +5,12 @@ with ONE python process. Gate order is preserved exactly:
5
5
 
6
6
  1. KB-first research gate (core/workflow/research_gate.py)
7
7
  2. Specialist-dispatch gate (core/workflow/specialist_enforcer.py)
8
- 3. Fast allow for non-flow-gated tools
9
- 4. CostGovernor budget check (stdlib-only MUST run even when the
8
+ 3. Frontend excellence gate (core/workflow/frontend_gate.py — v4.2.0
9
+ excellence-mandate: UI edits require [arka:design] evidence)
10
+ 4. Fast allow for non-flow-gated tools
11
+ 5. CostGovernor budget check (stdlib-only — MUST run even when the
10
12
  yaml-needing enforcers degrade; see ADR 2026-07-04-cost-governor)
11
- 5. Evidence-flow gate (core/workflow/flow_enforcer.py)
13
+ 6. Evidence-flow gate (core/workflow/flow_enforcer.py)
12
14
 
13
15
  Behavior contract (unchanged from the bash version):
14
16
  - allow → no stdout, exit 0 (nudges/warnings on stderr)
@@ -165,6 +167,50 @@ def _specialist_gate(
165
167
  return None
166
168
 
167
169
 
170
+ def _frontend_gate(
171
+ root: str,
172
+ tool_name: str,
173
+ transcript_path: str,
174
+ session_id: str,
175
+ cwd: str,
176
+ tool_input: dict,
177
+ messages: _MessagesOnce,
178
+ ) -> int | None:
179
+ """Frontend excellence gate (excellence-mandate). 2 on deny, None to continue."""
180
+ if tool_name not in _SPECIALIST_TOOLS:
181
+ return None
182
+ if not (Path(root) / "core" / "workflow" / "frontend_gate.py").is_file():
183
+ return None
184
+ try:
185
+ from core.workflow.frontend_gate import (
186
+ evaluate,
187
+ is_ui_file,
188
+ record_telemetry,
189
+ )
190
+ except Exception:
191
+ return None # frontend-gate-import-failed → allow
192
+ # Zero-read fast path: only UI files ever need the transcript.
193
+ if not is_ui_file(str(tool_input.get("file_path", ""))):
194
+ return None
195
+ decision = evaluate(
196
+ tool_name=tool_name,
197
+ transcript_path=transcript_path,
198
+ session_id=session_id,
199
+ cwd=cwd,
200
+ tool_input=tool_input,
201
+ messages=messages.load(),
202
+ )
203
+ try:
204
+ record_telemetry(session_id=session_id, tool=tool_name, decision=decision)
205
+ except Exception:
206
+ pass
207
+ if not decision.allow:
208
+ return _deny(decision.to_stderr_message())
209
+ if decision.to_stderr_message():
210
+ print(decision.to_stderr_message(), file=sys.stderr)
211
+ return None
212
+
213
+
168
214
  def _budget_check(session_id: str) -> tuple[bool, str]:
169
215
  """CostGovernor gate (stdlib-only). Returns (allow, warning)."""
170
216
  try:
@@ -244,6 +290,12 @@ def main(stdin_json: dict | None = None) -> int:
244
290
  if code is not None:
245
291
  return code
246
292
 
293
+ code = _frontend_gate(
294
+ root, tool_name, transcript_path, session_id, cwd, tool_input, messages
295
+ )
296
+ if code is not None:
297
+ return code
298
+
247
299
  # Fast allow: not a flow-gated tool (Bash stays — classified per-command
248
300
  # by the enforcer via bash_is_effect()).
249
301
  if tool_name not in _FLOW_GATED_TOOLS:
@@ -32,7 +32,9 @@ from core.runtime.pricing import estimate_cost_usd
32
32
 
33
33
 
34
34
  _DEFAULT_CONFIG_PATH = Path.home() / ".arkaos" / "config.json"
35
- _FALLBACK_ORDER: tuple[str, ...] = ("subagent", "ollama", "anthropic-direct", "stub")
35
+ _FALLBACK_ORDER: tuple[str, ...] = (
36
+ "subagent", "ollama", "openrouter", "anthropic-direct", "stub"
37
+ )
36
38
 
37
39
 
38
40
  # ─── Public dataclass ─────────────────────────────────────────────────
@@ -330,9 +332,16 @@ def _import_ollama_provider() -> type:
330
332
  return OllamaProvider
331
333
 
332
334
 
335
+ def _import_openrouter_provider() -> type:
336
+ """Lazy import to keep the OpenRouter provider optional at runtime."""
337
+ from core.runtime.openrouter_provider import OpenRouterProvider
338
+ return OpenRouterProvider
339
+
340
+
333
341
  _PROVIDERS: dict[str, type] = {
334
342
  "subagent": SubagentProvider,
335
343
  "ollama": _import_ollama_provider(),
344
+ "openrouter": _import_openrouter_provider(),
336
345
  "anthropic-direct": AnthropicDirectProvider,
337
346
  "stub": StubProvider,
338
347
  }
@@ -0,0 +1,204 @@
1
+ """Model Fabric router — resolve a work role to (provider, model, effort).
2
+
3
+ The user owns ``~/.arkaos/models.yaml``; the packaged default ships at
4
+ ``config/models.yaml``. Roles govern everything ArkaOS dispatches
5
+ (subagents, Quality Gate, Forge, fusion, cognition, daemons) — the
6
+ interactive runtime keeps its own main-loop model.
7
+
8
+ Constitution ``model-routing`` (Excellence Reform 2026-07-05): quality-
9
+ critical roles (design, review, architecture, strategy, quality_gate)
10
+ default to the best model available at maximum effort; only genuinely
11
+ mechanical work economises. The built-in fallback used when no YAML can
12
+ be read encodes the same posture — a missing file never silently
13
+ downgrades a quality role.
14
+
15
+ Public API::
16
+
17
+ resolve("review") -> ResolvedModel(provider, model, effort)
18
+ load_config() -> ModelsConfig
19
+ ensure_user_config() -> Path (creates ~/.arkaos/models.yaml)
20
+ set_role("review", "anthropic/best", effort="max")
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import shutil
26
+ from pathlib import Path
27
+
28
+ import yaml
29
+ from pydantic import BaseModel, Field, field_validator
30
+
31
+ USER_CONFIG_PATH = Path.home() / ".arkaos" / "models.yaml"
32
+
33
+ QUALITY_ROLES = frozenset({
34
+ "design", "review", "architecture", "strategy", "quality_gate",
35
+ })
36
+ KNOWN_EFFORTS = ("low", "medium", "high", "max")
37
+
38
+ _ALIAS_NAMES = frozenset({"best", "default", "fast"})
39
+
40
+
41
+ class RoleChoice(BaseModel):
42
+ """One role entry as written in models.yaml."""
43
+
44
+ provider: str = "runtime"
45
+ model: str = "best"
46
+ effort: str = "high"
47
+
48
+ @field_validator("effort")
49
+ @classmethod
50
+ def _known_effort(cls, value: str) -> str:
51
+ if value not in KNOWN_EFFORTS:
52
+ raise ValueError(
53
+ f"effort must be one of {KNOWN_EFFORTS}, got {value!r}"
54
+ )
55
+ return value
56
+
57
+
58
+ class FusionConfig(BaseModel):
59
+ """Judge + panel for the fusion engine (PR-D consumes this)."""
60
+
61
+ judge: RoleChoice = Field(default_factory=lambda: RoleChoice(effort="max"))
62
+ panel: list[RoleChoice] = Field(default_factory=list)
63
+
64
+
65
+ class ModelsConfig(BaseModel):
66
+ """Validated shape of models.yaml."""
67
+
68
+ version: int = 1
69
+ providers: dict[str, dict] = Field(default_factory=dict)
70
+ aliases: dict[str, dict[str, str]] = Field(default_factory=dict)
71
+ roles: dict[str, RoleChoice] = Field(default_factory=dict)
72
+ fusion: FusionConfig = Field(default_factory=FusionConfig)
73
+
74
+
75
+ class ResolvedModel(BaseModel):
76
+ """A role after alias resolution — what a dispatcher actually uses."""
77
+
78
+ role: str
79
+ provider: str
80
+ model: str
81
+ effort: str
82
+ source: str # "user" | "packaged" | "builtin"
83
+
84
+
85
+ def _packaged_config_path() -> Path:
86
+ return Path(__file__).resolve().parents[2] / "config" / "models.yaml"
87
+
88
+
89
+ def _builtin_config() -> ModelsConfig:
90
+ """Quality-first posture used when no YAML is readable at all."""
91
+ roles = {
92
+ role: RoleChoice(model="best", effort="max") for role in QUALITY_ROLES
93
+ }
94
+ roles["execution"] = RoleChoice(model="default", effort="high")
95
+ roles["mechanical"] = RoleChoice(model="fast", effort="low")
96
+ return ModelsConfig(
97
+ providers={"runtime": {"type": "subagent"}},
98
+ aliases={"runtime": {"best": "opus", "default": "sonnet", "fast": "haiku"}},
99
+ roles=roles,
100
+ )
101
+
102
+
103
+ def _read_yaml(path: Path) -> ModelsConfig | None:
104
+ try:
105
+ data = yaml.safe_load(path.read_text(encoding="utf-8"))
106
+ except (OSError, yaml.YAMLError):
107
+ return None
108
+ if not isinstance(data, dict):
109
+ return None
110
+ try:
111
+ return ModelsConfig.model_validate(data)
112
+ except ValueError:
113
+ return None
114
+
115
+
116
+ def load_config(user_path: Path | None = None) -> tuple[ModelsConfig, str]:
117
+ """Load config with provenance: user file, packaged default, builtin."""
118
+ path = user_path or USER_CONFIG_PATH
119
+ config = _read_yaml(path) if path.is_file() else None
120
+ if config is not None:
121
+ return config, "user"
122
+ packaged = _packaged_config_path()
123
+ config = _read_yaml(packaged) if packaged.is_file() else None
124
+ if config is not None:
125
+ return config, "packaged"
126
+ return _builtin_config(), "builtin"
127
+
128
+
129
+ def _resolve_alias(config: ModelsConfig, provider: str, model: str) -> str:
130
+ if model not in _ALIAS_NAMES:
131
+ return model
132
+ return config.aliases.get(provider, {}).get(model, "") or model
133
+
134
+
135
+ def resolve(role: str, user_path: Path | None = None) -> ResolvedModel:
136
+ """Resolve a role name to a concrete (provider, model, effort).
137
+
138
+ Unknown roles resolve conservatively: quality posture, not economy —
139
+ a typo must never silently land on the cheap tier
140
+ (``excellence-mandate``).
141
+ """
142
+ config, source = load_config(user_path)
143
+ choice = config.roles.get(role)
144
+ if choice is None:
145
+ fallback = "execution" if role not in QUALITY_ROLES else role
146
+ choice = config.roles.get(fallback, RoleChoice(model="best", effort="max"))
147
+ return ResolvedModel(
148
+ role=role,
149
+ provider=choice.provider,
150
+ model=_resolve_alias(config, choice.provider, choice.model),
151
+ effort=choice.effort,
152
+ source=source,
153
+ )
154
+
155
+
156
+ def resolve_all(user_path: Path | None = None) -> list[ResolvedModel]:
157
+ """Resolve every configured role — the CLI table."""
158
+ config, _ = load_config(user_path)
159
+ return [resolve(role, user_path) for role in sorted(config.roles)]
160
+
161
+
162
+ def ensure_user_config(user_path: Path | None = None) -> Path:
163
+ """Create ~/.arkaos/models.yaml from the packaged default if absent."""
164
+ path = user_path or USER_CONFIG_PATH
165
+ if path.is_file():
166
+ return path
167
+ path.parent.mkdir(parents=True, exist_ok=True)
168
+ packaged = _packaged_config_path()
169
+ if packaged.is_file():
170
+ shutil.copy(packaged, path)
171
+ else:
172
+ path.write_text(
173
+ yaml.safe_dump(_builtin_config().model_dump(), sort_keys=False),
174
+ encoding="utf-8",
175
+ )
176
+ return path
177
+
178
+
179
+ def set_role(
180
+ role: str,
181
+ target: str,
182
+ effort: str | None = None,
183
+ user_path: Path | None = None,
184
+ ) -> ResolvedModel:
185
+ """Point a role at ``provider/model`` (e.g. ``anthropic/best``).
186
+
187
+ Writes the user file (creating it first when needed) and returns the
188
+ new resolution. Raises ValueError on a malformed target or effort.
189
+ """
190
+ if "/" not in target:
191
+ raise ValueError(
192
+ f"target must be provider/model (e.g. runtime/best), got {target!r}"
193
+ )
194
+ provider, model = target.split("/", 1)
195
+ path = ensure_user_config(user_path)
196
+ data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
197
+ entry = {"provider": provider, "model": model}
198
+ entry["effort"] = effort or data.get("roles", {}).get(role, {}).get(
199
+ "effort", "max" if role in QUALITY_ROLES else "high"
200
+ )
201
+ RoleChoice.model_validate(entry) # fail before writing anything
202
+ data.setdefault("roles", {})[role] = entry
203
+ path.write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8")
204
+ return resolve(role, path)
@@ -0,0 +1,151 @@
1
+ """CLI for the Model Fabric router — `npx arkaos models` delegates here.
2
+
3
+ Usage::
4
+
5
+ python -m core.runtime.model_router_cli # table of roles
6
+ python -m core.runtime.model_router_cli --json # machine-readable
7
+ python -m core.runtime.model_router_cli init # create user file
8
+ python -m core.runtime.model_router_cli set review anthropic/best --effort max
9
+ python -m core.runtime.model_router_cli usage --period week
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import json
16
+ import sys
17
+
18
+ from core.runtime.llm_cost_telemetry import VALID_PERIODS, summarise
19
+ from core.runtime.model_router import (
20
+ USER_CONFIG_PATH,
21
+ ensure_user_config,
22
+ load_config,
23
+ resolve_all,
24
+ set_role,
25
+ )
26
+ from core.runtime.ollama_discovery import discover
27
+
28
+
29
+ def _print_table() -> None:
30
+ resolved = resolve_all()
31
+ _, source = load_config()
32
+ print()
33
+ print(" ArkaOS Model Fabric — role routing")
34
+ print(f" config: {USER_CONFIG_PATH if source == 'user' else source}")
35
+ print()
36
+ header = f" {'ROLE':<14} {'PROVIDER':<12} {'MODEL':<34} EFFORT"
37
+ print(header)
38
+ print(" " + "─" * (len(header) - 2))
39
+ for item in resolved:
40
+ model = item.model or "(unset — configure alias)"
41
+ print(f" {item.role:<14} {item.provider:<12} {model:<34} {item.effort}")
42
+ print()
43
+ _print_ollama_line()
44
+ print(" Change: npx arkaos models set <role> <provider>/<model> [--effort max]")
45
+ print(" Usage: npx arkaos models usage [--period today|week|month|all]")
46
+ print()
47
+
48
+
49
+ def _print_ollama_line() -> None:
50
+ status = discover()
51
+ if status.running and status.models:
52
+ names = ", ".join(m.name for m in status.models[:5])
53
+ extra = f" (+{len(status.models) - 5} more)" if len(status.models) > 5 else ""
54
+ print(f" Ollama: running — {len(status.models)} local models: {names}{extra}")
55
+ print(" use them: npx arkaos models set mechanical ollama/<model>")
56
+ elif status.installed:
57
+ print(" Ollama: installed but not running — start it to unlock local models")
58
+ else:
59
+ print(" Ollama: not detected — https://ollama.com for free local models")
60
+ print()
61
+
62
+
63
+ def _print_usage(period: str) -> None:
64
+ summary = summarise(period=period)
65
+ total = summary.total_cost_usd
66
+ total_s = f"${total:.4f}" if total is not None else "—"
67
+ print()
68
+ print(f" Model Fabric — usage ({period})")
69
+ print(f" calls: {summary.call_count} tokens in/out: "
70
+ f"{summary.total_tokens_in}/{summary.total_tokens_out} "
71
+ f"cached: {summary.total_cached_tokens} est. cost: {total_s}")
72
+ for title, group in (("BY MODEL", summary.by_model),
73
+ ("BY PROVIDER", summary.by_provider)):
74
+ if not group:
75
+ continue
76
+ print()
77
+ print(f" {title:<28} {'CALLS':>6} {'IN':>10} {'OUT':>10} {'COST':>10}")
78
+ print(" " + "─" * 68)
79
+ ranked = sorted(group.items(),
80
+ key=lambda kv: kv[1].get("total_cost_usd") or 0,
81
+ reverse=True)
82
+ for name, bucket in ranked:
83
+ cost = bucket.get("total_cost_usd")
84
+ cost_s = f"${cost:.4f}" if cost is not None else "—"
85
+ print(f" {(name or '(unknown)'):<28} "
86
+ f"{bucket.get('call_count', 0):>6} "
87
+ f"{bucket.get('total_tokens_in', 0):>10} "
88
+ f"{bucket.get('total_tokens_out', 0):>10} {cost_s:>10}")
89
+ print()
90
+
91
+
92
+ def _print_json() -> None:
93
+ payload = [item.model_dump() for item in resolve_all()]
94
+ print(json.dumps(payload, indent=2))
95
+
96
+
97
+ def main(argv: list[str] | None = None) -> int:
98
+ parser = argparse.ArgumentParser(prog="arkaos models")
99
+ parser.add_argument("action", nargs="?", default="list",
100
+ choices=["list", "init", "set", "usage", "discover"])
101
+ parser.add_argument("role", nargs="?")
102
+ parser.add_argument("target", nargs="?")
103
+ parser.add_argument("--effort", choices=["low", "medium", "high", "max"])
104
+ parser.add_argument("--period", choices=list(VALID_PERIODS), default="today")
105
+ parser.add_argument("--json", action="store_true", dest="as_json")
106
+ args = parser.parse_args(argv)
107
+
108
+ if args.action == "usage":
109
+ if args.as_json:
110
+ summary = summarise(period=args.period)
111
+ print(json.dumps({
112
+ "period": summary.period,
113
+ "call_count": summary.call_count,
114
+ "total_cost_usd": summary.total_cost_usd,
115
+ "total_tokens_in": summary.total_tokens_in,
116
+ "total_tokens_out": summary.total_tokens_out,
117
+ "total_cached_tokens": summary.total_cached_tokens,
118
+ "by_model": summary.by_model,
119
+ "by_provider": summary.by_provider,
120
+ "by_category": summary.by_category,
121
+ }, indent=2))
122
+ else:
123
+ _print_usage(args.period)
124
+ return 0
125
+ if args.action == "discover":
126
+ print(json.dumps(discover().to_dict(), indent=2))
127
+ return 0
128
+ if args.action == "init":
129
+ path = ensure_user_config()
130
+ print(f" ✓ Model Fabric config: {path}")
131
+ return 0
132
+ if args.action == "set":
133
+ if not args.role or not args.target:
134
+ parser.error("set requires: <role> <provider>/<model>")
135
+ try:
136
+ item = set_role(args.role, args.target, effort=args.effort)
137
+ except ValueError as exc:
138
+ print(f" ✗ {exc}", file=sys.stderr)
139
+ return 1
140
+ print(f" ✓ {item.role} -> {item.provider}/{item.model} "
141
+ f"(effort {item.effort})")
142
+ return 0
143
+ if args.as_json:
144
+ _print_json()
145
+ else:
146
+ _print_table()
147
+ return 0
148
+
149
+
150
+ if __name__ == "__main__":
151
+ raise SystemExit(main())
@@ -0,0 +1,96 @@
1
+ """Ollama discovery — is it installed, is it running, which models exist.
2
+
3
+ Model Fabric PR-C. Feeds three consumers: the `npx arkaos models` CLI
4
+ (show what the machine can actually run), the `/arka-fusion` advisor
5
+ (recommend local models for panel/mechanical roles), and the dashboard
6
+ Models page. Read-only: never starts the daemon, never pulls models.
7
+
8
+ Detection is two-layered on purpose: the binary can be installed while
9
+ the daemon is stopped (installed=True, running=False lets the advisor
10
+ say "start Ollama to unlock N local models" instead of pretending
11
+ Ollama does not exist).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import os
18
+ import shutil
19
+ import urllib.error
20
+ import urllib.request
21
+ from dataclasses import asdict, dataclass, field
22
+
23
+ _DEFAULT_HOST = "http://localhost:11434"
24
+ _TAGS_TIMEOUT_S = 2.0
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class OllamaModel:
29
+ """One locally available model as reported by /api/tags."""
30
+
31
+ name: str
32
+ size_gb: float
33
+ family: str
34
+ parameter_size: str
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class OllamaStatus:
39
+ """Discovery result for the local Ollama installation."""
40
+
41
+ installed: bool
42
+ running: bool
43
+ host: str
44
+ models: list[OllamaModel] = field(default_factory=list)
45
+
46
+ def to_dict(self) -> dict:
47
+ return asdict(self)
48
+
49
+
50
+ def _host() -> str:
51
+ return os.environ.get("OLLAMA_HOST", _DEFAULT_HOST).rstrip("/")
52
+
53
+
54
+ def binary_installed() -> bool:
55
+ """True when the `ollama` binary is on PATH."""
56
+ return shutil.which("ollama") is not None
57
+
58
+
59
+ def _fetch_tags(host: str) -> list[dict] | None:
60
+ """GET /api/tags; None when the daemon is unreachable."""
61
+ try:
62
+ with urllib.request.urlopen(
63
+ f"{host}/api/tags", timeout=_TAGS_TIMEOUT_S
64
+ ) as response:
65
+ data = json.loads(response.read().decode("utf-8"))
66
+ except (urllib.error.URLError, TimeoutError, OSError,
67
+ json.JSONDecodeError):
68
+ return None
69
+ models = data.get("models")
70
+ return models if isinstance(models, list) else []
71
+
72
+
73
+ def _parse_model(record: dict) -> OllamaModel:
74
+ details = record.get("details") or {}
75
+ size_bytes = int(record.get("size", 0) or 0)
76
+ return OllamaModel(
77
+ name=str(record.get("name", "")),
78
+ size_gb=round(size_bytes / 1_000_000_000, 1),
79
+ family=str(details.get("family", "") or ""),
80
+ parameter_size=str(details.get("parameter_size", "") or ""),
81
+ )
82
+
83
+
84
+ def discover() -> OllamaStatus:
85
+ """Full discovery: binary on PATH, daemon reachable, model list."""
86
+ host = _host()
87
+ tags = _fetch_tags(host)
88
+ if tags is None:
89
+ return OllamaStatus(
90
+ installed=binary_installed(), running=False, host=host
91
+ )
92
+ models = [_parse_model(r) for r in tags if r.get("name")]
93
+ models.sort(key=lambda m: m.name)
94
+ # Daemon answering means Ollama is effectively installed even if the
95
+ # binary is not on this shell's PATH (e.g. Docker deployment).
96
+ return OllamaStatus(installed=True, running=True, host=host, models=models)