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.
- package/README.md +1 -1
- package/VERSION +1 -1
- package/arka/SKILL.md +1 -1
- package/arka/skills/flow/SKILL.md +15 -0
- package/arka/skills/fusion/SKILL.md +91 -0
- package/config/constitution.yaml +15 -7
- package/config/hooks/session-start.sh +22 -0
- package/config/models.yaml +70 -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/hooks/__pycache__/pre_tool_use.cpython-313.pyc +0 -0
- package/core/hooks/__pycache__/pre_tool_use.cpython-314.pyc +0 -0
- package/core/hooks/pre_tool_use.py +55 -3
- package/core/obsidian/__pycache__/relator.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/llm_provider.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/llm_provider.cpython-314.pyc +0 -0
- package/core/runtime/__pycache__/model_router.cpython-313.pyc +0 -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/__pycache__/openrouter_provider.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/openrouter_provider.cpython-314.pyc +0 -0
- package/core/runtime/llm_provider.py +10 -1
- package/core/runtime/model_router.py +204 -0
- package/core/runtime/model_router_cli.py +151 -0
- package/core/runtime/ollama_discovery.py +96 -0
- package/core/runtime/openrouter_provider.py +172 -0
- package/core/sync/__pycache__/__init__.cpython-312.pyc +0 -0
- package/core/sync/__pycache__/engine.cpython-312.pyc +0 -0
- package/core/sync/__pycache__/manifest.cpython-312.pyc +0 -0
- package/core/workflow/__pycache__/frontend_gate.cpython-313.pyc +0 -0
- package/core/workflow/frontend_gate.py +162 -0
- package/dashboard/app/layouts/default.vue +7 -0
- package/dashboard/app/pages/models.vue +404 -0
- package/installer/autostart.js +16 -2
- package/installer/cli.js +23 -0
- package/installer/keys.js +1 -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/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.
|
|
9
|
-
|
|
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
|
-
|
|
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:
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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, ...] = (
|
|
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)
|