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 +1 -1
- package/VERSION +1 -1
- package/arka/SKILL.md +1 -1
- package/arka/skills/fusion/SKILL.md +91 -0
- package/core/fusion/__init__.py +5 -0
- package/core/fusion/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/fusion/__pycache__/cli.cpython-313.pyc +0 -0
- package/core/fusion/__pycache__/engine.cpython-313.pyc +0 -0
- package/core/fusion/cli.py +37 -0
- package/core/fusion/engine.py +153 -0
- package/core/governance/__pycache__/redo_counter.cpython-313.pyc +0 -0
- package/core/governance/redo_counter.py +81 -0
- package/core/runtime/__pycache__/model_router_cli.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/ollama_discovery.cpython-313.pyc +0 -0
- package/core/runtime/model_router_cli.py +70 -1
- package/core/runtime/ollama_discovery.py +96 -0
- package/dashboard/app/layouts/default.vue +7 -0
- package/dashboard/app/pages/models.vue +404 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/scripts/__pycache__/dashboard-api.cpython-313.pyc +0 -0
- package/scripts/dashboard-api.py +79 -0
package/README.md
CHANGED
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
4.
|
|
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.
|
|
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.
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|
+
)
|
|
Binary file
|
|
@@ -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)
|
|
Binary file
|
|
Binary file
|
|
@@ -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)
|
|
@@ -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
package/pyproject.toml
CHANGED
|
Binary file
|
package/scripts/dashboard-api.py
CHANGED
|
@@ -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()
|