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.
Files changed (144) hide show
  1. package/VERSION +1 -1
  2. package/arka/skills/flow/SKILL.md +15 -0
  3. package/config/constitution.yaml +15 -7
  4. package/config/hooks/post-tool-use.sh +16 -1
  5. package/config/hooks/pre-tool-use.sh +16 -1
  6. package/config/hooks/session-start.sh +22 -0
  7. package/config/hooks/stop.sh +16 -1
  8. package/config/hooks/user-prompt-submit.sh +16 -1
  9. package/config/models.yaml +70 -0
  10. package/core/__pycache__/__init__.cpython-314.pyc +0 -0
  11. package/core/cognition/__pycache__/dreaming.cpython-313.pyc +0 -0
  12. package/core/cognition/scheduler/__pycache__/daemon.cpython-313.pyc +0 -0
  13. package/core/forge/__pycache__/__init__.cpython-313.pyc +0 -0
  14. package/core/forge/__pycache__/orchestrator.cpython-313.pyc +0 -0
  15. package/core/forge/__pycache__/persistence.cpython-313.pyc +0 -0
  16. package/core/forge/__pycache__/renderer.cpython-313.pyc +0 -0
  17. package/core/forge/__pycache__/runtime_dispatcher.cpython-313.pyc +0 -0
  18. package/core/forge/__pycache__/schema.cpython-313.pyc +0 -0
  19. package/core/governance/__pycache__/__init__.cpython-314.pyc +0 -0
  20. package/core/governance/__pycache__/closing_marker_check.cpython-313.pyc +0 -0
  21. package/core/governance/__pycache__/closing_marker_check.cpython-314.pyc +0 -0
  22. package/core/governance/__pycache__/compliance_telemetry.cpython-313.pyc +0 -0
  23. package/core/governance/__pycache__/constitution.cpython-314.pyc +0 -0
  24. package/core/governance/__pycache__/evidence_checks.cpython-313.pyc +0 -0
  25. package/core/governance/__pycache__/kb_cite_check.cpython-314.pyc +0 -0
  26. package/core/governance/__pycache__/meta_tag_check.cpython-313.pyc +0 -0
  27. package/core/governance/__pycache__/meta_tag_check.cpython-314.pyc +0 -0
  28. package/core/governance/__pycache__/qg_verdict.cpython-313.pyc +0 -0
  29. package/core/governance/__pycache__/review_workflow.cpython-313.pyc +0 -0
  30. package/core/governance/__pycache__/skill_proposer.cpython-313.pyc +0 -0
  31. package/core/governance/__pycache__/skill_proposer.cpython-314.pyc +0 -0
  32. package/core/governance/__pycache__/sycophancy_detector.cpython-314.pyc +0 -0
  33. package/core/hooks/__pycache__/__init__.cpython-312.pyc +0 -0
  34. package/core/hooks/__pycache__/__init__.cpython-313.pyc +0 -0
  35. package/core/hooks/__pycache__/__init__.cpython-314.pyc +0 -0
  36. package/core/hooks/__pycache__/_shared.cpython-312.pyc +0 -0
  37. package/core/hooks/__pycache__/_shared.cpython-313.pyc +0 -0
  38. package/core/hooks/__pycache__/_shared.cpython-314.pyc +0 -0
  39. package/core/hooks/__pycache__/post_tool_use.cpython-312.pyc +0 -0
  40. package/core/hooks/__pycache__/post_tool_use.cpython-313.pyc +0 -0
  41. package/core/hooks/__pycache__/pre_tool_use.cpython-312.pyc +0 -0
  42. package/core/hooks/__pycache__/pre_tool_use.cpython-313.pyc +0 -0
  43. package/core/hooks/__pycache__/pre_tool_use.cpython-314.pyc +0 -0
  44. package/core/hooks/__pycache__/stop.cpython-312.pyc +0 -0
  45. package/core/hooks/__pycache__/stop.cpython-313.pyc +0 -0
  46. package/core/hooks/__pycache__/stop.cpython-314.pyc +0 -0
  47. package/core/hooks/__pycache__/user_prompt_submit.cpython-313.pyc +0 -0
  48. package/core/hooks/pre_tool_use.py +55 -3
  49. package/core/jobs/__pycache__/__init__.cpython-314.pyc +0 -0
  50. package/core/jobs/__pycache__/auto_doc_worker.cpython-314.pyc +0 -0
  51. package/core/jobs/__pycache__/manager.cpython-314.pyc +0 -0
  52. package/core/knowledge/__pycache__/embedder.cpython-312.pyc +0 -0
  53. package/core/knowledge/__pycache__/embedder.cpython-313.pyc +0 -0
  54. package/core/knowledge/__pycache__/vector_store.cpython-312.pyc +0 -0
  55. package/core/knowledge/__pycache__/vector_store.cpython-313.pyc +0 -0
  56. package/core/memory/__pycache__/session_store.cpython-314.pyc +0 -0
  57. package/core/obsidian/__pycache__/relator.cpython-313.pyc +0 -0
  58. package/core/obsidian/relator.py +18 -8
  59. package/core/runtime/__pycache__/__init__.cpython-314.pyc +0 -0
  60. package/core/runtime/__pycache__/base.cpython-312.pyc +0 -0
  61. package/core/runtime/__pycache__/base.cpython-313.pyc +0 -0
  62. package/core/runtime/__pycache__/base.cpython-314.pyc +0 -0
  63. package/core/runtime/__pycache__/capabilities_cli.cpython-313.pyc +0 -0
  64. package/core/runtime/__pycache__/claude_code.cpython-312.pyc +0 -0
  65. package/core/runtime/__pycache__/claude_code.cpython-313.pyc +0 -0
  66. package/core/runtime/__pycache__/claude_code.cpython-314.pyc +0 -0
  67. package/core/runtime/__pycache__/codex_cli.cpython-312.pyc +0 -0
  68. package/core/runtime/__pycache__/codex_cli.cpython-313.pyc +0 -0
  69. package/core/runtime/__pycache__/codex_cli.cpython-314.pyc +0 -0
  70. package/core/runtime/__pycache__/cost_governor.cpython-313.pyc +0 -0
  71. package/core/runtime/__pycache__/cursor.cpython-312.pyc +0 -0
  72. package/core/runtime/__pycache__/cursor.cpython-313.pyc +0 -0
  73. package/core/runtime/__pycache__/cursor.cpython-314.pyc +0 -0
  74. package/core/runtime/__pycache__/gemini_cli.cpython-312.pyc +0 -0
  75. package/core/runtime/__pycache__/gemini_cli.cpython-313.pyc +0 -0
  76. package/core/runtime/__pycache__/gemini_cli.cpython-314.pyc +0 -0
  77. package/core/runtime/__pycache__/llm_cost_telemetry.cpython-314.pyc +0 -0
  78. package/core/runtime/__pycache__/llm_provider.cpython-312.pyc +0 -0
  79. package/core/runtime/__pycache__/llm_provider.cpython-313.pyc +0 -0
  80. package/core/runtime/__pycache__/llm_provider.cpython-314.pyc +0 -0
  81. package/core/runtime/__pycache__/llm_retry.cpython-312.pyc +0 -0
  82. package/core/runtime/__pycache__/llm_retry.cpython-313.pyc +0 -0
  83. package/core/runtime/__pycache__/llm_retry.cpython-314.pyc +0 -0
  84. package/core/runtime/__pycache__/model_router.cpython-313.pyc +0 -0
  85. package/core/runtime/__pycache__/model_router_cli.cpython-313.pyc +0 -0
  86. package/core/runtime/__pycache__/native_usage.cpython-312.pyc +0 -0
  87. package/core/runtime/__pycache__/native_usage.cpython-313.pyc +0 -0
  88. package/core/runtime/__pycache__/native_usage.cpython-314.pyc +0 -0
  89. package/core/runtime/__pycache__/ollama_provider.cpython-314.pyc +0 -0
  90. package/core/runtime/__pycache__/openrouter_provider.cpython-313.pyc +0 -0
  91. package/core/runtime/__pycache__/openrouter_provider.cpython-314.pyc +0 -0
  92. package/core/runtime/__pycache__/pricing.cpython-312.pyc +0 -0
  93. package/core/runtime/__pycache__/pricing.cpython-313.pyc +0 -0
  94. package/core/runtime/__pycache__/pricing.cpython-314.pyc +0 -0
  95. package/core/runtime/__pycache__/registry.cpython-312.pyc +0 -0
  96. package/core/runtime/__pycache__/registry.cpython-313.pyc +0 -0
  97. package/core/runtime/__pycache__/registry.cpython-314.pyc +0 -0
  98. package/core/runtime/llm_provider.py +10 -1
  99. package/core/runtime/model_router.py +204 -0
  100. package/core/runtime/model_router_cli.py +82 -0
  101. package/core/runtime/openrouter_provider.py +172 -0
  102. package/core/shared/__pycache__/__init__.cpython-314.pyc +0 -0
  103. package/core/shared/__pycache__/safe_session_id.cpython-314.pyc +0 -0
  104. package/core/shared/__pycache__/test_evidence.cpython-313-pytest-9.1.1.pyc +0 -0
  105. package/core/shared/__pycache__/test_evidence.cpython-313.pyc +0 -0
  106. package/core/synapse/__pycache__/__init__.cpython-314.pyc +0 -0
  107. package/core/synapse/__pycache__/cache.cpython-314.pyc +0 -0
  108. package/core/synapse/__pycache__/engine.cpython-312.pyc +0 -0
  109. package/core/synapse/__pycache__/engine.cpython-313.pyc +0 -0
  110. package/core/synapse/__pycache__/engine.cpython-314.pyc +0 -0
  111. package/core/synapse/__pycache__/graph_context_layer.cpython-313.pyc +0 -0
  112. package/core/synapse/__pycache__/kb_cache.cpython-312.pyc +0 -0
  113. package/core/synapse/__pycache__/kb_cache.cpython-313.pyc +0 -0
  114. package/core/synapse/__pycache__/kb_cache.cpython-314.pyc +0 -0
  115. package/core/synapse/__pycache__/layers.cpython-312.pyc +0 -0
  116. package/core/synapse/__pycache__/layers.cpython-313.pyc +0 -0
  117. package/core/synapse/__pycache__/layers.cpython-314.pyc +0 -0
  118. package/core/sync/__pycache__/__init__.cpython-312.pyc +0 -0
  119. package/core/sync/__pycache__/engine.cpython-312.pyc +0 -0
  120. package/core/sync/__pycache__/manifest.cpython-312.pyc +0 -0
  121. package/core/workflow/__pycache__/__init__.cpython-314.pyc +0 -0
  122. package/core/workflow/__pycache__/enforcer.cpython-313.pyc +0 -0
  123. package/core/workflow/__pycache__/engine.cpython-314.pyc +0 -0
  124. package/core/workflow/__pycache__/flow_enforcer.cpython-313.pyc +0 -0
  125. package/core/workflow/__pycache__/flow_enforcer.cpython-314.pyc +0 -0
  126. package/core/workflow/__pycache__/frontend_gate.cpython-313.pyc +0 -0
  127. package/core/workflow/__pycache__/gate_checkpoint.cpython-313.pyc +0 -0
  128. package/core/workflow/__pycache__/gate_checkpoint.cpython-314.pyc +0 -0
  129. package/core/workflow/__pycache__/loader.cpython-314.pyc +0 -0
  130. package/core/workflow/__pycache__/marker_cache.cpython-314.pyc +0 -0
  131. package/core/workflow/__pycache__/research_gate.cpython-313.pyc +0 -0
  132. package/core/workflow/__pycache__/research_gate.cpython-314.pyc +0 -0
  133. package/core/workflow/__pycache__/rules_registry.cpython-313.pyc +0 -0
  134. package/core/workflow/__pycache__/schema.cpython-314.pyc +0 -0
  135. package/core/workflow/__pycache__/specialist_enforcer.cpython-313.pyc +0 -0
  136. package/core/workflow/__pycache__/state.cpython-314.pyc +0 -0
  137. package/core/workflow/frontend_gate.py +162 -0
  138. package/installer/autostart.js +16 -2
  139. package/installer/cli.js +23 -0
  140. package/installer/keys.js +1 -0
  141. package/package.json +1 -1
  142. package/pyproject.toml +3 -1
  143. package/scripts/__pycache__/synapse-bridge.cpython-313.pyc +0 -0
  144. package/scripts/start-dashboard.sh +25 -1
