arkaos 4.3.5 → 4.3.6
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/SKILL.md +1 -1
- package/bin/arka-claude +35 -4
- package/core/runtime/__pycache__/model_router.cpython-312.pyc +0 -0
- package/core/runtime/__pycache__/model_router.cpython-314.pyc +0 -0
- package/core/runtime/__pycache__/model_routing_check.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/model_routing_context.cpython-313.pyc +0 -0
- package/core/runtime/gateway/__init__.py +28 -0
- package/core/runtime/gateway/__main__.py +30 -0
- package/core/runtime/gateway/__pycache__/__init__.cpython-312.pyc +0 -0
- package/core/runtime/gateway/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/runtime/gateway/__pycache__/__init__.cpython-314.pyc +0 -0
- package/core/runtime/gateway/__pycache__/__main__.cpython-313.pyc +0 -0
- package/core/runtime/gateway/__pycache__/litellm_config.cpython-312.pyc +0 -0
- package/core/runtime/gateway/__pycache__/litellm_config.cpython-313.pyc +0 -0
- package/core/runtime/gateway/__pycache__/litellm_config.cpython-314.pyc +0 -0
- package/core/runtime/gateway/litellm_config.py +205 -0
- package/core/runtime/model_routing_check.py +71 -0
- package/core/runtime/model_routing_context.py +22 -1
- package/package.json +1 -1
- package/pyproject.toml +7 -1
- package/scripts/gateway-ensure.sh +93 -0
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
4.3.
|
|
1
|
+
4.3.6
|
package/arka/SKILL.md
CHANGED
|
@@ -179,7 +179,7 @@ violation (squad-routing, arka-supremacy, spec-driven, mandatory-qa).
|
|
|
179
179
|
|
|
180
180
|
| Command | Description |
|
|
181
181
|
|---------|-------------|
|
|
182
|
-
| `/arka status` | System status (version, departments, agents, active projects). Includes **LLM costs (24h)** section: top-line cost + cache hit rate + call count from `core.runtime.llm_cost_telemetry.summarise(period="today")`. Also includes **Enforcement (24h)** section: total calls, block rate, top blocked tools/reasons from `core.governance.enforcement_telemetry.summarise(period="today")` (PR19 v2.41.0). Plus **Reorganization (today)** section: today's proposal path + artifact count from `core.cognition.reorganizer_scheduler.status_summary()` (PR24 v2.46.0). |
|
|
182
|
+
| `/arka status` | System status (version, departments, agents, active projects). Includes **LLM costs (24h)** section: top-line cost + cache hit rate + call count from `core.runtime.llm_cost_telemetry.summarise(period="today")`. Also includes **Enforcement (24h)** section: total calls, block rate, top blocked tools/reasons from `core.governance.enforcement_telemetry.summarise(period="today")` (PR19 v2.41.0). Plus **Reorganization (today)** section: today's proposal path + artifact count from `core.cognition.reorganizer_scheduler.status_summary()` (PR24 v2.46.0). Plus **Model routing** section: gateway live/off + resolved per-slot routes + served counts from `core.runtime.model_routing_check.status_summary()`. |
|
|
183
183
|
| `/arka costs [period]` | LLM cost visibility — aggregates telemetry by day/week/month/all, with top expensive sessions. See `arka/skills/costs/SKILL.md`. Shells out to `~/.arkaos/bin/arka-py -m core.runtime.llm_cost_telemetry_cli <period>`. |
|
|
184
184
|
| `/arka enforcement [period]` | Enforcement compliance — aggregates flow-marker enforcement telemetry by day/week/month/all. Shows block rate, top blocked tools, top reasons. Shells out to `~/.arkaos/bin/arka-py -m core.governance.enforcement_telemetry_cli <period>`. |
|
|
185
185
|
| `/arka compliance [period]` | Behavior compliance summary (PR29 v2.48.0) — aggregates stop-hook telemetry by day/week/month/all. Shows rates for the four contracts: closing marker, `[arka:meta]` tag, KB citation pass, sycophancy clean. Shells out to `~/.arkaos/bin/arka-py -m core.governance.compliance_telemetry_cli <period>`. |
|
package/bin/arka-claude
CHANGED
|
@@ -13,14 +13,24 @@ CYAN='\033[0;36m'
|
|
|
13
13
|
YELLOW='\033[1;33m'
|
|
14
14
|
RESET='\033[0m'
|
|
15
15
|
|
|
16
|
+
# ─── Shared Python resolver (exports ARKA_PY) ──────────────────────────
|
|
17
|
+
_self="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
|
|
18
|
+
for _lib in \
|
|
19
|
+
"$_self/../config/hooks/_lib/arka_python.sh" \
|
|
20
|
+
"$HOME/.arkaos/config/hooks/_lib/arka_python.sh"; do
|
|
21
|
+
# shellcheck source=/dev/null
|
|
22
|
+
[ -f "$_lib" ] && { . "$_lib"; break; }
|
|
23
|
+
done
|
|
24
|
+
: "${ARKA_PY:=python3}"
|
|
25
|
+
|
|
16
26
|
# ─── Profile ───────────────────────────────────────────────────────────
|
|
17
27
|
NAME="founder"
|
|
18
28
|
COMPANY="WizardingCode"
|
|
19
29
|
VERSION="2.x"
|
|
20
30
|
|
|
21
|
-
if [ -f "$HOME/.arkaos/profile.json" ]
|
|
22
|
-
NAME=$(
|
|
23
|
-
COMPANY=$(
|
|
31
|
+
if [ -f "$HOME/.arkaos/profile.json" ]; then
|
|
32
|
+
NAME=$("$ARKA_PY" -c "import json; p=json.load(open('$HOME/.arkaos/profile.json')); print(p.get('name', p.get('role', 'founder')))" 2>/dev/null || echo "founder")
|
|
33
|
+
COMPANY=$("$ARKA_PY" -c "import json; print(json.load(open('$HOME/.arkaos/profile.json')).get('company', 'WizardingCode'))" 2>/dev/null || echo "WizardingCode")
|
|
24
34
|
fi
|
|
25
35
|
|
|
26
36
|
if [ -f "$HOME/.arkaos/.repo-path" ]; then
|
|
@@ -38,7 +48,7 @@ else GREETING="Boa noite"; fi
|
|
|
38
48
|
DRIFT_MSG=""
|
|
39
49
|
SYNC_STATE="$HOME/.arkaos/sync-state.json"
|
|
40
50
|
if [ -f "$SYNC_STATE" ]; then
|
|
41
|
-
SYNCED=$(
|
|
51
|
+
SYNCED=$("$ARKA_PY" -c "import json; print(json.load(open('$SYNC_STATE'))['version'])" 2>/dev/null || echo "none")
|
|
42
52
|
if [ "$SYNCED" != "$VERSION" ]; then
|
|
43
53
|
DRIFT_MSG="\n ${YELLOW}⚠ Update available: run /arka update to sync${RESET}"
|
|
44
54
|
fi
|
|
@@ -60,5 +70,26 @@ echo -e " ${DIM}ArkaOS v${VERSION} │ 65 agents │ 17 departments │ 244+ sk
|
|
|
60
70
|
echo -e "${DRIFT_MSG}"
|
|
61
71
|
echo ""
|
|
62
72
|
|
|
73
|
+
# ─── Model-routing gateway (opt-in: ARKA_GATEWAY=1) ────────────────────
|
|
74
|
+
# Renders the LiteLLM proxy from ~/.arkaos/models.yaml and launches Claude
|
|
75
|
+
# Code against it, so per-role model choices (execution→Ollama, quality→
|
|
76
|
+
# Anthropic) actually take effect. Any failure degrades to a plain launch.
|
|
77
|
+
if [ "${ARKA_GATEWAY:-0}" = "1" ] && [ -n "${REPO:-}" ]; then
|
|
78
|
+
_GW="$REPO/scripts/gateway-ensure.sh"
|
|
79
|
+
_ENV_FILE="$HOME/.arkaos/gateway/launch.env"
|
|
80
|
+
if [ -f "$_GW" ] && ARKAOS_ROOT="$REPO" ARKAOS_HOME="$HOME/.arkaos" bash "$_GW"; then
|
|
81
|
+
# Source the generated env without eval (values are ArkaOS-controlled).
|
|
82
|
+
set -a
|
|
83
|
+
# shellcheck source=/dev/null
|
|
84
|
+
. "$_ENV_FILE"
|
|
85
|
+
set +a
|
|
86
|
+
echo -e " ${GREEN}⚡ Model Fabric gateway active${RESET} ${DIM}(models.yaml per-role routing)${RESET}"
|
|
87
|
+
echo ""
|
|
88
|
+
else
|
|
89
|
+
echo -e " ${DIM}(gateway off — plain Claude launch)${RESET}"
|
|
90
|
+
echo ""
|
|
91
|
+
fi
|
|
92
|
+
fi
|
|
93
|
+
|
|
63
94
|
# ─── Launch Claude ─────────────────────────────────────────────────────
|
|
64
95
|
exec claude "$@"
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""ArkaOS model-routing gateway.
|
|
2
|
+
|
|
3
|
+
Turns the operator's ``~/.arkaos/models.yaml`` role routing into a running
|
|
4
|
+
LiteLLM proxy so per-role model choices actually take effect at dispatch
|
|
5
|
+
time. Claude Code points ``ANTHROPIC_BASE_URL`` at the proxy; the proxy
|
|
6
|
+
fans each request out to the right upstream (Anthropic-direct or local
|
|
7
|
+
Ollama) by the model name the alias slot carries.
|
|
8
|
+
|
|
9
|
+
See ``docs/adr`` and the plan for why a gateway is required: Claude Code's
|
|
10
|
+
``ANTHROPIC_BASE_URL`` is one endpoint per process, so honouring mixed
|
|
11
|
+
providers per role in a single session needs a fan-out proxy.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from core.runtime.gateway.litellm_config import (
|
|
15
|
+
GatewayPlan,
|
|
16
|
+
build_gateway_plan,
|
|
17
|
+
build_launch_env,
|
|
18
|
+
build_litellm_config,
|
|
19
|
+
role_alias,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"GatewayPlan",
|
|
24
|
+
"build_gateway_plan",
|
|
25
|
+
"build_launch_env",
|
|
26
|
+
"build_litellm_config",
|
|
27
|
+
"role_alias",
|
|
28
|
+
]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""CLI: render the LiteLLM gateway config from models.yaml.
|
|
2
|
+
|
|
3
|
+
arka-py -m core.runtime.gateway [--local] # print config.yaml
|
|
4
|
+
arka-py -m core.runtime.gateway [--local] --env KEY # print launch env
|
|
5
|
+
|
|
6
|
+
``--local`` renders the keyless local-only variant (every route → local
|
|
7
|
+
Ollama) for subscription users with no ANTHROPIC_API_KEY.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
from core.runtime.gateway.litellm_config import build_launch_env, render_config_yaml
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def main(argv: list[str]) -> int:
|
|
18
|
+
local_only = "--local" in argv
|
|
19
|
+
argv = [a for a in argv if a != "--local"]
|
|
20
|
+
if len(argv) >= 2 and argv[0] == "--env":
|
|
21
|
+
env = build_launch_env(master_key=argv[1])
|
|
22
|
+
for key, value in env.items():
|
|
23
|
+
print(f"{key}={value}")
|
|
24
|
+
return 0
|
|
25
|
+
sys.stdout.write(render_config_yaml(local_only=local_only))
|
|
26
|
+
return 0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
if __name__ == "__main__":
|
|
30
|
+
raise SystemExit(main(sys.argv[1:]))
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Render a LiteLLM proxy config + Claude Code launch env from models.yaml.
|
|
2
|
+
|
|
3
|
+
Claude Code exposes at most three subagent alias slots (``opus``,
|
|
4
|
+
``sonnet``, ``haiku``) plus the interactive main-loop model. The seven
|
|
5
|
+
Model Fabric roles therefore collapse onto those slots by tier:
|
|
6
|
+
|
|
7
|
+
quality tier (design/review/architecture/quality_gate) -> opus slot
|
|
8
|
+
execution -> haiku slot
|
|
9
|
+
mechanical -> sonnet slot
|
|
10
|
+
strategy / orchestrator -> main model
|
|
11
|
+
|
|
12
|
+
Each slot maps to a LiteLLM route (``arka-<slot>``) that fans out to the
|
|
13
|
+
role's real upstream — Anthropic-direct or local Ollama. The launch env
|
|
14
|
+
points ``ANTHROPIC_DEFAULT_<TIER>_MODEL`` at those routes, so a subagent
|
|
15
|
+
dispatched with ``model: haiku`` reaches Ollama while ``model: opus``
|
|
16
|
+
reaches Anthropic — in the same session. The Anthropic API key never
|
|
17
|
+
enters the client env; it stays server-side in the proxy's environment.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from pydantic import BaseModel
|
|
23
|
+
|
|
24
|
+
from core.runtime.model_router import ModelsConfig, load_config, resolve
|
|
25
|
+
|
|
26
|
+
# The three Claude Code subagent alias slots we can address, plus a
|
|
27
|
+
# sentinel for the interactive main-loop model (not an alias).
|
|
28
|
+
_SLOTS = ("opus", "sonnet", "haiku")
|
|
29
|
+
_MAIN = "main"
|
|
30
|
+
|
|
31
|
+
# Which alias slot each work-role dispatches through. The orchestrator
|
|
32
|
+
# uses this to choose the Task `model` param per role; the launch env
|
|
33
|
+
# points each slot's default model at the matching gateway route.
|
|
34
|
+
ROLE_ALIAS: dict[str, str] = {
|
|
35
|
+
"design": "opus",
|
|
36
|
+
"review": "opus",
|
|
37
|
+
"architecture": "opus",
|
|
38
|
+
"quality_gate": "opus",
|
|
39
|
+
"strategy": _MAIN,
|
|
40
|
+
"execution": "haiku",
|
|
41
|
+
"mechanical": "sonnet",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
# Representative role whose configured upstream defines each slot's route.
|
|
45
|
+
# All quality roles share the opus slot, so one of them stands in.
|
|
46
|
+
_SLOT_ROLE: dict[str, str] = {
|
|
47
|
+
"opus": "quality_gate",
|
|
48
|
+
"sonnet": "mechanical",
|
|
49
|
+
"haiku": "execution",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
# runtime-tier tokens -> real Anthropic model ids the proxy calls upstream.
|
|
53
|
+
# Real ids (claude-fable-5, claude-*) pass straight through.
|
|
54
|
+
_RUNTIME_TO_ANTHROPIC: dict[str, str] = {
|
|
55
|
+
"opus": "claude-opus-4-8",
|
|
56
|
+
"best": "claude-opus-4-8",
|
|
57
|
+
"sonnet": "claude-sonnet-5",
|
|
58
|
+
"default": "claude-sonnet-5",
|
|
59
|
+
"haiku": "claude-haiku-4-5-20251001",
|
|
60
|
+
"fast": "claude-haiku-4-5-20251001",
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
_DEFAULT_OLLAMA_BASE = "http://localhost:11434"
|
|
64
|
+
_DEFAULT_PORT = 4000
|
|
65
|
+
_ANTHROPIC_KEY_REF = "os.environ/ANTHROPIC_API_KEY"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def role_alias(role: str) -> str:
|
|
69
|
+
"""Which Claude Code alias slot a role dispatches through.
|
|
70
|
+
|
|
71
|
+
Unknown roles fall back to the opus slot — never the cheap tier —
|
|
72
|
+
consistent with the model-router's quality-first posture.
|
|
73
|
+
"""
|
|
74
|
+
return ROLE_ALIAS.get(role, "opus")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class Upstream(BaseModel):
|
|
78
|
+
"""A concrete backend a slot routes to."""
|
|
79
|
+
|
|
80
|
+
kind: str # "anthropic" | "ollama"
|
|
81
|
+
model_id: str
|
|
82
|
+
api_base: str | None = None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class GatewayPlan(BaseModel):
|
|
86
|
+
"""Resolved routing: one upstream per subagent alias slot.
|
|
87
|
+
|
|
88
|
+
The strategy role rides the interactive main-loop model (set by
|
|
89
|
+
``--model`` / the operator's ``/model``), which reaches Anthropic via
|
|
90
|
+
the wildcard passthrough route — it is not a gateway-controlled slot.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
slots: dict[str, Upstream] # keyed by "opus"/"sonnet"/"haiku"
|
|
94
|
+
source: str
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _upstream_for(provider: str, model: str, ollama_base: str) -> Upstream:
|
|
98
|
+
if provider == "ollama":
|
|
99
|
+
return Upstream(kind="ollama", model_id=model, api_base=ollama_base)
|
|
100
|
+
# runtime + anthropic both terminate at Anthropic-direct.
|
|
101
|
+
return Upstream(kind="anthropic", model_id=_RUNTIME_TO_ANTHROPIC.get(model, model))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _ollama_base(config: ModelsConfig) -> str:
|
|
105
|
+
return (config.providers.get("ollama", {}) or {}).get("base_url") or _DEFAULT_OLLAMA_BASE
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def build_gateway_plan(user_path=None, local_only: bool = False) -> GatewayPlan:
|
|
109
|
+
"""Resolve every alias slot to its upstream from the operator's config.
|
|
110
|
+
|
|
111
|
+
``local_only`` (for subscription users with no ANTHROPIC_API_KEY):
|
|
112
|
+
every slot that would hit Anthropic is redirected to the local Ollama
|
|
113
|
+
model instead, so the whole session runs keyless on local models. Raises
|
|
114
|
+
if the config has no Ollama route to fall back to.
|
|
115
|
+
"""
|
|
116
|
+
config, source = load_config(user_path)
|
|
117
|
+
ollama_base = _ollama_base(config)
|
|
118
|
+
slots: dict[str, Upstream] = {}
|
|
119
|
+
for slot, role in _SLOT_ROLE.items():
|
|
120
|
+
r = resolve(role, user_path)
|
|
121
|
+
slots[slot] = _upstream_for(r.provider, r.model, ollama_base)
|
|
122
|
+
if local_only:
|
|
123
|
+
fallback = _local_fallback(slots)
|
|
124
|
+
if fallback is None:
|
|
125
|
+
raise ValueError(
|
|
126
|
+
"local-only gateway requested but models.yaml has no Ollama "
|
|
127
|
+
"route to fall back to — point at least one role at ollama/<model>"
|
|
128
|
+
)
|
|
129
|
+
slots = {
|
|
130
|
+
slot: (up if up.kind == "ollama" else fallback)
|
|
131
|
+
for slot, up in slots.items()
|
|
132
|
+
}
|
|
133
|
+
return GatewayPlan(slots=slots, source=source)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _local_fallback(slots: dict[str, Upstream]) -> Upstream | None:
|
|
137
|
+
"""The local Ollama upstream to redirect Anthropic routes to (prefer
|
|
138
|
+
execution/haiku), or None when no slot is local."""
|
|
139
|
+
if slots.get("haiku", Upstream(kind="anthropic", model_id="")).kind == "ollama":
|
|
140
|
+
return slots["haiku"]
|
|
141
|
+
for up in slots.values():
|
|
142
|
+
if up.kind == "ollama":
|
|
143
|
+
return up
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _litellm_params(up: Upstream) -> dict:
|
|
148
|
+
if up.kind == "ollama":
|
|
149
|
+
# ollama_chat/ enables tool-use translation; api_base points at the
|
|
150
|
+
# local daemon (which forwards :cloud models to Ollama Cloud).
|
|
151
|
+
return {"model": f"ollama_chat/{up.model_id}", "api_base": up.api_base}
|
|
152
|
+
return {"model": up.model_id, "api_key": _ANTHROPIC_KEY_REF}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def build_litellm_config(user_path=None, local_only: bool = False) -> dict:
|
|
156
|
+
"""The LiteLLM proxy config.yaml structure (regenerated per launch).
|
|
157
|
+
|
|
158
|
+
One ``arka-<slot>`` route per alias slot, plus a wildcard route for the
|
|
159
|
+
interactive main-loop model and any raw model id. In the default mixed
|
|
160
|
+
mode the wildcard forwards to Anthropic (key via ``os.environ``, never
|
|
161
|
+
inlined); in ``local_only`` mode it forwards to the local Ollama model
|
|
162
|
+
so the whole session is keyless. The client authenticates with the
|
|
163
|
+
gateway master key either way.
|
|
164
|
+
"""
|
|
165
|
+
plan = build_gateway_plan(user_path, local_only=local_only)
|
|
166
|
+
model_list = [
|
|
167
|
+
{"model_name": f"arka-{slot}", "litellm_params": _litellm_params(plan.slots[slot])}
|
|
168
|
+
for slot in _SLOTS
|
|
169
|
+
]
|
|
170
|
+
if local_only:
|
|
171
|
+
wildcard = _litellm_params(_local_fallback(plan.slots))
|
|
172
|
+
else:
|
|
173
|
+
# LiteLLM matches explicit names first; this catches the main-loop
|
|
174
|
+
# model and any raw claude-* id and forwards it to Anthropic.
|
|
175
|
+
wildcard = {"model": "anthropic/*", "api_key": _ANTHROPIC_KEY_REF}
|
|
176
|
+
model_list.append({"model_name": "*", "litellm_params": wildcard})
|
|
177
|
+
return {
|
|
178
|
+
"model_list": model_list,
|
|
179
|
+
"litellm_settings": {"drop_params": True},
|
|
180
|
+
"general_settings": {"master_key": "os.environ/ARKA_GATEWAY_KEY"},
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def build_launch_env(master_key: str, port: int = _DEFAULT_PORT, user_path=None) -> dict[str, str]:
|
|
185
|
+
"""Env vars Claude Code launches with so subagent aliases hit the gateway.
|
|
186
|
+
|
|
187
|
+
``ANTHROPIC_API_KEY`` is deliberately absent — it belongs to the proxy,
|
|
188
|
+
not the client. The client presents only the gateway master key.
|
|
189
|
+
"""
|
|
190
|
+
return {
|
|
191
|
+
"ANTHROPIC_BASE_URL": f"http://127.0.0.1:{port}",
|
|
192
|
+
"ANTHROPIC_AUTH_TOKEN": master_key,
|
|
193
|
+
"ANTHROPIC_DEFAULT_OPUS_MODEL": "arka-opus",
|
|
194
|
+
"ANTHROPIC_DEFAULT_SONNET_MODEL": "arka-sonnet",
|
|
195
|
+
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "arka-haiku",
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def render_config_yaml(user_path=None, local_only: bool = False) -> str:
|
|
200
|
+
"""The LiteLLM config as a YAML string (what arka-claude writes to disk)."""
|
|
201
|
+
import yaml
|
|
202
|
+
|
|
203
|
+
return yaml.safe_dump(
|
|
204
|
+
build_litellm_config(user_path, local_only=local_only), sort_keys=False
|
|
205
|
+
)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Model-routing telemetry — answer "is the Model Fabric actually used?".
|
|
2
|
+
|
|
3
|
+
The gateway makes per-role routing real; this module makes it *observable*.
|
|
4
|
+
It reports the resolved route table, whether the LiteLLM proxy is live, and
|
|
5
|
+
a best-effort count of which ``arka-<slot>`` routes actually served traffic
|
|
6
|
+
(parsed from the proxy log). Everything degrades to a plain summary when the
|
|
7
|
+
gateway is off, so ``/arka status`` always has a truthful line to show.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from core.runtime.gateway.litellm_config import build_gateway_plan
|
|
16
|
+
|
|
17
|
+
_GATEWAY_LOG = Path.home() / ".arkaos" / "gateway" / "litellm.log"
|
|
18
|
+
_DEFAULT_PORT = 4000
|
|
19
|
+
_ROUTE_RE = re.compile(r"\barka-(opus|sonnet|haiku)\b")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def resolved_routes(user_path=None) -> dict[str, str]:
|
|
23
|
+
"""Alias slot -> human 'kind:model' the gateway would route it to."""
|
|
24
|
+
plan = build_gateway_plan(user_path)
|
|
25
|
+
return {
|
|
26
|
+
slot: f"{up.kind}:{up.model_id}" for slot, up in plan.slots.items()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def gateway_healthy(port: int = _DEFAULT_PORT, timeout: float = 1.0) -> bool:
|
|
31
|
+
"""True when the LiteLLM proxy answers its liveness probe."""
|
|
32
|
+
import urllib.error
|
|
33
|
+
import urllib.request
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
with urllib.request.urlopen(
|
|
37
|
+
f"http://127.0.0.1:{port}/health/liveliness", timeout=timeout
|
|
38
|
+
) as resp:
|
|
39
|
+
return resp.status == 200
|
|
40
|
+
except (urllib.error.URLError, OSError, ValueError):
|
|
41
|
+
return False
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def served_counts(log_path: Path | None = None) -> dict[str, int]:
|
|
45
|
+
"""Best-effort count of arka-<slot> routes seen in the proxy log."""
|
|
46
|
+
path = log_path or _GATEWAY_LOG
|
|
47
|
+
counts: dict[str, int] = {}
|
|
48
|
+
try:
|
|
49
|
+
text = path.read_text(encoding="utf-8", errors="ignore")
|
|
50
|
+
except OSError:
|
|
51
|
+
return counts
|
|
52
|
+
for match in _ROUTE_RE.finditer(text):
|
|
53
|
+
slot = match.group(1)
|
|
54
|
+
counts[slot] = counts.get(slot, 0) + 1
|
|
55
|
+
return counts
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def status_summary(port: int = _DEFAULT_PORT, user_path=None, log_path=None) -> str:
|
|
59
|
+
"""One compact block for /arka status — reality, not intent."""
|
|
60
|
+
live = gateway_healthy(port)
|
|
61
|
+
routes = resolved_routes(user_path)
|
|
62
|
+
header = (
|
|
63
|
+
f"Gateway: {'LIVE' if live else 'off'} (:{port}) — per-role model routing "
|
|
64
|
+
f"{'active' if live else 'not active; honour-system context injection only'}"
|
|
65
|
+
)
|
|
66
|
+
lines = [f" {slot} → {target}" for slot, target in sorted(routes.items())]
|
|
67
|
+
counts = served_counts(log_path)
|
|
68
|
+
if counts:
|
|
69
|
+
served = " ".join(f"{slot}={n}" for slot, n in sorted(counts.items()))
|
|
70
|
+
lines.append(f" served: {served}")
|
|
71
|
+
return header + "\n" + "\n".join(lines)
|
|
@@ -57,7 +57,7 @@ def routing_summary() -> str:
|
|
|
57
57
|
|
|
58
58
|
|
|
59
59
|
def routing_directive() -> str:
|
|
60
|
-
"""Full SessionStart block: the config plus how to
|
|
60
|
+
"""Full SessionStart block: the config plus how to honour it.
|
|
61
61
|
|
|
62
62
|
Returns '' when the config cannot be read, so the hook can skip the
|
|
63
63
|
block entirely rather than inject an empty directive.
|
|
@@ -65,6 +65,26 @@ def routing_directive() -> str:
|
|
|
65
65
|
summary = routing_summary()
|
|
66
66
|
if not summary:
|
|
67
67
|
return ""
|
|
68
|
+
|
|
69
|
+
# When the LiteLLM gateway is live, the alias a subagent is dispatched
|
|
70
|
+
# with is what physically selects the upstream (opus→Anthropic,
|
|
71
|
+
# haiku→Ollama for execution), so the directive names the concrete
|
|
72
|
+
# alias per role. Off, it stays advisory context injection.
|
|
73
|
+
gateway_line = ""
|
|
74
|
+
try:
|
|
75
|
+
from core.runtime.model_routing_check import gateway_healthy
|
|
76
|
+
|
|
77
|
+
if gateway_healthy():
|
|
78
|
+
gateway_line = (
|
|
79
|
+
"\nGateway LIVE: the Task model param physically routes the "
|
|
80
|
+
"upstream. Dispatch execution with model=haiku (→ Ollama), "
|
|
81
|
+
"mechanical with model=sonnet, and design/review/architecture/"
|
|
82
|
+
"quality_gate with model=opus (→ Anthropic). strategy is the "
|
|
83
|
+
"main loop model."
|
|
84
|
+
)
|
|
85
|
+
except Exception:
|
|
86
|
+
gateway_line = ""
|
|
87
|
+
|
|
68
88
|
# No backticks: this string is embedded in session-start.sh which
|
|
69
89
|
# passes it through $(echo -e "$MSG") — backticks would trigger shell
|
|
70
90
|
# command substitution. Use plain quotes.
|
|
@@ -79,4 +99,5 @@ def routing_directive() -> str:
|
|
|
79
99
|
"the agent YAML default. Quality roles never downgrade below the "
|
|
80
100
|
"configured model (excellence-mandate). Genuinely mechanical "
|
|
81
101
|
"dispatches (commit messages, formatting) use the mechanical role."
|
|
102
|
+
f"{gateway_line}"
|
|
82
103
|
)
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "arkaos-core"
|
|
3
|
-
version = "4.3.
|
|
3
|
+
version = "4.3.6"
|
|
4
4
|
description = "Core engine for ArkaOS — The Operating System for AI Agent Teams"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = {text = "MIT"}
|
|
@@ -45,6 +45,12 @@ ingest = [
|
|
|
45
45
|
"beautifulsoup4>=4.12.0",
|
|
46
46
|
"requests>=2.31.0",
|
|
47
47
|
]
|
|
48
|
+
gateway = [
|
|
49
|
+
# LiteLLM proxy: the Anthropic-compatible fan-out endpoint that makes
|
|
50
|
+
# per-role mixed-provider routing (execution→Ollama, quality→Anthropic)
|
|
51
|
+
# take effect. Only needed when launching with ARKA_GATEWAY=1.
|
|
52
|
+
"litellm[proxy]>=1.50.0",
|
|
53
|
+
]
|
|
48
54
|
dev = [
|
|
49
55
|
"pytest>=8.0",
|
|
50
56
|
"pytest-cov>=5.0",
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# ============================================================================
|
|
3
|
+
# ArkaOS — Model-routing gateway ensure
|
|
4
|
+
#
|
|
5
|
+
# Renders the LiteLLM proxy config from ~/.arkaos/models.yaml, ensures the
|
|
6
|
+
# proxy is running, and writes the Claude Code launch env to a file the
|
|
7
|
+
# caller sources. Best-effort: any failure returns non-zero so arka-claude
|
|
8
|
+
# degrades to a plain `claude` launch — a broken gateway never blocks work.
|
|
9
|
+
#
|
|
10
|
+
# Contract:
|
|
11
|
+
# in : $ARKA_PY (interpreter), $ARKAOS_ROOT (repo), env ARKA_GATEWAY_PORT
|
|
12
|
+
# out: writes $ARKAOS_HOME/gateway/launch.env on success; exit 0
|
|
13
|
+
# exit non-zero on any failure (caller must degrade)
|
|
14
|
+
# ============================================================================
|
|
15
|
+
set -uo pipefail
|
|
16
|
+
|
|
17
|
+
ARKAOS_HOME="${ARKAOS_HOME:-$HOME/.arkaos}"
|
|
18
|
+
PORT="${ARKA_GATEWAY_PORT:-4000}"
|
|
19
|
+
GW_DIR="$ARKAOS_HOME/gateway"
|
|
20
|
+
CONFIG="$GW_DIR/config.yaml"
|
|
21
|
+
ENV_FILE="$GW_DIR/launch.env"
|
|
22
|
+
LOG="$GW_DIR/litellm.log"
|
|
23
|
+
LITELLM="$ARKAOS_HOME/venv/bin/litellm"
|
|
24
|
+
|
|
25
|
+
warn() { printf ' \033[1;33m⚠ gateway: %s\033[0m\n' "$1" >&2; }
|
|
26
|
+
|
|
27
|
+
# ─── Preconditions ────────────────────────────────────────────────────────
|
|
28
|
+
[ -n "${ARKA_PY:-}" ] || { warn "ARKA_PY unset"; exit 1; }
|
|
29
|
+
[ -n "${ARKAOS_ROOT:-}" ] || { warn "ARKAOS_ROOT unset"; exit 1; }
|
|
30
|
+
if [ ! -x "$LITELLM" ]; then
|
|
31
|
+
warn "LiteLLM not installed in the ArkaOS venv — run: ~/.arkaos/venv/bin/pip install 'litellm[proxy]'"
|
|
32
|
+
exit 1
|
|
33
|
+
fi
|
|
34
|
+
|
|
35
|
+
# Mode: with an API key -> mixed (quality→Anthropic, execution→Ollama).
|
|
36
|
+
# Without one (subscription users) -> local-only: every route runs on the
|
|
37
|
+
# local Ollama model, keyless. The main arka-claude keeps the subscription.
|
|
38
|
+
LOCAL_FLAG=""
|
|
39
|
+
if [ -z "${ANTHROPIC_API_KEY:-}" ]; then
|
|
40
|
+
LOCAL_FLAG="--local"
|
|
41
|
+
warn "no ANTHROPIC_API_KEY — local-only mode: the whole session runs on the local Ollama model (use plain arka-claude for subscription/quality work)"
|
|
42
|
+
fi
|
|
43
|
+
|
|
44
|
+
mkdir -p "$GW_DIR"
|
|
45
|
+
health() { curl -fsS -m 2 "http://127.0.0.1:$PORT/health/liveliness" >/dev/null 2>&1; }
|
|
46
|
+
|
|
47
|
+
# ─── Reuse a healthy proxy WITHOUT rotating its key ───────────────────────
|
|
48
|
+
# The running proxy validates the ARKA_GATEWAY_KEY it started with; the
|
|
49
|
+
# client reads that same value from the existing launch.env. Minting a new
|
|
50
|
+
# key on reuse would 401 every request, so the reuse path touches neither
|
|
51
|
+
# the key nor launch.env. Changing models.yaml needs ARKA_GATEWAY_RESTART=1.
|
|
52
|
+
if [ "${ARKA_GATEWAY_RESTART:-0}" != "1" ] && health && [ -f "$ENV_FILE" ]; then
|
|
53
|
+
exit 0
|
|
54
|
+
fi
|
|
55
|
+
if health; then # RESTART path — old proxy must go
|
|
56
|
+
pkill -f "litellm .*--port $PORT" 2>/dev/null || true
|
|
57
|
+
sleep 1
|
|
58
|
+
fi
|
|
59
|
+
|
|
60
|
+
# ─── Render config from models.yaml ───────────────────────────────────────
|
|
61
|
+
# shellcheck disable=SC2086 # LOCAL_FLAG is a single controlled token
|
|
62
|
+
if ! PYTHONPATH="$ARKAOS_ROOT" "$ARKA_PY" -m core.runtime.gateway $LOCAL_FLAG > "$CONFIG" 2>/dev/null; then
|
|
63
|
+
if [ -n "$LOCAL_FLAG" ]; then
|
|
64
|
+
warn "could not render gateway config — local-only needs at least one ollama route in models.yaml"
|
|
65
|
+
else
|
|
66
|
+
warn "could not render gateway config from models.yaml"
|
|
67
|
+
fi
|
|
68
|
+
exit 1
|
|
69
|
+
fi
|
|
70
|
+
|
|
71
|
+
# ─── Fresh master key + client launch env (created for THIS proxy) ────────
|
|
72
|
+
MASTER_KEY="$("$ARKA_PY" -c 'import secrets; print("sk-arka-" + secrets.token_hex(16))')"
|
|
73
|
+
export ARKA_GATEWAY_KEY="$MASTER_KEY"
|
|
74
|
+
|
|
75
|
+
if ! PYTHONPATH="$ARKAOS_ROOT" "$ARKA_PY" -m core.runtime.gateway --env "$MASTER_KEY" > "$ENV_FILE" 2>/dev/null; then
|
|
76
|
+
warn "could not render launch env"
|
|
77
|
+
exit 1
|
|
78
|
+
fi
|
|
79
|
+
# The proxy reads ANTHROPIC_API_KEY + ARKA_GATEWAY_KEY from its own env.
|
|
80
|
+
printf 'ARKA_GATEWAY_KEY=%s\n' "$MASTER_KEY" >> "$ENV_FILE"
|
|
81
|
+
chmod 600 "$ENV_FILE" 2>/dev/null || true # bearer token — owner-only
|
|
82
|
+
|
|
83
|
+
# Start in the background; ARKA_GATEWAY_KEY + ANTHROPIC_API_KEY inherited.
|
|
84
|
+
( ARKA_GATEWAY_KEY="$MASTER_KEY" nohup "$LITELLM" --config "$CONFIG" --port "$PORT" \
|
|
85
|
+
>> "$LOG" 2>&1 & disown 2>/dev/null || true )
|
|
86
|
+
|
|
87
|
+
# ─── Health-wait (bounded) ────────────────────────────────────────────────
|
|
88
|
+
for _ in $(seq 1 30); do
|
|
89
|
+
if health; then exit 0; fi
|
|
90
|
+
sleep 1
|
|
91
|
+
done
|
|
92
|
+
warn "gateway did not become healthy within 30s (see $LOG)"
|
|
93
|
+
exit 1
|