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 CHANGED
@@ -1 +1 @@
1
- 4.3.5
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" ] && command -v python3 &>/dev/null; then
22
- NAME=$(python3 -c "import json; p=json.load(open('$HOME/.arkaos/profile.json')); print(p.get('name', p.get('role', 'founder')))" 2>/dev/null || echo "founder")
23
- COMPANY=$(python3 -c "import json; print(json.load(open('$HOME/.arkaos/profile.json')).get('company', 'WizardingCode'))" 2>/dev/null || echo "WizardingCode")
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=$(python3 -c "import json; print(json.load(open('$SYNC_STATE'))['version'])" 2>/dev/null || echo "none")
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 "$@"
@@ -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:]))
@@ -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 honor it.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arkaos",
3
- "version": "4.3.5",
3
+ "version": "4.3.6",
4
4
  "description": "The Operating System for AI Agent Teams",
5
5
  "type": "module",
6
6
  "bin": {
package/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "arkaos-core"
3
- version = "4.3.5"
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