arkaos 4.2.0 → 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.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  **The Operating System for AI Agent Teams.**
4
4
 
5
- 82 agents. 17 departments. 267 skills. Enterprise frameworks. Multi-runtime. One install.
5
+ 82 agents. 17 departments. 268 skills. Enterprise frameworks. Multi-runtime. One install.
6
6
 
7
7
  ```bash
8
8
  npx arkaos install
package/VERSION CHANGED
@@ -1 +1 @@
1
- 4.2.0
1
+ 4.3.0
package/arka/SKILL.md CHANGED
@@ -24,7 +24,7 @@ does not replace the vault.
24
24
  # ArkaOS — Main Orchestrator
25
25
 
26
26
  > **The Operating System for AI Agent Teams**
27
- > 82 agents. 17 departments. 267 skills. Multi-runtime. Dashboard. Knowledge RAG.
27
+ > 82 agents. 17 departments. 268 skills. Multi-runtime. Dashboard. Knowledge RAG.
28
28
 
29
29
  ## ⛔ Evidence flow — 4 gates (NON-NEGOTIABLE)
30
30
 
@@ -0,0 +1,91 @@
1
+ ---
2
+ name: arka-fusion
3
+ description: >
4
+ Model Fabric advisor and fusion configurator. Discovers what the machine
5
+ can run (Ollama local models, OpenRouter/Anthropic keys), reads the
6
+ current role routing, interviews the user about goals and constraints,
7
+ and recommends — with rationale — which model should run each role and
8
+ which panel+judge combination to use for fusion. Applies the approved
9
+ configuration via `npx arkaos models`. Trigger: "/arka-fusion", "fusion",
10
+ "que modelos usar", "configurar modelos", "model routing".
11
+ allowed-tools: [Read, Bash, AskUserQuestion]
12
+ ---
13
+
14
+ # /arka-fusion — Model Fabric Advisor
15
+
16
+ You are the Model Fabric advisor. Your job: leave the user with a role →
17
+ model mapping and a fusion panel that fit THEIR machine, keys, and goals —
18
+ never a generic default. Constitution `model-routing` sets the posture:
19
+ quality-critical roles get the best model available; only genuinely
20
+ mechanical work economises.
21
+
22
+ ## Phase 1 — Discover (never skip, never assume)
23
+
24
+ Run and read all three:
25
+
26
+ ```bash
27
+ python -m core.runtime.model_router_cli discover # Ollama + local models
28
+ python -m core.runtime.model_router_cli --json # current routing
29
+ python -m core.runtime.model_router_cli usage --period week --json
30
+ ```
31
+
32
+ Also check key presence (never print values): `OPENROUTER_API_KEY` /
33
+ `ANTHROPIC_API_KEY` in env or `~/.arkaos/keys.json`.
34
+
35
+ Interpret the discovery honestly:
36
+ - Ollama **not installed** → local lane unavailable; say what installing
37
+ it would unlock (free mechanical/panel calls, privacy).
38
+ - Ollama installed but **not running** → tell the user to start it; count
39
+ its models as one `ollama serve` away.
40
+ - Ollama running → classify each local model by size/family: coder models
41
+ (qwen-coder, deepseek-coder) suit `mechanical` + code review panels;
42
+ large general models (30B+, kimi cloud) qualify for panel seats;
43
+ sub-8B models are mechanical-only. `:cloud` models are Ollama-proxied
44
+ remote models — panel-grade, not privacy-local.
45
+
46
+ ## Phase 2 — Interview (one AskUserQuestion, then dialogue)
47
+
48
+ Ask about, at most 4 questions, adapting to what discovery found:
49
+ 1. **Prioridade** — máxima qualidade custe o que custar / equilíbrio /
50
+ privacidade local primeiro?
51
+ 2. **Fusion** — ativar painel multi-modelo para trabalho crítico
52
+ (2-5× chamadas por resposta, qualidade validada acima de qualquer
53
+ modelo solo) ou manter single-model por agora?
54
+ 3. **OpenRouter** — se não há chave: quer criar uma? (1 chave → Kimi,
55
+ DeepSeek, GPT, Gemini como participantes de painel.)
56
+ 4. Qualquer ambiguidade real que a discovery levantou.
57
+
58
+ ## Phase 3 — Recommend (framework-backed, named tradeoffs)
59
+
60
+ Produce a table: role → provider/model + ONE-line rationale each. Rules:
61
+ - `design/review/architecture/strategy/quality_gate` → best frontier
62
+ model available. NEVER a local small model, even if the user leans
63
+ cost-sensitive — offer fusion-with-budget-panel instead (OpenRouter
64
+ evidence: budget panel + frontier judge beats frontier solo at ~50%
65
+ cost).
66
+ - `mechanical` → cheapest competent lane: local coder model if Ollama
67
+ runs one, else haiku-class.
68
+ - Fusion panel: 2-3 DIVERSE models (different families — e.g. one
69
+ Anthropic, one Kimi/DeepSeek, one local large) + frontier judge.
70
+ Homogeneous panels waste the fan-out.
71
+ - Cite `usage` data when it argues for a change ("80% of your tokens are
72
+ mechanical → moving them local saves X").
73
+
74
+ ## Phase 4 — Apply (only after explicit approval)
75
+
76
+ ```bash
77
+ npx arkaos models set <role> <provider>/<model> --effort <effort>
78
+ ```
79
+
80
+ For fusion panel/judge and aliases, edit `~/.arkaos/models.yaml` directly
81
+ (keep comments). Show the final `npx arkaos models` table as proof and
82
+ remind: the dashboard Models page shows the same state + live usage.
83
+
84
+ ## Hard rules
85
+
86
+ - Discovery before advice — recommending a model the machine cannot run
87
+ is a constitution breach (`context-verification`).
88
+ - Never print key values. Presence only.
89
+ - Quality roles never downgrade below frontier (`model-routing`,
90
+ `excellence-mandate`). Push back if asked; offer budget fusion instead.
91
+ - Every recommendation carries a rationale the user can disagree with.
@@ -0,0 +1,5 @@
1
+ """Fusion engine — panel of models answers, a judge synthesises."""
2
+
3
+ from core.fusion.engine import FusionResult, FusionUnavailable, fuse
4
+
5
+ __all__ = ["FusionResult", "FusionUnavailable", "fuse"]
@@ -0,0 +1,37 @@
1
+ """Fusion CLI — smoke path for the panel → judge pipeline.
2
+
3
+ Usage::
4
+
5
+ python -m core.fusion.cli "your question"
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import sys
11
+
12
+ from core.fusion.engine import FusionUnavailable, fuse
13
+
14
+
15
+ def main(argv: list[str] | None = None) -> int:
16
+ args = argv if argv is not None else sys.argv[1:]
17
+ if not args:
18
+ print("usage: python -m core.fusion.cli \"question\"", file=sys.stderr)
19
+ return 1
20
+ prompt = " ".join(args)
21
+ try:
22
+ result = fuse(prompt)
23
+ except FusionUnavailable as exc:
24
+ print(f" ✗ {exc}", file=sys.stderr)
25
+ return 1
26
+ seats = ", ".join(
27
+ f"{a.provider}/{a.model}" + (" (failed)" if a.failed else "")
28
+ for a in result.answers
29
+ )
30
+ print(f"\n Fusion — judge {result.judge_provider}/{result.judge_model} "
31
+ f"| panel: {seats}\n")
32
+ print(result.text)
33
+ return 0
34
+
35
+
36
+ if __name__ == "__main__":
37
+ raise SystemExit(main())
@@ -0,0 +1,153 @@
1
+ """Fusion engine — panel → judge → synthesis (Model Fabric PR-D).
2
+
3
+ Pattern validated by OpenRouter's DRACO results: a panel of diverse
4
+ models answers the same prompt in parallel; a judge model receives every
5
+ answer, analyses consensus, contradictions and blind spots, and writes
6
+ the final synthesis. A budget panel + frontier judge beats frontier solo
7
+ at roughly half the cost; self-fusion alone gains several points.
8
+
9
+ Configuration lives in ``models.yaml`` (``fusion.judge`` +
10
+ ``fusion.panel`` — see ``core/runtime/model_router.py``). An empty panel
11
+ means fusion is disabled; callers get ``FusionUnavailable`` and fall
12
+ back to single-model completion.
13
+
14
+ Consumers: Forge complex/super tiers, QG adversarial review, and the
15
+ ``python -m core.fusion.cli`` smoke path used by ``/arka-fusion``.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from concurrent.futures import ThreadPoolExecutor
21
+ from dataclasses import dataclass, field
22
+
23
+ from core.runtime.llm_provider import LLMResponse, LLMUnavailable
24
+ from core.runtime.model_router import ModelsConfig, RoleChoice, load_config
25
+
26
+ _PANEL_MAX_TOKENS = 2000
27
+ _JUDGE_MAX_TOKENS = 3000
28
+
29
+ _JUDGE_SYSTEM = (
30
+ "You are the judge in a model-fusion panel. You receive one question "
31
+ "and several independent answers from different models. Analyse them: "
32
+ "consensus points, contradictions, partial coverage, unique insights, "
33
+ "blind spots. Then write the best possible final answer grounded in "
34
+ "that analysis. Output ONLY the final answer — no meta-commentary "
35
+ "about the panel."
36
+ )
37
+
38
+
39
+ class FusionUnavailable(RuntimeError):
40
+ """Fusion cannot run: empty panel or no reachable participant."""
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class PanelAnswer:
45
+ provider: str
46
+ model: str
47
+ text: str
48
+ failed: bool = False
49
+ error: str = ""
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class FusionResult:
54
+ text: str
55
+ judge_provider: str
56
+ judge_model: str
57
+ answers: list[PanelAnswer] = field(default_factory=list)
58
+ tokens_in: int = 0
59
+ tokens_out: int = 0
60
+
61
+
62
+ def _provider_for(choice: RoleChoice, config: ModelsConfig):
63
+ """Instantiate the LLM provider for one panel/judge seat."""
64
+ from core.runtime.model_router import _resolve_alias
65
+ model = _resolve_alias(config, choice.provider, choice.model)
66
+ if choice.provider == "ollama":
67
+ from core.runtime.ollama_provider import OllamaProvider
68
+ return OllamaProvider(model=model), model
69
+ if choice.provider == "openrouter":
70
+ from core.runtime.openrouter_provider import OpenRouterProvider
71
+ return OpenRouterProvider(model=model), model
72
+ # runtime / anthropic / anything else: provider chain decides; the
73
+ # model override is advisory (subagent uses the session model).
74
+ from core.runtime.llm_provider import get_llm_provider
75
+ return get_llm_provider(), model
76
+
77
+
78
+ def _ask_participant(
79
+ choice: RoleChoice, config: ModelsConfig, prompt: str, system: str
80
+ ) -> PanelAnswer:
81
+ try:
82
+ provider, model = _provider_for(choice, config)
83
+ response: LLMResponse = provider.complete(
84
+ prompt, max_tokens=_PANEL_MAX_TOKENS, system=system
85
+ )
86
+ if not response.text.strip():
87
+ return PanelAnswer(choice.provider, model, "", failed=True,
88
+ error="empty response")
89
+ return PanelAnswer(choice.provider, model, response.text)
90
+ except LLMUnavailable as exc:
91
+ return PanelAnswer(choice.provider, choice.model, "", failed=True,
92
+ error=str(exc))
93
+ except Exception as exc: # noqa: BLE001 — one dead seat must not kill the panel
94
+ return PanelAnswer(choice.provider, choice.model, "", failed=True,
95
+ error=f"unexpected: {exc}")
96
+
97
+
98
+ def _judge_prompt(prompt: str, answers: list[PanelAnswer]) -> str:
99
+ blocks = []
100
+ for i, answer in enumerate(answers, 1):
101
+ blocks.append(
102
+ f"--- Answer {i} (model: {answer.model}) ---\n{answer.text}"
103
+ )
104
+ return (
105
+ f"QUESTION:\n{prompt}\n\n"
106
+ f"PANEL ANSWERS ({len(answers)}):\n\n" + "\n\n".join(blocks)
107
+ )
108
+
109
+
110
+ def fuse(
111
+ prompt: str,
112
+ *,
113
+ system: str = "",
114
+ config: ModelsConfig | None = None,
115
+ ) -> FusionResult:
116
+ """Run the full panel → judge → synthesis pipeline.
117
+
118
+ Raises FusionUnavailable when the panel is empty (fusion disabled in
119
+ models.yaml) or when every participant fails — the caller falls back
120
+ to a single-model completion, never to silence.
121
+ """
122
+ if config is None:
123
+ config, _ = load_config()
124
+ panel = config.fusion.panel
125
+ if not panel:
126
+ raise FusionUnavailable(
127
+ "fusion panel is empty — configure fusion.panel in models.yaml "
128
+ "(try /arka-fusion for guided setup)"
129
+ )
130
+ with ThreadPoolExecutor(max_workers=max(len(panel), 1)) as pool:
131
+ answers = list(pool.map(
132
+ lambda choice: _ask_participant(choice, config, prompt, system),
133
+ panel,
134
+ ))
135
+ alive = [a for a in answers if not a.failed]
136
+ if not alive:
137
+ details = "; ".join(f"{a.provider}/{a.model}: {a.error}" for a in answers)
138
+ raise FusionUnavailable(f"every panel participant failed — {details}")
139
+
140
+ judge_provider, judge_model = _provider_for(config.fusion.judge, config)
141
+ verdict = judge_provider.complete(
142
+ _judge_prompt(prompt, alive),
143
+ max_tokens=_JUDGE_MAX_TOKENS,
144
+ system=_JUDGE_SYSTEM,
145
+ )
146
+ return FusionResult(
147
+ text=verdict.text,
148
+ judge_provider=config.fusion.judge.provider,
149
+ judge_model=judge_model or verdict.model,
150
+ answers=answers,
151
+ tokens_in=verdict.tokens_in,
152
+ tokens_out=verdict.tokens_out,
153
+ )
@@ -0,0 +1,81 @@
1
+ """Quality Gate redo-loop counter (excellence-mandate, v4.2.0).
2
+
3
+ The constitution caps REJECTED redo cycles at 2 — a third REJECTED
4
+ escalates to the operator with the full verdict instead of another
5
+ silent retry. This module is the mechanical counter behind that rule
6
+ (previously declarative only).
7
+
8
+ State: ``~/.arkaos/quality-gate/redo-counters.json`` keyed by session id.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+
17
+ REDO_CAP = 2
18
+
19
+ _STATE_PATH = Path.home() / ".arkaos" / "quality-gate" / "redo-counters.json"
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class RedoState:
24
+ session_id: str
25
+ count: int
26
+ escalate: bool
27
+
28
+ def to_message(self) -> str:
29
+ if not self.escalate:
30
+ return (
31
+ f"[arka:qg] REJECTED — redo cycle {self.count}/{REDO_CAP}. "
32
+ f"Looping back to execution with the issue list."
33
+ )
34
+ return (
35
+ f"[arka:qg:escalate] REJECTED {self.count} times — cap of "
36
+ f"{REDO_CAP} redo cycles exceeded (excellence-mandate). "
37
+ f"STOP: present the full verdict to the operator and wait for "
38
+ f"a decision. Do not retry silently."
39
+ )
40
+
41
+
42
+ def _load(path: Path) -> dict:
43
+ try:
44
+ data = json.loads(path.read_text(encoding="utf-8"))
45
+ except (OSError, json.JSONDecodeError):
46
+ return {}
47
+ return data if isinstance(data, dict) else {}
48
+
49
+
50
+ def record_rejected(session_id: str, path: Path | None = None) -> RedoState:
51
+ """Increment the REJECTED counter; escalate above the cap."""
52
+ state_path = path or _STATE_PATH
53
+ data = _load(state_path)
54
+ count = int(data.get(session_id, 0) or 0) + 1
55
+ data[session_id] = count
56
+ try:
57
+ state_path.parent.mkdir(parents=True, exist_ok=True)
58
+ state_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
59
+ except OSError:
60
+ pass # counter must never block the gate itself
61
+ return RedoState(session_id=session_id, count=count,
62
+ escalate=count > REDO_CAP)
63
+
64
+
65
+ def reset(session_id: str, path: Path | None = None) -> None:
66
+ """Clear the counter — called on APPROVED."""
67
+ state_path = path or _STATE_PATH
68
+ data = _load(state_path)
69
+ if session_id in data:
70
+ del data[session_id]
71
+ try:
72
+ state_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
73
+ except OSError:
74
+ pass
75
+
76
+
77
+ def current(session_id: str, path: Path | None = None) -> RedoState:
78
+ """Read-only view of the counter."""
79
+ count = int(_load(path or _STATE_PATH).get(session_id, 0) or 0)
80
+ return RedoState(session_id=session_id, count=count,
81
+ escalate=count > REDO_CAP)
@@ -6,6 +6,7 @@ Usage::
6
6
  python -m core.runtime.model_router_cli --json # machine-readable
7
7
  python -m core.runtime.model_router_cli init # create user file
8
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
9
10
  """
10
11
 
11
12
  from __future__ import annotations
@@ -14,6 +15,7 @@ import argparse
14
15
  import json
15
16
  import sys
16
17
 
18
+ from core.runtime.llm_cost_telemetry import VALID_PERIODS, summarise
17
19
  from core.runtime.model_router import (
18
20
  USER_CONFIG_PATH,
19
21
  ensure_user_config,
@@ -21,6 +23,7 @@ from core.runtime.model_router import (
21
23
  resolve_all,
22
24
  set_role,
23
25
  )
26
+ from core.runtime.ollama_discovery import discover
24
27
 
25
28
 
26
29
  def _print_table() -> None:
@@ -37,7 +40,52 @@ def _print_table() -> None:
37
40
  model = item.model or "(unset — configure alias)"
38
41
  print(f" {item.role:<14} {item.provider:<12} {model:<34} {item.effort}")
39
42
  print()
43
+ _print_ollama_line()
40
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}")
41
89
  print()
42
90
 
43
91
 
@@ -49,13 +97,34 @@ def _print_json() -> None:
49
97
  def main(argv: list[str] | None = None) -> int:
50
98
  parser = argparse.ArgumentParser(prog="arkaos models")
51
99
  parser.add_argument("action", nargs="?", default="list",
52
- choices=["list", "init", "set"])
100
+ choices=["list", "init", "set", "usage", "discover"])
53
101
  parser.add_argument("role", nargs="?")
54
102
  parser.add_argument("target", nargs="?")
55
103
  parser.add_argument("--effort", choices=["low", "medium", "high", "max"])
104
+ parser.add_argument("--period", choices=list(VALID_PERIODS), default="today")
56
105
  parser.add_argument("--json", action="store_true", dest="as_json")
57
106
  args = parser.parse_args(argv)
58
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
59
128
  if args.action == "init":
60
129
  path = ensure_user_config()
61
130
  print(f" ✓ Model Fabric config: {path}")
@@ -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)
@@ -52,6 +52,13 @@ const links = [[{
52
52
  onSelect: () => {
53
53
  open.value = false
54
54
  }
55
+ }, {
56
+ label: 'Models',
57
+ icon: 'i-lucide-layers',
58
+ to: '/models',
59
+ onSelect: () => {
60
+ open.value = false
61
+ }
55
62
  }, {
56
63
  label: 'Tasks',
57
64
  icon: 'i-lucide-list-checks',
@@ -0,0 +1,404 @@
1
+ <script setup lang="ts">
2
+ // Model Fabric (PR-C) — who runs what, what this machine offers, where
3
+ // tokens flow. Backed by /api/models, /api/models/role, /api/models/usage.
4
+
5
+ interface ResolvedRole {
6
+ role: string
7
+ provider: string
8
+ model: string
9
+ effort: string
10
+ source: string
11
+ }
12
+
13
+ interface OllamaModel {
14
+ name: string
15
+ size_gb: number
16
+ family: string
17
+ parameter_size: string
18
+ }
19
+
20
+ interface ModelsOverview {
21
+ source: string
22
+ config_path: string
23
+ roles: ResolvedRole[]
24
+ providers: Record<string, { type: string }>
25
+ aliases: Record<string, Record<string, string>>
26
+ fusion: { judge: Record<string, string>, panel: Array<Record<string, string>> }
27
+ ollama: { installed: boolean, running: boolean, host: string, models: OllamaModel[] }
28
+ keys: { openrouter: boolean, anthropic: boolean }
29
+ }
30
+
31
+ interface BreakdownRow {
32
+ total_cost_usd: number | null
33
+ total_tokens_in: number
34
+ total_tokens_out: number
35
+ call_count: number
36
+ }
37
+
38
+ interface UsageSummary {
39
+ period: string
40
+ call_count: number
41
+ total_cost_usd: number | null
42
+ total_tokens_in: number
43
+ total_tokens_out: number
44
+ total_cached_tokens: number
45
+ by_model: Record<string, BreakdownRow>
46
+ by_provider: Record<string, BreakdownRow>
47
+ }
48
+
49
+ type Period = 'today' | 'week' | 'month' | 'all'
50
+
51
+ const { fetchApi, apiBase } = useApi()
52
+ const toast = useToast()
53
+
54
+ const {
55
+ data: overview,
56
+ status,
57
+ error,
58
+ refresh,
59
+ } = fetchApi<ModelsOverview>('/api/models')
60
+
61
+ const period = ref<Period>('week')
62
+ const periodOptions: { label: string, value: Period }[] = [
63
+ { label: 'Today', value: 'today' },
64
+ { label: '7 days', value: 'week' },
65
+ { label: '30 days', value: 'month' },
66
+ { label: 'All time', value: 'all' },
67
+ ]
68
+ const { data: usage, refresh: refreshUsage } = fetchApi<UsageSummary>(
69
+ '/api/models/usage',
70
+ { query: computed(() => ({ period: period.value })) },
71
+ )
72
+ watch(period, () => refreshUsage())
73
+
74
+ // ─── Provider lanes (signature element: live availability strip) ────────
75
+
76
+ const QUALITY_ROLES = new Set(['design', 'review', 'architecture', 'strategy', 'quality_gate'])
77
+
78
+ const laneStyles: Record<string, { badge: string, dot: string }> = {
79
+ runtime: { badge: 'text-primary bg-primary/10', dot: 'bg-primary' },
80
+ ollama: { badge: 'text-amber-600 dark:text-amber-400 bg-amber-500/10', dot: 'bg-amber-500' },
81
+ openrouter: { badge: 'text-violet-600 dark:text-violet-400 bg-violet-500/10', dot: 'bg-violet-500' },
82
+ anthropic: { badge: 'text-sky-600 dark:text-sky-400 bg-sky-500/10', dot: 'bg-sky-500' },
83
+ }
84
+
85
+ function laneStyle(provider: string) {
86
+ return laneStyles[provider] ?? { badge: 'text-muted bg-muted/10', dot: 'bg-muted' }
87
+ }
88
+
89
+ interface Lane {
90
+ id: string
91
+ label: string
92
+ live: boolean
93
+ detail: string
94
+ }
95
+
96
+ const lanes = computed<Lane[]>(() => {
97
+ const o = overview.value
98
+ if (!o) return []
99
+ const ollama = o.ollama ?? { installed: false, running: false, models: [] }
100
+ const keys = o.keys ?? { openrouter: false, anthropic: false }
101
+ const ollamaDetail = ollama.running
102
+ ? `${(ollama.models ?? []).length} local models`
103
+ : ollama.installed ? 'installed, not running' : 'not installed'
104
+ return [
105
+ { id: 'runtime', label: 'Runtime', live: true, detail: 'active CLI session' },
106
+ { id: 'ollama', label: 'Ollama', live: ollama.running, detail: ollamaDetail },
107
+ { id: 'openrouter', label: 'OpenRouter', live: keys.openrouter, detail: keys.openrouter ? 'key configured' : 'no key' },
108
+ { id: 'anthropic', label: 'Anthropic', live: keys.anthropic, detail: keys.anthropic ? 'key configured' : 'no key' },
109
+ ]
110
+ })
111
+
112
+ // ─── Inline role re-routing ──────────────────────────────────────────────
113
+
114
+ const editingRole = ref<string | null>(null)
115
+ const editTarget = ref('')
116
+ const saving = ref(false)
117
+
118
+ const targetOptions = computed(() => {
119
+ const o = overview.value
120
+ if (!o) return []
121
+ const options: { label: string, value: string }[] = []
122
+ for (const alias of ['best', 'default', 'fast']) {
123
+ options.push({ label: `runtime / ${alias}`, value: `runtime/${alias}` })
124
+ }
125
+ for (const m of o.ollama?.models ?? []) {
126
+ options.push({ label: `ollama / ${m.name}`, value: `ollama/${m.name}` })
127
+ }
128
+ if (o.keys?.anthropic) {
129
+ for (const alias of ['best', 'default', 'fast']) {
130
+ options.push({ label: `anthropic / ${alias}`, value: `anthropic/${alias}` })
131
+ }
132
+ }
133
+ if (o.keys?.openrouter) {
134
+ options.push({ label: 'openrouter / (type model id)', value: 'openrouter/' })
135
+ }
136
+ return options
137
+ })
138
+
139
+ function startEdit(row: ResolvedRole) {
140
+ editingRole.value = row.role
141
+ editTarget.value = `${row.provider}/${row.model}`
142
+ }
143
+
144
+ async function saveEdit(role: string) {
145
+ if (!editTarget.value.includes('/')) {
146
+ toast.add({ title: 'Target must be provider/model', color: 'error' })
147
+ return
148
+ }
149
+ saving.value = true
150
+ try {
151
+ await $fetch(`${apiBase.value}/api/models/role`, {
152
+ method: 'POST',
153
+ body: { role, target: editTarget.value },
154
+ })
155
+ toast.add({ title: `${role} re-routed`, description: editTarget.value, color: 'success', icon: 'i-lucide-route' })
156
+ editingRole.value = null
157
+ await refresh()
158
+ } catch (err) {
159
+ toast.add({
160
+ title: 'Re-route failed',
161
+ description: err instanceof Error ? err.message : 'unknown error',
162
+ color: 'error',
163
+ })
164
+ } finally {
165
+ saving.value = false
166
+ }
167
+ }
168
+
169
+ async function useForMechanical(model: OllamaModel) {
170
+ editTarget.value = `ollama/${model.name}`
171
+ await saveEdit('mechanical')
172
+ }
173
+
174
+ // ─── Usage bars ──────────────────────────────────────────────────────────
175
+
176
+ const usageRows = computed<Array<[string, BreakdownRow]>>(() =>
177
+ Object.entries(usage.value?.by_model ?? {}).sort(
178
+ (a, b) => (b[1].total_tokens_in + b[1].total_tokens_out) - (a[1].total_tokens_in + a[1].total_tokens_out),
179
+ ).slice(0, 10),
180
+ )
181
+
182
+ const maxUsageTokens = computed(() => {
183
+ const max = usageRows.value.reduce(
184
+ (acc, [, r]) => Math.max(acc, r.total_tokens_in + r.total_tokens_out), 0,
185
+ )
186
+ return max > 0 ? max : 1
187
+ })
188
+
189
+ function formatTokens(value: number): string {
190
+ if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`
191
+ if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`
192
+ return value.toString()
193
+ }
194
+
195
+ function formatCost(value: number | null): string {
196
+ if (value === null || value === undefined) return 'n/a'
197
+ if (value === 0) return '$0'
198
+ if (value < 0.01) return `$${value.toFixed(4)}`
199
+ return `$${value.toFixed(2)}`
200
+ }
201
+ </script>
202
+
203
+ <template>
204
+ <UDashboardPanel id="models">
205
+ <template #header>
206
+ <UDashboardNavbar title="Models">
207
+ <template #right>
208
+ <UButton
209
+ label="Refresh"
210
+ variant="ghost"
211
+ icon="i-lucide-refresh-cw"
212
+ size="sm"
213
+ @click="() => { refresh(); refreshUsage() }"
214
+ />
215
+ </template>
216
+ </UDashboardNavbar>
217
+ </template>
218
+
219
+ <template #body>
220
+ <DashboardState
221
+ :status="status"
222
+ :error="error"
223
+ loading-label="Loading model fabric"
224
+ :on-retry="() => refresh()"
225
+ >
226
+ <div class="space-y-6">
227
+ <!-- Provider lanes: live availability strip -->
228
+ <UCard>
229
+ <div class="grid grid-cols-2 md:grid-cols-4 gap-4">
230
+ <div
231
+ v-for="lane in lanes"
232
+ :key="lane.id"
233
+ class="flex items-start gap-3"
234
+ >
235
+ <span class="relative flex h-2.5 w-2.5 mt-1.5">
236
+ <span
237
+ v-if="lane.live"
238
+ class="animate-ping absolute inline-flex h-full w-full rounded-full opacity-60 motion-reduce:hidden"
239
+ :class="laneStyle(lane.id).dot"
240
+ />
241
+ <span
242
+ class="relative inline-flex rounded-full h-2.5 w-2.5"
243
+ :class="lane.live ? laneStyle(lane.id).dot : 'bg-muted/40'"
244
+ />
245
+ </span>
246
+ <div>
247
+ <p class="text-sm font-semibold">{{ lane.label }}</p>
248
+ <p class="text-xs text-muted">{{ lane.detail }}</p>
249
+ </div>
250
+ </div>
251
+ </div>
252
+ </UCard>
253
+
254
+ <!-- Role routing -->
255
+ <UCard>
256
+ <template #header>
257
+ <div class="flex items-center justify-between">
258
+ <p class="text-xs font-semibold text-muted uppercase tracking-wider">
259
+ Role routing
260
+ </p>
261
+ <p class="text-xs text-muted font-mono hidden md:block">{{ overview?.config_path }}</p>
262
+ </div>
263
+ </template>
264
+ <div class="divide-y divide-default">
265
+ <div
266
+ v-for="row in overview?.roles ?? []"
267
+ :key="row.role"
268
+ class="flex items-center gap-3 py-2.5"
269
+ >
270
+ <UIcon
271
+ :name="QUALITY_ROLES.has(row.role) ? 'i-lucide-gem' : 'i-lucide-cog'"
272
+ class="size-4 shrink-0"
273
+ :class="QUALITY_ROLES.has(row.role) ? 'text-primary' : 'text-muted'"
274
+ />
275
+ <span class="w-32 shrink-0 text-sm font-medium">{{ row.role }}</span>
276
+ <template v-if="editingRole === row.role">
277
+ <UInputMenu
278
+ v-model="editTarget"
279
+ :items="targetOptions"
280
+ value-key="value"
281
+ create-item
282
+ size="sm"
283
+ class="flex-1 max-w-xs"
284
+ placeholder="provider/model"
285
+ @create="(item: string) => { editTarget = item }"
286
+ />
287
+ <UButton size="xs" label="Save" :loading="saving" @click="saveEdit(row.role)" />
288
+ <UButton size="xs" variant="ghost" label="Cancel" @click="editingRole = null" />
289
+ </template>
290
+ <template v-else>
291
+ <span
292
+ class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium"
293
+ :class="laneStyle(row.provider).badge"
294
+ >
295
+ {{ row.provider }}
296
+ </span>
297
+ <span class="text-sm font-mono truncate">
298
+ {{ row.model || '(unset)' }}
299
+ </span>
300
+ <UBadge variant="subtle" size="sm" color="neutral">{{ row.effort }}</UBadge>
301
+ <UButton
302
+ icon="i-lucide-pencil"
303
+ variant="ghost"
304
+ size="xs"
305
+ class="ml-auto"
306
+ :aria-label="`Edit ${row.role} routing`"
307
+ @click="startEdit(row)"
308
+ />
309
+ </template>
310
+ </div>
311
+ </div>
312
+ </UCard>
313
+
314
+ <div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
315
+ <!-- Local Ollama models -->
316
+ <UCard>
317
+ <template #header>
318
+ <p class="text-xs font-semibold text-muted uppercase tracking-wider">
319
+ Local models (Ollama)
320
+ </p>
321
+ </template>
322
+ <div v-if="overview?.ollama?.running && (overview.ollama?.models ?? []).length" class="divide-y divide-default">
323
+ <div
324
+ v-for="m in (overview.ollama?.models ?? [])"
325
+ :key="m.name"
326
+ class="flex items-center gap-3 py-2"
327
+ >
328
+ <UIcon name="i-lucide-hard-drive" class="size-4 text-amber-500 shrink-0" />
329
+ <div class="min-w-0 flex-1">
330
+ <p class="text-sm font-mono truncate">{{ m.name }}</p>
331
+ <p class="text-xs text-muted">
332
+ {{ m.family || 'unknown family' }}
333
+ <template v-if="m.parameter_size"> · {{ m.parameter_size }}</template>
334
+ <template v-if="m.size_gb"> · {{ m.size_gb }} GB</template>
335
+ <template v-if="!m.size_gb"> · cloud-proxied</template>
336
+ </p>
337
+ </div>
338
+ <UButton
339
+ size="xs"
340
+ variant="soft"
341
+ label="Use for mechanical"
342
+ :loading="saving"
343
+ @click="useForMechanical(m)"
344
+ />
345
+ </div>
346
+ </div>
347
+ <div v-else-if="overview?.ollama?.installed" class="py-6 text-center">
348
+ <p class="text-sm text-muted">Ollama is installed but not running.</p>
349
+ <p class="text-xs text-muted mt-1 font-mono">ollama serve</p>
350
+ </div>
351
+ <div v-else class="py-6 text-center">
352
+ <p class="text-sm text-muted">No local models yet.</p>
353
+ <p class="text-xs text-muted mt-1">
354
+ Install Ollama to run free local models for mechanical work and fusion panels.
355
+ </p>
356
+ </div>
357
+ </UCard>
358
+
359
+ <!-- Usage by model -->
360
+ <UCard>
361
+ <template #header>
362
+ <div class="flex items-center justify-between">
363
+ <p class="text-xs font-semibold text-muted uppercase tracking-wider">
364
+ Usage by model
365
+ </p>
366
+ <USelect v-model="period" :items="periodOptions" size="xs" class="min-w-24" aria-label="Usage period" />
367
+ </div>
368
+ </template>
369
+ <div v-if="usageRows.length" class="space-y-3">
370
+ <div class="flex items-baseline justify-between">
371
+ <p class="text-sm">
372
+ <span class="font-semibold">{{ usage?.call_count ?? 0 }}</span>
373
+ <span class="text-muted"> calls · </span>
374
+ <span class="font-semibold">{{ formatTokens((usage?.total_tokens_in ?? 0) + (usage?.total_tokens_out ?? 0)) }}</span>
375
+ <span class="text-muted"> tokens</span>
376
+ </p>
377
+ <p class="text-sm font-semibold">{{ formatCost(usage?.total_cost_usd ?? null) }}</p>
378
+ </div>
379
+ <div v-for="[name, row] in usageRows" :key="name" class="space-y-1">
380
+ <div class="flex items-center justify-between text-xs">
381
+ <span class="font-mono truncate">{{ name || '(unattributed)' }}</span>
382
+ <span class="text-muted shrink-0 ml-2">
383
+ {{ formatTokens(row.total_tokens_in + row.total_tokens_out) }} · {{ row.call_count }} calls
384
+ </span>
385
+ </div>
386
+ <div class="h-1.5 rounded-full bg-muted/20 overflow-hidden">
387
+ <div
388
+ class="h-full rounded-full bg-primary"
389
+ :style="{ width: `${Math.max(2, ((row.total_tokens_in + row.total_tokens_out) / maxUsageTokens) * 100)}%` }"
390
+ />
391
+ </div>
392
+ </div>
393
+ </div>
394
+ <div v-else class="py-6 text-center">
395
+ <p class="text-sm text-muted">No usage recorded for this period.</p>
396
+ <p class="text-xs text-muted mt-1">Calls made through the Model Fabric land here automatically.</p>
397
+ </div>
398
+ </UCard>
399
+ </div>
400
+ </div>
401
+ </DashboardState>
402
+ </template>
403
+ </UDashboardPanel>
404
+ </template>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arkaos",
3
- "version": "4.2.0",
3
+ "version": "4.3.0",
4
4
  "description": "The Operating System for AI Agent Teams",
5
5
  "type": "module",
6
6
  "bin": {
package/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "arkaos-core"
3
- version = "4.2.0"
3
+ version = "4.3.0"
4
4
  description = "Core engine for ArkaOS — The Operating System for AI Agent Teams"
5
5
  readme = "README.md"
6
6
  license = {text = "MIT"}
@@ -864,6 +864,85 @@ def commands(dept: Optional[str] = Query(None), q: Optional[str] = Query(None)):
864
864
  return {"commands": data, "total": len(data)}
865
865
 
866
866
 
867
+ @app.get("/api/models")
868
+ def models_overview():
869
+ """Model Fabric: roles, providers, Ollama discovery, key presence."""
870
+ from core.runtime.model_router import USER_CONFIG_PATH, load_config, resolve_all
871
+ from core.runtime.ollama_discovery import discover
872
+
873
+ config, source = load_config()
874
+ keys_path = Path.home() / ".arkaos" / "keys.json"
875
+ try:
876
+ keys = json.loads(keys_path.read_text(encoding="utf-8"))
877
+ except (OSError, json.JSONDecodeError):
878
+ keys = {}
879
+ return {
880
+ "source": source,
881
+ "config_path": str(USER_CONFIG_PATH),
882
+ "roles": [item.model_dump() for item in resolve_all()],
883
+ "providers": {
884
+ name: {"type": spec.get("type", "")}
885
+ for name, spec in config.providers.items()
886
+ },
887
+ "aliases": config.aliases,
888
+ "fusion": config.fusion.model_dump(),
889
+ "ollama": discover().to_dict(),
890
+ "keys": {
891
+ "openrouter": bool(
892
+ os.environ.get("OPENROUTER_API_KEY")
893
+ or keys.get("OPENROUTER_API_KEY")
894
+ ),
895
+ "anthropic": bool(os.environ.get("ANTHROPIC_API_KEY")),
896
+ },
897
+ }
898
+
899
+
900
+ @app.post("/api/models/role")
901
+ async def models_set_role(request: Request):
902
+ """Re-route a role: {role, target: 'provider/model', effort?}."""
903
+ from core.runtime.model_router import set_role
904
+
905
+ body = await request.json()
906
+ role = str(body.get("role", "")).strip()
907
+ target = str(body.get("target", "")).strip()
908
+ effort = body.get("effort") or None
909
+ if not role or not target:
910
+ return JSONResponse(
911
+ {"error": "role and target (provider/model) are required"},
912
+ status_code=422,
913
+ )
914
+ try:
915
+ item = set_role(role, target, effort=effort)
916
+ except ValueError as exc:
917
+ return JSONResponse({"error": str(exc)}, status_code=422)
918
+ return item.model_dump()
919
+
920
+
921
+ @app.get("/api/models/usage")
922
+ def models_usage(period: str = "today"):
923
+ """Per-model/provider/category token + cost aggregation."""
924
+ from core.runtime.llm_cost_telemetry import VALID_PERIODS, summarise
925
+
926
+ if period not in VALID_PERIODS:
927
+ return JSONResponse(
928
+ {"error": f"period must be one of {list(VALID_PERIODS)}"},
929
+ status_code=422,
930
+ )
931
+ summary = summarise(period=period)
932
+ return {
933
+ "period": summary.period,
934
+ "call_count": summary.call_count,
935
+ "total_cost_usd": summary.total_cost_usd,
936
+ "total_tokens_in": summary.total_tokens_in,
937
+ "total_tokens_out": summary.total_tokens_out,
938
+ "total_cached_tokens": summary.total_cached_tokens,
939
+ "cache_hit_rate": summary.cache_hit_rate,
940
+ "by_model": summary.by_model,
941
+ "by_provider": summary.by_provider,
942
+ "by_category": summary.by_category,
943
+ }
944
+
945
+
867
946
  @app.get("/api/budget")
868
947
  def budget_all():
869
948
  mgr = _get_budget_manager()