@@ -32,7 +32,9 @@ from core.runtime.pricing import estimate_cost_usd
32
32
 
33
33
 
34
34
  _DEFAULT_CONFIG_PATH = Path.home() / ".arkaos" / "config.json"
35
- _FALLBACK_ORDER: tuple[str, ...] = ("subagent", "ollama", "anthropic-direct", "stub")
35
+ _FALLBACK_ORDER: tuple[str, ...] = (
36
+ "subagent", "ollama", "openrouter", "anthropic-direct", "stub"
37
+ )
36
38
 
37
39
 
38
40
  # ─── Public dataclass ─────────────────────────────────────────────────
@@ -330,9 +332,16 @@ def _import_ollama_provider() -> type:
330
332
  return OllamaProvider
331
333
 
332
334
 
335
+ def _import_openrouter_provider() -> type:
336
+ """Lazy import to keep the OpenRouter provider optional at runtime."""
337
+ from core.runtime.openrouter_provider import OpenRouterProvider
338
+ return OpenRouterProvider
339
+
340
+
333
341
  _PROVIDERS: dict[str, type] = {
334
342
  "subagent": SubagentProvider,
335
343
  "ollama": _import_ollama_provider(),
344
+ "openrouter": _import_openrouter_provider(),
336
345
  "anthropic-direct": AnthropicDirectProvider,
337
346
  "stub": StubProvider,
338
347
  }
