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.
- package/VERSION +1 -1
- package/arka/SKILL.md +2 -1
- package/arka/skills/costs/SKILL.md +62 -0
- package/core/cognition/__pycache__/auto_documentor.cpython-313.pyc +0 -0
- package/core/cognition/auto_documentor.py +75 -52
- package/core/jobs/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/jobs/__pycache__/auto_doc_worker.cpython-313.pyc +0 -0
- package/core/obsidian/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/obsidian/__pycache__/cataloger.cpython-313.pyc +0 -0
- package/core/obsidian/__pycache__/relator.cpython-313.pyc +0 -0
- package/core/obsidian/__pycache__/taxonomy.cpython-313.pyc +0 -0
- package/core/runtime/__init__.py +22 -1
- package/core/runtime/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/base.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/claude_code.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/codex_cli.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/cursor.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/gemini_cli.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/llm_cost_telemetry.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/llm_cost_telemetry_cli.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/llm_provider.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/pricing.cpython-313.pyc +0 -0
- package/core/runtime/base.py +30 -1
- package/core/runtime/claude_code.py +68 -0
- package/core/runtime/codex_cli.py +33 -0
- package/core/runtime/cursor.py +19 -0
- package/core/runtime/gemini_cli.py +33 -0
- package/core/runtime/llm_cost_telemetry.py +306 -0
- package/core/runtime/llm_cost_telemetry_cli.py +138 -0
- package/core/runtime/llm_provider.py +382 -0
- package/core/runtime/pricing.py +85 -0
- package/core/synapse/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/synapse/__pycache__/engine.cpython-313.pyc +0 -0
- package/core/synapse/__pycache__/kb_cache.cpython-313.pyc +0 -0
- package/core/synapse/__pycache__/layers.cpython-313.pyc +0 -0
- package/core/workflow/__pycache__/flow_enforcer.cpython-313.pyc +0 -0
- package/core/workflow/__pycache__/kb_first_decider.cpython-313.pyc +0 -0
- package/core/workflow/__pycache__/marker_cache.cpython-313.pyc +0 -0
- package/core/workflow/__pycache__/research_gate.cpython-313.pyc +0 -0
- package/package.json +1 -1
- 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())
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED