arkaos 4.1.0 → 4.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/VERSION +1 -1
- package/arka/skills/flow/SKILL.md +15 -0
- package/config/constitution.yaml +15 -7
- package/config/hooks/post-tool-use.sh +16 -1
- package/config/hooks/pre-tool-use.sh +16 -1
- package/config/hooks/session-start.sh +22 -0
- package/config/hooks/stop.sh +16 -1
- package/config/hooks/user-prompt-submit.sh +16 -1
- package/config/models.yaml +70 -0
- package/core/__pycache__/__init__.cpython-314.pyc +0 -0
- package/core/cognition/__pycache__/dreaming.cpython-313.pyc +0 -0
- package/core/cognition/scheduler/__pycache__/daemon.cpython-313.pyc +0 -0
- package/core/forge/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/forge/__pycache__/orchestrator.cpython-313.pyc +0 -0
- package/core/forge/__pycache__/persistence.cpython-313.pyc +0 -0
- package/core/forge/__pycache__/renderer.cpython-313.pyc +0 -0
- package/core/forge/__pycache__/runtime_dispatcher.cpython-313.pyc +0 -0
- package/core/forge/__pycache__/schema.cpython-313.pyc +0 -0
- package/core/governance/__pycache__/__init__.cpython-314.pyc +0 -0
- package/core/governance/__pycache__/closing_marker_check.cpython-313.pyc +0 -0
- package/core/governance/__pycache__/closing_marker_check.cpython-314.pyc +0 -0
- package/core/governance/__pycache__/compliance_telemetry.cpython-313.pyc +0 -0
- package/core/governance/__pycache__/constitution.cpython-314.pyc +0 -0
- package/core/governance/__pycache__/evidence_checks.cpython-313.pyc +0 -0
- package/core/governance/__pycache__/kb_cite_check.cpython-314.pyc +0 -0
- package/core/governance/__pycache__/meta_tag_check.cpython-313.pyc +0 -0
- package/core/governance/__pycache__/meta_tag_check.cpython-314.pyc +0 -0
- package/core/governance/__pycache__/qg_verdict.cpython-313.pyc +0 -0
- package/core/governance/__pycache__/review_workflow.cpython-313.pyc +0 -0
- package/core/governance/__pycache__/skill_proposer.cpython-313.pyc +0 -0
- package/core/governance/__pycache__/skill_proposer.cpython-314.pyc +0 -0
- package/core/governance/__pycache__/sycophancy_detector.cpython-314.pyc +0 -0
- package/core/hooks/__pycache__/__init__.cpython-312.pyc +0 -0
- package/core/hooks/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/hooks/__pycache__/__init__.cpython-314.pyc +0 -0
- package/core/hooks/__pycache__/_shared.cpython-312.pyc +0 -0
- package/core/hooks/__pycache__/_shared.cpython-313.pyc +0 -0
- package/core/hooks/__pycache__/_shared.cpython-314.pyc +0 -0
- package/core/hooks/__pycache__/post_tool_use.cpython-312.pyc +0 -0
- package/core/hooks/__pycache__/post_tool_use.cpython-313.pyc +0 -0
- package/core/hooks/__pycache__/pre_tool_use.cpython-312.pyc +0 -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/__pycache__/stop.cpython-312.pyc +0 -0
- package/core/hooks/__pycache__/stop.cpython-313.pyc +0 -0
- package/core/hooks/__pycache__/stop.cpython-314.pyc +0 -0
- package/core/hooks/__pycache__/user_prompt_submit.cpython-313.pyc +0 -0
- package/core/hooks/pre_tool_use.py +55 -3
- package/core/jobs/__pycache__/__init__.cpython-314.pyc +0 -0
- package/core/jobs/__pycache__/auto_doc_worker.cpython-314.pyc +0 -0
- package/core/jobs/__pycache__/manager.cpython-314.pyc +0 -0
- package/core/knowledge/__pycache__/embedder.cpython-312.pyc +0 -0
- package/core/knowledge/__pycache__/embedder.cpython-313.pyc +0 -0
- package/core/knowledge/__pycache__/vector_store.cpython-312.pyc +0 -0
- package/core/knowledge/__pycache__/vector_store.cpython-313.pyc +0 -0
- package/core/memory/__pycache__/session_store.cpython-314.pyc +0 -0
- package/core/obsidian/__pycache__/relator.cpython-313.pyc +0 -0
- package/core/obsidian/relator.py +18 -8
- package/core/runtime/__pycache__/__init__.cpython-314.pyc +0 -0
- package/core/runtime/__pycache__/base.cpython-312.pyc +0 -0
- package/core/runtime/__pycache__/base.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/base.cpython-314.pyc +0 -0
- package/core/runtime/__pycache__/capabilities_cli.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/claude_code.cpython-312.pyc +0 -0
- package/core/runtime/__pycache__/claude_code.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/claude_code.cpython-314.pyc +0 -0
- package/core/runtime/__pycache__/codex_cli.cpython-312.pyc +0 -0
- package/core/runtime/__pycache__/codex_cli.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/codex_cli.cpython-314.pyc +0 -0
- package/core/runtime/__pycache__/cost_governor.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/cursor.cpython-312.pyc +0 -0
- package/core/runtime/__pycache__/cursor.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/cursor.cpython-314.pyc +0 -0
- package/core/runtime/__pycache__/gemini_cli.cpython-312.pyc +0 -0
- package/core/runtime/__pycache__/gemini_cli.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/gemini_cli.cpython-314.pyc +0 -0
- package/core/runtime/__pycache__/llm_cost_telemetry.cpython-314.pyc +0 -0
- package/core/runtime/__pycache__/llm_provider.cpython-312.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__/llm_retry.cpython-312.pyc +0 -0
- package/core/runtime/__pycache__/llm_retry.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/llm_retry.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__/native_usage.cpython-312.pyc +0 -0
- package/core/runtime/__pycache__/native_usage.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/native_usage.cpython-314.pyc +0 -0
- package/core/runtime/__pycache__/ollama_provider.cpython-314.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/__pycache__/pricing.cpython-312.pyc +0 -0
- package/core/runtime/__pycache__/pricing.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/pricing.cpython-314.pyc +0 -0
- package/core/runtime/__pycache__/registry.cpython-312.pyc +0 -0
- package/core/runtime/__pycache__/registry.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/registry.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 +82 -0
- package/core/runtime/openrouter_provider.py +172 -0
- package/core/shared/__pycache__/__init__.cpython-314.pyc +0 -0
- package/core/shared/__pycache__/safe_session_id.cpython-314.pyc +0 -0
- package/core/shared/__pycache__/test_evidence.cpython-313-pytest-9.1.1.pyc +0 -0
- package/core/shared/__pycache__/test_evidence.cpython-313.pyc +0 -0
- package/core/synapse/__pycache__/__init__.cpython-314.pyc +0 -0
- package/core/synapse/__pycache__/cache.cpython-314.pyc +0 -0
- package/core/synapse/__pycache__/engine.cpython-312.pyc +0 -0
- package/core/synapse/__pycache__/engine.cpython-313.pyc +0 -0
- package/core/synapse/__pycache__/engine.cpython-314.pyc +0 -0
- package/core/synapse/__pycache__/graph_context_layer.cpython-313.pyc +0 -0
- package/core/synapse/__pycache__/kb_cache.cpython-312.pyc +0 -0
- package/core/synapse/__pycache__/kb_cache.cpython-313.pyc +0 -0
- package/core/synapse/__pycache__/kb_cache.cpython-314.pyc +0 -0
- package/core/synapse/__pycache__/layers.cpython-312.pyc +0 -0
- package/core/synapse/__pycache__/layers.cpython-313.pyc +0 -0
- package/core/synapse/__pycache__/layers.cpython-314.pyc +0 -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__/__init__.cpython-314.pyc +0 -0
- package/core/workflow/__pycache__/enforcer.cpython-313.pyc +0 -0
- package/core/workflow/__pycache__/engine.cpython-314.pyc +0 -0
- package/core/workflow/__pycache__/flow_enforcer.cpython-313.pyc +0 -0
- package/core/workflow/__pycache__/flow_enforcer.cpython-314.pyc +0 -0
- package/core/workflow/__pycache__/frontend_gate.cpython-313.pyc +0 -0
- package/core/workflow/__pycache__/gate_checkpoint.cpython-313.pyc +0 -0
- package/core/workflow/__pycache__/gate_checkpoint.cpython-314.pyc +0 -0
- package/core/workflow/__pycache__/loader.cpython-314.pyc +0 -0
- package/core/workflow/__pycache__/marker_cache.cpython-314.pyc +0 -0
- package/core/workflow/__pycache__/research_gate.cpython-313.pyc +0 -0
- package/core/workflow/__pycache__/research_gate.cpython-314.pyc +0 -0
- package/core/workflow/__pycache__/rules_registry.cpython-313.pyc +0 -0
- package/core/workflow/__pycache__/schema.cpython-314.pyc +0 -0
- package/core/workflow/__pycache__/specialist_enforcer.cpython-313.pyc +0 -0
- package/core/workflow/__pycache__/state.cpython-314.pyc +0 -0
- package/core/workflow/frontend_gate.py +162 -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 +3 -1
- package/scripts/__pycache__/synapse-bridge.cpython-313.pyc +0 -0
- package/scripts/start-dashboard.sh +25 -1
|
Binary file
|
|
Binary file
|
|
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,82 @@
|
|
|
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
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import json
|
|
15
|
+
import sys
|
|
16
|
+
|
|
17
|
+
from core.runtime.model_router import (
|
|
18
|
+
USER_CONFIG_PATH,
|
|
19
|
+
ensure_user_config,
|
|
20
|
+
load_config,
|
|
21
|
+
resolve_all,
|
|
22
|
+
set_role,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _print_table() -> None:
|
|
27
|
+
resolved = resolve_all()
|
|
28
|
+
_, source = load_config()
|
|
29
|
+
print()
|
|
30
|
+
print(" ArkaOS Model Fabric — role routing")
|
|
31
|
+
print(f" config: {USER_CONFIG_PATH if source == 'user' else source}")
|
|
32
|
+
print()
|
|
33
|
+
header = f" {'ROLE':<14} {'PROVIDER':<12} {'MODEL':<34} EFFORT"
|
|
34
|
+
print(header)
|
|
35
|
+
print(" " + "─" * (len(header) - 2))
|
|
36
|
+
for item in resolved:
|
|
37
|
+
model = item.model or "(unset — configure alias)"
|
|
38
|
+
print(f" {item.role:<14} {item.provider:<12} {model:<34} {item.effort}")
|
|
39
|
+
print()
|
|
40
|
+
print(" Change: npx arkaos models set <role> <provider>/<model> [--effort max]")
|
|
41
|
+
print()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _print_json() -> None:
|
|
45
|
+
payload = [item.model_dump() for item in resolve_all()]
|
|
46
|
+
print(json.dumps(payload, indent=2))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def main(argv: list[str] | None = None) -> int:
|
|
50
|
+
parser = argparse.ArgumentParser(prog="arkaos models")
|
|
51
|
+
parser.add_argument("action", nargs="?", default="list",
|
|
52
|
+
choices=["list", "init", "set"])
|
|
53
|
+
parser.add_argument("role", nargs="?")
|
|
54
|
+
parser.add_argument("target", nargs="?")
|
|
55
|
+
parser.add_argument("--effort", choices=["low", "medium", "high", "max"])
|
|
56
|
+
parser.add_argument("--json", action="store_true", dest="as_json")
|
|
57
|
+
args = parser.parse_args(argv)
|
|
58
|
+
|
|
59
|
+
if args.action == "init":
|
|
60
|
+
path = ensure_user_config()
|
|
61
|
+
print(f" ✓ Model Fabric config: {path}")
|
|
62
|
+
return 0
|
|
63
|
+
if args.action == "set":
|
|
64
|
+
if not args.role or not args.target:
|
|
65
|
+
parser.error("set requires: <role> <provider>/<model>")
|
|
66
|
+
try:
|
|
67
|
+
item = set_role(args.role, args.target, effort=args.effort)
|
|
68
|
+
except ValueError as exc:
|
|
69
|
+
print(f" ✗ {exc}", file=sys.stderr)
|
|
70
|
+
return 1
|
|
71
|
+
print(f" ✓ {item.role} -> {item.provider}/{item.model} "
|
|
72
|
+
f"(effort {item.effort})")
|
|
73
|
+
return 0
|
|
74
|
+
if args.as_json:
|
|
75
|
+
_print_json()
|
|
76
|
+
else:
|
|
77
|
+
_print_table()
|
|
78
|
+
return 0
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
if __name__ == "__main__":
|
|
82
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""OpenRouter provider — one API key, hundreds of models.
|
|
2
|
+
|
|
3
|
+
Model Fabric PR-B. One of the backends behind the ``LLMProvider``
|
|
4
|
+
Protocol in ``core/runtime/llm_provider.py``; callers go through
|
|
5
|
+
``get_llm_provider()`` or pass an explicit model resolved by
|
|
6
|
+
``core/runtime/model_router.py`` (e.g. role ``review`` →
|
|
7
|
+
``openrouter/deepseek/deepseek-v4-pro``).
|
|
8
|
+
|
|
9
|
+
OpenAI-compatible ``/api/v1/chat/completions`` via stdlib ``urllib`` —
|
|
10
|
+
no extra dependencies, mirroring ``ollama_provider.py``. Key resolution:
|
|
11
|
+
``OPENROUTER_API_KEY`` env, then ``~/.arkaos/keys.json``. Model from
|
|
12
|
+
constructor, then ``OPENROUTER_MODEL`` env, then
|
|
13
|
+
``models.yaml`` alias ``openrouter.default``.
|
|
14
|
+
|
|
15
|
+
OpenRouter returns token usage (and native cached-token counts for
|
|
16
|
+
providers that support caching) in the ``usage`` block; both are
|
|
17
|
+
forwarded so cost telemetry stays accurate.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import urllib.error
|
|
25
|
+
import urllib.request
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
from core.runtime.llm_provider import LLMResponse, LLMUnavailable
|
|
29
|
+
|
|
30
|
+
_API_BASE = "https://openrouter.ai/api/v1"
|
|
31
|
+
_TIMEOUT_S = 180 # frontier models on long prompts; fusion panels wait
|
|
32
|
+
_KEYS_PATH = Path.home() / ".arkaos" / "keys.json"
|
|
33
|
+
|
|
34
|
+
# Attribution headers — OpenRouter ranks apps by these; harmless otherwise.
|
|
35
|
+
_APP_HEADERS = {
|
|
36
|
+
"HTTP-Referer": "https://github.com/andreagroferreira/arka-os",
|
|
37
|
+
"X-Title": "ArkaOS",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _read_key_from_keys_json() -> str:
|
|
42
|
+
try:
|
|
43
|
+
data = json.loads(_KEYS_PATH.read_text(encoding="utf-8"))
|
|
44
|
+
except (OSError, json.JSONDecodeError):
|
|
45
|
+
return ""
|
|
46
|
+
value = data.get("OPENROUTER_API_KEY", "")
|
|
47
|
+
return value.strip() if isinstance(value, str) else ""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _default_model_from_fabric() -> str:
|
|
51
|
+
"""Resolve the `openrouter.default` alias from models.yaml, if set."""
|
|
52
|
+
try:
|
|
53
|
+
from core.runtime.model_router import load_config
|
|
54
|
+
config, _ = load_config()
|
|
55
|
+
except Exception:
|
|
56
|
+
return ""
|
|
57
|
+
return config.aliases.get("openrouter", {}).get("default", "")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class OpenRouterProvider:
|
|
61
|
+
"""Provider that hits the OpenRouter chat-completions API."""
|
|
62
|
+
|
|
63
|
+
ENV_KEY = "OPENROUTER_API_KEY"
|
|
64
|
+
ENV_MODEL = "OPENROUTER_MODEL"
|
|
65
|
+
|
|
66
|
+
def __init__(self, model: str | None = None, api_base: str | None = None) -> None:
|
|
67
|
+
self._model_override = model
|
|
68
|
+
self._api_base = (api_base or _API_BASE).rstrip("/")
|
|
69
|
+
|
|
70
|
+
def name(self) -> str:
|
|
71
|
+
return "openrouter"
|
|
72
|
+
|
|
73
|
+
def _resolve_key(self) -> str:
|
|
74
|
+
env_value = os.environ.get(self.ENV_KEY, "").strip()
|
|
75
|
+
return env_value or _read_key_from_keys_json()
|
|
76
|
+
|
|
77
|
+
def _resolve_model(self) -> str:
|
|
78
|
+
if self._model_override:
|
|
79
|
+
return self._model_override
|
|
80
|
+
env_value = os.environ.get(self.ENV_MODEL, "").strip()
|
|
81
|
+
return env_value or _default_model_from_fabric()
|
|
82
|
+
|
|
83
|
+
def is_available(self) -> bool:
|
|
84
|
+
"""Static capability check: a key and a model are configured.
|
|
85
|
+
|
|
86
|
+
No network probe — OpenRouter is a paid remote API and this is
|
|
87
|
+
called on every provider-chain walk; a dead network surfaces as
|
|
88
|
+
``LLMUnavailable`` at call time and the chain moves on.
|
|
89
|
+
"""
|
|
90
|
+
return bool(self._resolve_key() and self._resolve_model())
|
|
91
|
+
|
|
92
|
+
def complete(
|
|
93
|
+
self,
|
|
94
|
+
prompt: str,
|
|
95
|
+
*,
|
|
96
|
+
max_tokens: int = 2000,
|
|
97
|
+
system: str = "",
|
|
98
|
+
) -> LLMResponse:
|
|
99
|
+
key = self._resolve_key()
|
|
100
|
+
if not key:
|
|
101
|
+
raise LLMUnavailable(
|
|
102
|
+
"OpenRouter key not configured (set OPENROUTER_API_KEY or "
|
|
103
|
+
"add it via `npx arkaos keys`)"
|
|
104
|
+
)
|
|
105
|
+
model = self._resolve_model()
|
|
106
|
+
if not model:
|
|
107
|
+
raise LLMUnavailable(
|
|
108
|
+
"OpenRouter model not configured (set OPENROUTER_MODEL, pass "
|
|
109
|
+
"model=, or set aliases.openrouter.default in models.yaml)"
|
|
110
|
+
)
|
|
111
|
+
data = self._post_chat(key, self._build_payload(model, prompt, system, max_tokens))
|
|
112
|
+
return self._to_response(data, model)
|
|
113
|
+
|
|
114
|
+
def _build_payload(
|
|
115
|
+
self, model: str, prompt: str, system: str, max_tokens: int
|
|
116
|
+
) -> dict:
|
|
117
|
+
messages: list[dict] = []
|
|
118
|
+
if system:
|
|
119
|
+
messages.append({"role": "system", "content": system})
|
|
120
|
+
messages.append({"role": "user", "content": prompt})
|
|
121
|
+
return {
|
|
122
|
+
"model": model,
|
|
123
|
+
"messages": messages,
|
|
124
|
+
"max_tokens": max_tokens,
|
|
125
|
+
"stream": False,
|
|
126
|
+
"usage": {"include": True}, # cost + cached tokens in response
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
def _post_chat(self, key: str, payload: dict) -> dict:
|
|
130
|
+
request = urllib.request.Request(
|
|
131
|
+
f"{self._api_base}/chat/completions",
|
|
132
|
+
data=json.dumps(payload).encode("utf-8"),
|
|
133
|
+
headers={
|
|
134
|
+
"Content-Type": "application/json",
|
|
135
|
+
"Authorization": f"Bearer {key}",
|
|
136
|
+
**_APP_HEADERS,
|
|
137
|
+
},
|
|
138
|
+
)
|
|
139
|
+
try:
|
|
140
|
+
with urllib.request.urlopen(request, timeout=_TIMEOUT_S) as response:
|
|
141
|
+
raw = response.read().decode("utf-8")
|
|
142
|
+
except urllib.error.HTTPError as exc:
|
|
143
|
+
detail = exc.read().decode("utf-8", errors="replace")[:300]
|
|
144
|
+
raise LLMUnavailable(
|
|
145
|
+
f"OpenRouter HTTP {exc.code}: {detail}"
|
|
146
|
+
) from exc
|
|
147
|
+
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
|
148
|
+
raise LLMUnavailable(f"OpenRouter request failed: {exc}") from exc
|
|
149
|
+
try:
|
|
150
|
+
return json.loads(raw)
|
|
151
|
+
except json.JSONDecodeError as exc:
|
|
152
|
+
raise LLMUnavailable(f"OpenRouter returned invalid JSON: {exc}") from exc
|
|
153
|
+
|
|
154
|
+
def _to_response(self, data: dict, model: str) -> LLMResponse:
|
|
155
|
+
if data.get("error"):
|
|
156
|
+
message = data["error"].get("message", "unknown error")
|
|
157
|
+
raise LLMUnavailable(f"OpenRouter error: {message}")
|
|
158
|
+
choices = data.get("choices") or []
|
|
159
|
+
text = ""
|
|
160
|
+
if choices:
|
|
161
|
+
text = ((choices[0].get("message") or {}).get("content") or "").strip()
|
|
162
|
+
usage = data.get("usage") or {}
|
|
163
|
+
cached = int(
|
|
164
|
+
(usage.get("prompt_tokens_details") or {}).get("cached_tokens", 0) or 0
|
|
165
|
+
)
|
|
166
|
+
return LLMResponse(
|
|
167
|
+
text=text,
|
|
168
|
+
tokens_in=int(usage.get("prompt_tokens", 0) or 0),
|
|
169
|
+
tokens_out=int(usage.get("completion_tokens", 0) or 0),
|
|
170
|
+
cached_tokens=cached,
|
|
171
|
+
model=str(data.get("model") or model),
|
|
172
|
+
)
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|