@@ -0,0 +1,204 @@
1
+ """Model Fabric router — resolve a work role to (provider, model, effort).
2
+
3
+ The user owns ``~/.arkaos/models.yaml``; the packaged default ships at
4
+ ``config/models.yaml``. Roles govern everything ArkaOS dispatches
5
+ (subagents, Quality Gate, Forge, fusion, cognition, daemons) — the
6
+ interactive runtime keeps its own main-loop model.
7
+
8
+ Constitution ``model-routing`` (Excellence Reform 2026-07-05): quality-
9
+ critical roles (design, review, architecture, strategy, quality_gate)
10
+ default to the best model available at maximum effort; only genuinely
11
+ mechanical work economises. The built-in fallback used when no YAML can
12
+ be read encodes the same posture — a missing file never silently
13
+ downgrades a quality role.
14
+
15
+ Public API::
16
+
17
+ resolve("review") -> ResolvedModel(provider, model, effort)
18
+ load_config() -> ModelsConfig
19
+ ensure_user_config() -> Path (creates ~/.arkaos/models.yaml)
20
+ set_role("review", "anthropic/best", effort="max")
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import shutil
26
+ from pathlib import Path
27
+
28
+ import yaml
29
+ from pydantic import BaseModel, Field, field_validator
30
+
31
+ USER_CONFIG_PATH = Path.home() / ".arkaos" / "models.yaml"
32
+
33
+ QUALITY_ROLES = frozenset({
34
+ "design", "review", "architecture", "strategy", "quality_gate",
35
+ })
36
+ KNOWN_EFFORTS = ("low", "medium", "high", "max")
37
+
38
+ _ALIAS_NAMES = frozenset({"best", "default", "fast"})
39
+
40
+
41
+ class RoleChoice(BaseModel):
42
+ """One role entry as written in models.yaml."""
43
+
44
+ provider: str = "runtime"
45
+ model: str = "best"
46
+ effort: str = "high"
47
+
48
+ @field_validator("effort")
49
+ @classmethod
50
+ def _known_effort(cls, value: str) -> str:
51
+ if value not in KNOWN_EFFORTS:
52
+ raise ValueError(
53
+ f"effort must be one of {KNOWN_EFFORTS}, got {value!r}"
54
+ )
55
+ return value
56
+
57
+
58
+ class FusionConfig(BaseModel):
59
+ """Judge + panel for the fusion engine (PR-D consumes this)."""
60
+
61
+ judge: RoleChoice = Field(default_factory=lambda: RoleChoice(effort="max"))
62
+ panel: list[RoleChoice] = Field(default_factory=list)
63
+
64
+
65
+ class ModelsConfig(BaseModel):
66
+ """Validated shape of models.yaml."""
67
+
68
+ version: int = 1
69
+ providers: dict[str, dict] = Field(default_factory=dict)
70
+ aliases: dict[str, dict[str, str]] = Field(default_factory=dict)
71
+ roles: dict[str, RoleChoice] = Field(default_factory=dict)
72
+ fusion: FusionConfig = Field(default_factory=FusionConfig)
73
+
74
+
75
+ class ResolvedModel(BaseModel):
76
+ """A role after alias resolution — what a dispatcher actually uses."""
77
+
78
+ role: str
79
+ provider: str
80
+ model: str
81
+ effort: str
82
+ source: str # "user" | "packaged" | "builtin"
83
+
84
+
85
+ def _packaged_config_path() -> Path:
86
+ return Path(__file__).resolve().parents[2] / "config" / "models.yaml"
87
+
88
+
89
+ def _builtin_config() -> ModelsConfig:
90
+ """Quality-first posture used when no YAML is readable at all."""
91
+ roles = {
92
+ role: RoleChoice(model="best", effort="max") for role in QUALITY_ROLES
93
+ }
94
+ roles["execution"] = RoleChoice(model="default", effort="high")
95
+ roles["mechanical"] = RoleChoice(model="fast", effort="low")
96
+ return ModelsConfig(
97
+ providers={"runtime": {"type": "subagent"}},
98
+ aliases={"runtime": {"best": "opus", "default": "sonnet", "fast": "haiku"}},
99
+ roles=roles,
100
+ )
101
+
102
+
103
+ def _read_yaml(path: Path) -> ModelsConfig | None:
104
+ try:
105
+ data = yaml.safe_load(path.read_text(encoding="utf-8"))
106
+ except (OSError, yaml.YAMLError):
107
+ return None
108
+ if not isinstance(data, dict):
109
+ return None
110
+ try:
111
+ return ModelsConfig.model_validate(data)
112
+ except ValueError:
113
+ return None
114
+
115
+
116
+ def load_config(user_path: Path | None = None) -> tuple[ModelsConfig, str]:
117
+ """Load config with provenance: user file, packaged default, builtin."""
118
+ path = user_path or USER_CONFIG_PATH
119
+ config = _read_yaml(path) if path.is_file() else None
120
+ if config is not None:
121
+ return config, "user"
122
+ packaged = _packaged_config_path()
123
+ config = _read_yaml(packaged) if packaged.is_file() else None
124
+ if config is not None:
125
+ return config, "packaged"
126
+ return _builtin_config(), "builtin"
127
+
128
+
129
+ def _resolve_alias(config: ModelsConfig, provider: str, model: str) -> str:
130
+ if model not in _ALIAS_NAMES:
131
+ return model
132
+ return config.aliases.get(provider, {}).get(model, "") or model
133
+
134
+
135
+ def resolve(role: str, user_path: Path | None = None) -> ResolvedModel:
136
+ """Resolve a role name to a concrete (provider, model, effort).
137
+
138
+ Unknown roles resolve conservatively: quality posture, not economy —
139
+ a typo must never silently land on the cheap tier
140
+ (``excellence-mandate``).
141
+ """
142
+ config, source = load_config(user_path)
143
+ choice = config.roles.get(role)
144
+ if choice is None:
145
+ fallback = "execution" if role not in QUALITY_ROLES else role
146
+ choice = config.roles.get(fallback, RoleChoice(model="best", effort="max"))
147
+ return ResolvedModel(
148
+ role=role,
149
+ provider=choice.provider,
150
+ model=_resolve_alias(config, choice.provider, choice.model),
151
+ effort=choice.effort,
152
+ source=source,
153
+ )
154
+
155
+
156
+ def resolve_all(user_path: Path | None = None) -> list[ResolvedModel]:
157
+ """Resolve every configured role — the CLI table."""
158
+ config, _ = load_config(user_path)
159
+ return [resolve(role, user_path) for role in sorted(config.roles)]
160
+
161
+
162
+ def ensure_user_config(user_path: Path | None = None) -> Path:
163
+ """Create ~/.arkaos/models.yaml from the packaged default if absent."""
164
+ path = user_path or USER_CONFIG_PATH
165
+ if path.is_file():
166
+ return path
167
+ path.parent.mkdir(parents=True, exist_ok=True)
168
+ packaged = _packaged_config_path()
169
+ if packaged.is_file():
170
+ shutil.copy(packaged, path)
171
+ else:
172
+ path.write_text(
173
+ yaml.safe_dump(_builtin_config().model_dump(), sort_keys=False),
174
+ encoding="utf-8",
175
+ )
176
+ return path
177
+
178
+
179
+ def set_role(
180
+ role: str,
181
+ target: str,
182
+ effort: str | None = None,
183
+ user_path: Path | None = None,
184
+ ) -> ResolvedModel:
185
+ """Point a role at ``provider/model`` (e.g. ``anthropic/best``).
186
+
187
+ Writes the user file (creating it first when needed) and returns the
188
+ new resolution. Raises ValueError on a malformed target or effort.
189
+ """
190
+ if "/" not in target:
191
+ raise ValueError(
192
+ f"target must be provider/model (e.g. runtime/best), got {target!r}"
193
+ )
194
+ provider, model = target.split("/", 1)
195
+ path = ensure_user_config(user_path)
196
+ data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
197
+ entry = {"provider": provider, "model": model}
198
+ entry["effort"] = effort or data.get("roles", {}).get(role, {}).get(
199
+ "effort", "max" if role in QUALITY_ROLES else "high"
200
+ )
201
+ RoleChoice.model_validate(entry) # fail before writing anything
202
+ data.setdefault("roles", {})[role] = entry
203
+ path.write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8")
204
+ return resolve(role, path)
@@ -0,0 +1,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
+ )