arkaos 2.21.0 → 2.22.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 (41) hide show
  1. package/VERSION +1 -1
  2. package/arka/SKILL.md +2 -1
  3. package/arka/skills/costs/SKILL.md +62 -0
  4. package/core/cognition/__pycache__/auto_documentor.cpython-313.pyc +0 -0
  5. package/core/cognition/auto_documentor.py +75 -52
  6. package/core/jobs/__pycache__/__init__.cpython-313.pyc +0 -0
  7. package/core/jobs/__pycache__/auto_doc_worker.cpython-313.pyc +0 -0
  8. package/core/obsidian/__pycache__/__init__.cpython-313.pyc +0 -0
  9. package/core/obsidian/__pycache__/cataloger.cpython-313.pyc +0 -0
  10. package/core/obsidian/__pycache__/relator.cpython-313.pyc +0 -0
  11. package/core/obsidian/__pycache__/taxonomy.cpython-313.pyc +0 -0
  12. package/core/runtime/__init__.py +22 -1
  13. package/core/runtime/__pycache__/__init__.cpython-313.pyc +0 -0
  14. package/core/runtime/__pycache__/base.cpython-313.pyc +0 -0
  15. package/core/runtime/__pycache__/claude_code.cpython-313.pyc +0 -0
  16. package/core/runtime/__pycache__/codex_cli.cpython-313.pyc +0 -0
  17. package/core/runtime/__pycache__/cursor.cpython-313.pyc +0 -0
  18. package/core/runtime/__pycache__/gemini_cli.cpython-313.pyc +0 -0
  19. package/core/runtime/__pycache__/llm_cost_telemetry.cpython-313.pyc +0 -0
  20. package/core/runtime/__pycache__/llm_cost_telemetry_cli.cpython-313.pyc +0 -0
  21. package/core/runtime/__pycache__/llm_provider.cpython-313.pyc +0 -0
  22. package/core/runtime/__pycache__/pricing.cpython-313.pyc +0 -0
  23. package/core/runtime/base.py +30 -1
  24. package/core/runtime/claude_code.py +68 -0
  25. package/core/runtime/codex_cli.py +33 -0
  26. package/core/runtime/cursor.py +19 -0
  27. package/core/runtime/gemini_cli.py +33 -0
  28. package/core/runtime/llm_cost_telemetry.py +306 -0
  29. package/core/runtime/llm_cost_telemetry_cli.py +138 -0
  30. package/core/runtime/llm_provider.py +382 -0
  31. package/core/runtime/pricing.py +85 -0
  32. package/core/synapse/__pycache__/__init__.cpython-313.pyc +0 -0
  33. package/core/synapse/__pycache__/engine.cpython-313.pyc +0 -0
  34. package/core/synapse/__pycache__/kb_cache.cpython-313.pyc +0 -0
  35. package/core/synapse/__pycache__/layers.cpython-313.pyc +0 -0
  36. package/core/workflow/__pycache__/flow_enforcer.cpython-313.pyc +0 -0
  37. package/core/workflow/__pycache__/kb_first_decider.cpython-313.pyc +0 -0
  38. package/core/workflow/__pycache__/marker_cache.cpython-313.pyc +0 -0
  39. package/core/workflow/__pycache__/research_gate.cpython-313.pyc +0 -0
  40. package/package.json +1 -1
  41. package/pyproject.toml +1 -1
@@ -0,0 +1,382 @@
1
+ """Runtime-agnostic LLM provider abstraction.
2
+
3
+ Three concrete providers, one factory, one fallback chain. The abstraction
4
+ is deliberately thin: `complete(prompt, *, max_tokens, system) -> LLMResponse`.
5
+ Nothing in this module selects a model. The runtime (Claude Code, Codex,
6
+ Gemini, Cursor) or the user's environment (`ANTHROPIC_MODEL`) owns that
7
+ decision. Adding a branch like `if "opus" in model: ...` here violates the
8
+ LLM-agnostic contract.
9
+
10
+ Provider order (default factory):
11
+ 1. subagent — shell out to the active runtime's CLI
12
+ 2. anthropic-direct — SDK call with prompt caching (requires env)
13
+ 3. stub — returns empty LLMResponse (template fallback)
14
+
15
+ Config: `~/.arkaos/config.json`:
16
+ {"llm": {"provider": "subagent"}}
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import os
23
+ from dataclasses import dataclass
24
+ from pathlib import Path
25
+ from typing import Protocol, runtime_checkable
26
+
27
+ from core.runtime import registry
28
+ from core.runtime.base import RuntimeAdapter
29
+ from core.runtime.llm_cost_telemetry import record_cost
30
+ from core.runtime.pricing import estimate_cost_usd
31
+
32
+
33
+ _DEFAULT_CONFIG_PATH = Path.home() / ".arkaos" / "config.json"
34
+ _FALLBACK_ORDER: tuple[str, ...] = ("subagent", "anthropic-direct", "stub")
35
+
36
+
37
+ # ─── Public dataclass ─────────────────────────────────────────────────
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class LLMResponse:
42
+ text: str
43
+ tokens_in: int
44
+ tokens_out: int
45
+ cached_tokens: int # 0 if no caching available/used
46
+ model: str # whatever the runtime/SDK reported; may be ""
47
+
48
+
49
+ # ─── Exceptions ───────────────────────────────────────────────────────
50
+
51
+
52
+ class LLMUnavailable(RuntimeError):
53
+ """Raised when a provider cannot complete a request at call time.
54
+
55
+ `is_available()` surfaces the static capability check; this
56
+ exception is for runtime-time failures (timeout, CLI missing,
57
+ subprocess error). Callers typically catch this to fall through to
58
+ the next provider in the chain or to a template path.
59
+ """
60
+
61
+
62
+ # ─── Protocol ─────────────────────────────────────────────────────────
63
+
64
+
65
+ @runtime_checkable
66
+ class LLMProvider(Protocol):
67
+ def complete(
68
+ self,
69
+ prompt: str,
70
+ *,
71
+ max_tokens: int = 2000,
72
+ system: str = "",
73
+ ) -> LLMResponse: ...
74
+
75
+ def is_available(self) -> bool: ...
76
+
77
+ def name(self) -> str: ...
78
+
79
+
80
+ # ─── Provider: Subagent (headless CLI shell-out) ──────────────────────
81
+
82
+
83
+ class SubagentProvider:
84
+ """Delegate completion to the active runtime's headless CLI.
85
+
86
+ The runtime — not ArkaOS — picks the model. We never pass one.
87
+ """
88
+
89
+ def __init__(self, adapter: RuntimeAdapter | None = None) -> None:
90
+ self._adapter = adapter
91
+
92
+ def name(self) -> str:
93
+ return "subagent"
94
+
95
+ def _resolve_adapter(self) -> RuntimeAdapter:
96
+ if self._adapter is not None:
97
+ return self._adapter
98
+ runtime_id = registry.detect_runtime()
99
+ return registry.get_adapter(runtime_id)
100
+
101
+ def is_available(self) -> bool:
102
+ try:
103
+ adapter = self._resolve_adapter()
104
+ except Exception: # noqa: BLE001
105
+ return False
106
+ try:
107
+ return adapter.headless_supported()
108
+ except Exception: # noqa: BLE001
109
+ return False
110
+
111
+ def complete(
112
+ self,
113
+ prompt: str,
114
+ *,
115
+ max_tokens: int = 2000,
116
+ system: str = "",
117
+ ) -> LLMResponse:
118
+ adapter = self._resolve_adapter()
119
+ try:
120
+ response = adapter.headless_complete(
121
+ prompt, max_tokens=max_tokens, system=system
122
+ )
123
+ except NotImplementedError as exc:
124
+ raise LLMUnavailable(str(exc)) from exc
125
+ except LLMUnavailable:
126
+ raise
127
+ except Exception as exc: # noqa: BLE001
128
+ raise LLMUnavailable(
129
+ f"headless_complete failed: {exc.__class__.__name__}: {exc}"
130
+ ) from exc
131
+
132
+ _record(
133
+ session_id=os.environ.get("ARKA_SESSION_ID", ""),
134
+ provider=self.name(),
135
+ response=response,
136
+ )
137
+ return response
138
+
139
+
140
+ # ─── Provider: Anthropic Direct (SDK with prompt caching) ─────────────
141
+
142
+
143
+ class AnthropicDirectProvider:
144
+ """Call the Anthropic SDK directly, model read from env var.
145
+
146
+ The `anthropic` package is an optional dependency; if it is not
147
+ installed, `is_available()` returns False and the factory skips to
148
+ the next provider.
149
+ """
150
+
151
+ ENV_MODEL = "ANTHROPIC_MODEL"
152
+ ENV_API_KEY = "ANTHROPIC_API_KEY"
153
+
154
+ def __init__(self, client: object | None = None) -> None:
155
+ self._client = client
156
+
157
+ def name(self) -> str:
158
+ return "anthropic-direct"
159
+
160
+ def _model_from_env(self) -> str | None:
161
+ value = os.environ.get(self.ENV_MODEL, "").strip()
162
+ return value or None
163
+
164
+ def is_available(self) -> bool:
165
+ if self._model_from_env() is None:
166
+ return False
167
+ if self._client is not None:
168
+ return True
169
+ if not os.environ.get(self.ENV_API_KEY, "").strip():
170
+ return False
171
+ try:
172
+ import anthropic # noqa: F401
173
+ except ImportError:
174
+ return False
175
+ return True
176
+
177
+ def _build_client(self) -> object:
178
+ if self._client is not None:
179
+ return self._client
180
+ try:
181
+ import anthropic
182
+ except ImportError as exc: # pragma: no cover — guarded by is_available
183
+ raise LLMUnavailable(
184
+ "anthropic SDK not installed. "
185
+ "Install with `pip install anthropic` or fall back to subagent."
186
+ ) from exc
187
+ return anthropic.Anthropic()
188
+
189
+ def _build_system_blocks(self, system: str) -> list[dict[str, object]]:
190
+ # Empty system prompt → skip cache marker. Caching below the
191
+ # provider minimum is a no-op but harmless.
192
+ if not system:
193
+ return []
194
+ return [
195
+ {
196
+ "type": "text",
197
+ "text": system,
198
+ "cache_control": {"type": "ephemeral"},
199
+ }
200
+ ]
201
+
202
+ def complete(
203
+ self,
204
+ prompt: str,
205
+ *,
206
+ max_tokens: int = 2000,
207
+ system: str = "",
208
+ ) -> LLMResponse:
209
+ model = self._model_from_env()
210
+ if model is None:
211
+ raise LLMUnavailable(
212
+ f"{self.ENV_MODEL} not set — AnthropicDirectProvider "
213
+ "cannot select a model."
214
+ )
215
+ client = self._build_client()
216
+ payload: dict[str, object] = {
217
+ "model": model,
218
+ "max_tokens": max_tokens,
219
+ "messages": [{"role": "user", "content": prompt}],
220
+ }
221
+ system_blocks = self._build_system_blocks(system)
222
+ if system_blocks:
223
+ payload["system"] = system_blocks
224
+
225
+ try:
226
+ raw = client.messages.create(**payload) # type: ignore[attr-defined]
227
+ except Exception as exc: # noqa: BLE001
228
+ raise LLMUnavailable(
229
+ f"anthropic.messages.create failed: {exc.__class__.__name__}: {exc}"
230
+ ) from exc
231
+
232
+ response = _response_from_anthropic(raw, fallback_model=model)
233
+ _record(
234
+ session_id=os.environ.get("ARKA_SESSION_ID", ""),
235
+ provider=self.name(),
236
+ response=response,
237
+ )
238
+ return response
239
+
240
+
241
+ def _response_from_anthropic(raw: object, fallback_model: str) -> LLMResponse:
242
+ text = _extract_anthropic_text(raw)
243
+ usage = getattr(raw, "usage", None)
244
+ tokens_in = int(getattr(usage, "input_tokens", 0) or 0)
245
+ tokens_out = int(getattr(usage, "output_tokens", 0) or 0)
246
+ cache_read = int(getattr(usage, "cache_read_input_tokens", 0) or 0)
247
+ cache_write = int(getattr(usage, "cache_creation_input_tokens", 0) or 0)
248
+ # Cached tokens = cache reads (cache writes are the first pass).
249
+ cached = cache_read
250
+ # Input tokens billed = uncached fresh + cache write (both full
251
+ # price) + cache read (discounted). Expose the sum so downstream
252
+ # pricing sees every billable input token.
253
+ total_input = tokens_in + cache_read + cache_write
254
+ model = str(getattr(raw, "model", "") or fallback_model)
255
+ return LLMResponse(
256
+ text=text,
257
+ tokens_in=total_input,
258
+ tokens_out=tokens_out,
259
+ cached_tokens=cached,
260
+ model=model,
261
+ )
262
+
263
+
264
+ def _extract_anthropic_text(raw: object) -> str:
265
+ content = getattr(raw, "content", None)
266
+ if not content:
267
+ return ""
268
+ parts: list[str] = []
269
+ for block in content:
270
+ block_type = getattr(block, "type", None)
271
+ if block_type == "text":
272
+ parts.append(str(getattr(block, "text", "") or ""))
273
+ return "".join(parts)
274
+
275
+
276
+ # ─── Provider: Stub ───────────────────────────────────────────────────
277
+
278
+
279
+ class StubProvider:
280
+ """Returns an empty LLMResponse. Used in tests and as last fallback."""
281
+
282
+ def name(self) -> str:
283
+ return "stub"
284
+
285
+ def is_available(self) -> bool:
286
+ return True
287
+
288
+ def complete(
289
+ self,
290
+ prompt: str, # noqa: ARG002
291
+ *,
292
+ max_tokens: int = 2000, # noqa: ARG002
293
+ system: str = "", # noqa: ARG002
294
+ ) -> LLMResponse:
295
+ return LLMResponse(
296
+ text="", tokens_in=0, tokens_out=0, cached_tokens=0, model=""
297
+ )
298
+
299
+
300
+ # ─── Factory + fallback chain ─────────────────────────────────────────
301
+
302
+
303
+ _PROVIDERS: dict[str, type] = {
304
+ "subagent": SubagentProvider,
305
+ "anthropic-direct": AnthropicDirectProvider,
306
+ "stub": StubProvider,
307
+ }
308
+
309
+
310
+ def _read_provider_config(config_path: Path | None) -> str:
311
+ path = config_path or _DEFAULT_CONFIG_PATH
312
+ try:
313
+ data = json.loads(path.read_text(encoding="utf-8"))
314
+ except (OSError, json.JSONDecodeError):
315
+ return _FALLBACK_ORDER[0]
316
+ value = (data.get("llm") or {}).get("provider")
317
+ if isinstance(value, str) and value.strip() in _PROVIDERS:
318
+ return value.strip()
319
+ return _FALLBACK_ORDER[0]
320
+
321
+
322
+ def get_llm_provider(config_path: Path | None = None) -> LLMProvider:
323
+ """Return the first available provider per config + fallback chain.
324
+
325
+ If the configured provider fails its `is_available()` check, fall
326
+ back through the `_FALLBACK_ORDER`. Logs each fallback to telemetry
327
+ so operators can see when a provider is silently skipped. The stub
328
+ is always available and ensures a non-null return.
329
+ """
330
+ preferred = _read_provider_config(config_path)
331
+ chain: list[str] = [preferred]
332
+ for name in _FALLBACK_ORDER:
333
+ if name not in chain:
334
+ chain.append(name)
335
+
336
+ last: LLMProvider | None = None
337
+ for name in chain:
338
+ provider_cls = _PROVIDERS.get(name)
339
+ if provider_cls is None:
340
+ continue
341
+ instance: LLMProvider = provider_cls()
342
+ last = instance
343
+ if instance.is_available():
344
+ if name != preferred:
345
+ _log_fallback(preferred=preferred, selected=name)
346
+ return instance
347
+ _log_fallback(preferred=preferred, selected=name, reason="unavailable")
348
+
349
+ # Stub is always available, so this branch is only a safety net.
350
+ return last if last is not None else StubProvider()
351
+
352
+
353
+ def _log_fallback(preferred: str, selected: str, reason: str = "") -> None:
354
+ # Piggy-back on the cost telemetry file: zero-token, provider-only row.
355
+ # Downstream can group by provider to spot degraded chains.
356
+ record_cost(
357
+ session_id=os.environ.get("ARKA_SESSION_ID", ""),
358
+ provider=f"fallback:{preferred}->{selected}",
359
+ model=reason or "selected",
360
+ tokens_in=0,
361
+ tokens_out=0,
362
+ cached_tokens=0,
363
+ estimated_cost_usd=None,
364
+ )
365
+
366
+
367
+ def _record(session_id: str, provider: str, response: LLMResponse) -> None:
368
+ cost = estimate_cost_usd(
369
+ response.model,
370
+ response.tokens_in,
371
+ response.tokens_out,
372
+ response.cached_tokens,
373
+ )
374
+ record_cost(
375
+ session_id=session_id,
376
+ provider=provider,
377
+ model=response.model,
378
+ tokens_in=response.tokens_in,
379
+ tokens_out=response.tokens_out,
380
+ cached_tokens=response.cached_tokens,
381
+ estimated_cost_usd=cost,
382
+ )
@@ -0,0 +1,85 @@
1
+ """Static pricing table for cost telemetry.
2
+
3
+ This file contains a static lookup table of known model identifiers to
4
+ their public per-token pricing. It is used to compute an optional
5
+ `estimated_cost_usd` attached to telemetry records. It does NOT drive
6
+ any model-selection logic — that is explicitly forbidden by the
7
+ LLM-agnostic contract.
8
+
9
+ TODO(pricing-snapshot 2026-04-20): values sourced from the public
10
+ Anthropic (https://www.anthropic.com/pricing) and OpenAI
11
+ (https://openai.com/api/pricing) pricing pages. Refresh when model
12
+ families change. Unknown models return `None` from `estimate_cost_usd`
13
+ and are logged with a null cost — never a guessed number.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+
19
+ # USD per 1M tokens. Only the `cache_read` and `cache_write` keys apply
20
+ # to providers that expose prompt caching (currently Anthropic). Missing
21
+ # keys fall back to 0 cost contribution rather than raising.
22
+ PRICING: dict[str, dict[str, float]] = {
23
+ "claude-opus-4-7": {
24
+ "input": 15.00,
25
+ "output": 75.00,
26
+ "cache_read": 1.50,
27
+ "cache_write": 18.75,
28
+ },
29
+ "claude-sonnet-4-6": {
30
+ "input": 3.00,
31
+ "output": 15.00,
32
+ "cache_read": 0.30,
33
+ "cache_write": 3.75,
34
+ },
35
+ "claude-haiku-4-5-20251001": {
36
+ "input": 0.80,
37
+ "output": 4.00,
38
+ "cache_read": 0.08,
39
+ "cache_write": 1.00,
40
+ },
41
+ "gpt-4": {
42
+ "input": 30.00,
43
+ "output": 60.00,
44
+ },
45
+ "gpt-4-turbo": {
46
+ "input": 10.00,
47
+ "output": 30.00,
48
+ },
49
+ "gemini-2.5-pro": {
50
+ "input": 1.25,
51
+ "output": 5.00,
52
+ },
53
+ }
54
+
55
+
56
+ def estimate_cost_usd(
57
+ model: str,
58
+ tokens_in: int,
59
+ tokens_out: int,
60
+ cached_tokens: int = 0,
61
+ ) -> float | None:
62
+ """Return the estimated USD cost for the given usage, or None.
63
+
64
+ Returns None when the model is not in `PRICING` — the caller should
65
+ emit a null cost in telemetry rather than guess. All inputs are
66
+ clamped to non-negative.
67
+ """
68
+ row = PRICING.get(model)
69
+ if row is None:
70
+ return None
71
+ tin = max(0, int(tokens_in))
72
+ tout = max(0, int(tokens_out))
73
+ tcached = max(0, int(cached_tokens))
74
+ # cached tokens are a subset of input tokens; charge them at the
75
+ # reduced cache-read rate and only charge the remainder at input.
76
+ tinput_paid = max(0, tin - tcached)
77
+ cost = 0.0
78
+ cost += tinput_paid * row.get("input", 0.0) / 1_000_000
79
+ cost += tout * row.get("output", 0.0) / 1_000_000
80
+ cost += tcached * row.get("cache_read", 0.0) / 1_000_000
81
+ return round(cost, 8)
82
+
83
+
84
+ def known_models() -> list[str]:
85
+ return sorted(PRICING.keys())
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arkaos",
3
- "version": "2.21.0",
3
+ "version": "2.22.0",
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 = "2.21.0"
3
+ version = "2.22.0"
4
4
  description = "Core engine for ArkaOS — The Operating System for AI Agent Teams"
5
5
  readme = "README.md"
6
6
  license = {text = "MIT"}