cli-modelarium 0.1.3__py3-none-any.whl
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.
- cli_modelarium/__init__.py +6 -0
- cli_modelarium/__main__.py +8 -0
- cli_modelarium/assertions.py +596 -0
- cli_modelarium/banner.py +96 -0
- cli_modelarium/batch.py +425 -0
- cli_modelarium/cli.py +2577 -0
- cli_modelarium/exceptions.py +88 -0
- cli_modelarium/hallucination.py +384 -0
- cli_modelarium/io_safety.py +112 -0
- cli_modelarium/judging.py +469 -0
- cli_modelarium/models_registry.py +138 -0
- cli_modelarium/output_formatters.py +1108 -0
- cli_modelarium/pricing.py +199 -0
- cli_modelarium/providers/__init__.py +7 -0
- cli_modelarium/providers/_utils.py +26 -0
- cli_modelarium/providers/anthropic_provider.py +148 -0
- cli_modelarium/providers/base.py +87 -0
- cli_modelarium/providers/deepseek_provider.py +15 -0
- cli_modelarium/providers/google_provider.py +135 -0
- cli_modelarium/providers/groq_provider.py +15 -0
- cli_modelarium/providers/local_provider.py +94 -0
- cli_modelarium/providers/mistral_provider.py +172 -0
- cli_modelarium/providers/openai_provider.py +163 -0
- cli_modelarium/providers/openrouter_provider.py +33 -0
- cli_modelarium/providers/xai_provider.py +15 -0
- cli_modelarium/run_statistics.py +1202 -0
- cli_modelarium/security.py +202 -0
- cli_modelarium/streaming.py +416 -0
- cli_modelarium-0.1.3.dist-info/METADATA +764 -0
- cli_modelarium-0.1.3.dist-info/RECORD +34 -0
- cli_modelarium-0.1.3.dist-info/WHEEL +4 -0
- cli_modelarium-0.1.3.dist-info/entry_points.txt +2 -0
- cli_modelarium-0.1.3.dist-info/licenses/LICENSE +201 -0
- cli_modelarium-0.1.3.dist-info/licenses/NOTICE +102 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Pricing data and cost calculation for all supported models.
|
|
2
|
+
|
|
3
|
+
Pricing is per 1M tokens, in USD. Verified May 25, 2026 from each provider's
|
|
4
|
+
official documentation. LLM pricing changes frequently - re-verify against
|
|
5
|
+
the provider's pricing page before relying on these values for production
|
|
6
|
+
budgeting.
|
|
7
|
+
|
|
8
|
+
Schema per entry:
|
|
9
|
+
input - cost per 1M input tokens (required)
|
|
10
|
+
output - cost per 1M output tokens (required)
|
|
11
|
+
cached_input - cost per 1M cached input tokens (optional; typically ~90% off)
|
|
12
|
+
provider - provider name matching BaseProvider.name (required)
|
|
13
|
+
is_local - True for local models (optional; always free)
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from cli_modelarium.exceptions import UnknownModelError
|
|
19
|
+
|
|
20
|
+
PRICING_AS_OF = "2026-05-25"
|
|
21
|
+
|
|
22
|
+
PRICING: dict[str, dict[str, float | str | bool]] = {
|
|
23
|
+
# ===== OpenAI =====
|
|
24
|
+
"gpt-5.5": {"input": 5.00, "output": 30.00, "cached_input": 0.50, "provider": "openai"},
|
|
25
|
+
"gpt-5.5-pro": {"input": 30.00, "output": 180.00, "provider": "openai"},
|
|
26
|
+
"gpt-5.4": {"input": 2.50, "output": 15.00, "cached_input": 1.25, "provider": "openai"},
|
|
27
|
+
"gpt-5.4-mini": {"input": 0.75, "output": 4.50, "cached_input": 0.40, "provider": "openai"},
|
|
28
|
+
"gpt-5.4-nano": {"input": 0.20, "output": 1.25, "cached_input": 0.10, "provider": "openai"},
|
|
29
|
+
"gpt-5.4-pro": {"input": 15.00, "output": 60.00, "provider": "openai"},
|
|
30
|
+
"gpt-5.3-codex": {"input": 1.75, "output": 14.00, "provider": "openai"},
|
|
31
|
+
"gpt-5.3-codex-spark": {"input": 0.50, "output": 2.00, "provider": "openai"},
|
|
32
|
+
"o3": {"input": 15.00, "output": 60.00, "cached_input": 7.50, "provider": "openai"},
|
|
33
|
+
"o3-pro": {"input": 30.00, "output": 120.00, "provider": "openai"},
|
|
34
|
+
"o4-mini": {"input": 1.10, "output": 4.40, "cached_input": 0.55, "provider": "openai"},
|
|
35
|
+
"gpt-oss-120b": {"input": 0.30, "output": 0.60, "provider": "openai"},
|
|
36
|
+
"gpt-oss-20b": {"input": 0.10, "output": 0.30, "provider": "openai"},
|
|
37
|
+
"gpt-5": {"input": 1.25, "output": 10.00, "provider": "openai"},
|
|
38
|
+
"gpt-4.1-mini": {"input": 0.40, "output": 1.60, "provider": "openai"},
|
|
39
|
+
"gpt-4o": {"input": 2.50, "output": 10.00, "provider": "openai"},
|
|
40
|
+
"gpt-4o-mini": {"input": 0.15, "output": 0.60, "provider": "openai"},
|
|
41
|
+
# ===== Anthropic =====
|
|
42
|
+
# Verified against Anthropic's pricing page May 25, 2026.
|
|
43
|
+
"claude-opus-4-7": {
|
|
44
|
+
"input": 5.00,
|
|
45
|
+
"output": 25.00,
|
|
46
|
+
"cached_input": 0.50,
|
|
47
|
+
"provider": "anthropic",
|
|
48
|
+
},
|
|
49
|
+
"claude-sonnet-4-6": {
|
|
50
|
+
"input": 3.00,
|
|
51
|
+
"output": 15.00,
|
|
52
|
+
"cached_input": 0.30,
|
|
53
|
+
"provider": "anthropic",
|
|
54
|
+
},
|
|
55
|
+
"claude-haiku-4-5": {
|
|
56
|
+
"input": 1.00,
|
|
57
|
+
"output": 5.00,
|
|
58
|
+
"cached_input": 0.10,
|
|
59
|
+
"provider": "anthropic",
|
|
60
|
+
},
|
|
61
|
+
"claude-opus-4-6": {
|
|
62
|
+
"input": 5.00,
|
|
63
|
+
"output": 25.00,
|
|
64
|
+
"cached_input": 0.50,
|
|
65
|
+
"provider": "anthropic",
|
|
66
|
+
},
|
|
67
|
+
# ===== Google Gemini (Google uses dots in model IDs) =====
|
|
68
|
+
"gemini-3.5-flash": {"input": 0.50, "output": 3.00, "provider": "google"},
|
|
69
|
+
"gemini-3.1-pro": {"input": 1.25, "output": 5.00, "cached_input": 0.625, "provider": "google"},
|
|
70
|
+
"gemini-3.1-flash-lite": {"input": 0.10, "output": 0.40, "provider": "google"},
|
|
71
|
+
"gemini-3-flash": {"input": 0.30, "output": 2.50, "provider": "google"},
|
|
72
|
+
"gemini-2.5-flash": {"input": 0.30, "output": 2.50, "provider": "google"},
|
|
73
|
+
"gemini-2.5-flash-lite": {"input": 0.10, "output": 0.40, "provider": "google"},
|
|
74
|
+
# ===== xAI Grok (xAI uses dots in model IDs) =====
|
|
75
|
+
"grok-4.3": {"input": 1.25, "output": 2.50, "cached_input": 0.20, "provider": "xai"},
|
|
76
|
+
"grok-4.20": {"input": 2.00, "output": 6.00, "cached_input": 0.20, "provider": "xai"},
|
|
77
|
+
"grok-4.20-multi-agent-beta": {
|
|
78
|
+
"input": 2.00,
|
|
79
|
+
"output": 6.00,
|
|
80
|
+
"cached_input": 0.20,
|
|
81
|
+
"provider": "xai",
|
|
82
|
+
},
|
|
83
|
+
"grok-4.1-fast": {"input": 0.20, "output": 0.50, "cached_input": 0.05, "provider": "xai"},
|
|
84
|
+
"grok-build-0.1": {"input": 1.00, "output": 2.00, "cached_input": 0.20, "provider": "xai"},
|
|
85
|
+
# ===== DeepSeek =====
|
|
86
|
+
"deepseek-v4-pro": {
|
|
87
|
+
"input": 0.55,
|
|
88
|
+
"output": 2.19,
|
|
89
|
+
"cached_input": 0.055,
|
|
90
|
+
"provider": "deepseek",
|
|
91
|
+
},
|
|
92
|
+
"deepseek-v4-flash": {
|
|
93
|
+
"input": 0.27,
|
|
94
|
+
"output": 1.10,
|
|
95
|
+
"cached_input": 0.027,
|
|
96
|
+
"provider": "deepseek",
|
|
97
|
+
},
|
|
98
|
+
# Legacy aliases - deprecating July 24, 2026; kept for backward compatibility.
|
|
99
|
+
"deepseek-chat": {"input": 0.27, "output": 1.10, "cached_input": 0.027, "provider": "deepseek"},
|
|
100
|
+
"deepseek-reasoner": {
|
|
101
|
+
"input": 0.27,
|
|
102
|
+
"output": 1.10,
|
|
103
|
+
"cached_input": 0.027,
|
|
104
|
+
"provider": "deepseek",
|
|
105
|
+
},
|
|
106
|
+
# ===== Mistral =====
|
|
107
|
+
"mistral-medium-3.5": {"input": 0.40, "output": 2.00, "provider": "mistral"},
|
|
108
|
+
"mistral-medium-latest": {"input": 0.40, "output": 2.00, "provider": "mistral"},
|
|
109
|
+
"mistral-large-latest": {"input": 0.50, "output": 1.50, "provider": "mistral"},
|
|
110
|
+
"mistral-small-latest": {"input": 0.15, "output": 0.60, "provider": "mistral"},
|
|
111
|
+
"codestral-latest": {"input": 0.30, "output": 0.90, "provider": "mistral"},
|
|
112
|
+
"magistral-medium-latest": {"input": 2.00, "output": 5.00, "provider": "mistral"},
|
|
113
|
+
"magistral-small-latest": {"input": 0.50, "output": 1.50, "provider": "mistral"},
|
|
114
|
+
# ===== Groq =====
|
|
115
|
+
"llama-3.3-70b-versatile": {"input": 0.59, "output": 0.79, "provider": "groq"},
|
|
116
|
+
"openai/gpt-oss-120b": {"input": 0.30, "output": 0.60, "provider": "groq"},
|
|
117
|
+
"openai/gpt-oss-safeguard-20b": {"input": 0.10, "output": 0.30, "provider": "groq"},
|
|
118
|
+
"meta-llama/llama-4-scout-17b-16e-instruct": {
|
|
119
|
+
"input": 0.20,
|
|
120
|
+
"output": 0.30,
|
|
121
|
+
"provider": "groq",
|
|
122
|
+
},
|
|
123
|
+
# ===== OpenRouter =====
|
|
124
|
+
# OpenRouter aggregates 315+ models behind one API. We register a few
|
|
125
|
+
# representative entries here; users can pass any OpenRouter model ID and
|
|
126
|
+
# we'll route it. Models not in this dict get a fallback cost of $0 (the
|
|
127
|
+
# provider returns real usage data in the response either way).
|
|
128
|
+
"qwen/qwen3.7-max": {"input": 2.50, "output": 7.50, "provider": "openrouter"},
|
|
129
|
+
"qwen/qwen3.5-plus": {"input": 0.30, "output": 1.80, "provider": "openrouter"},
|
|
130
|
+
"qwen/qwen3.6-flash": {"input": 0.19, "output": 1.13, "provider": "openrouter"},
|
|
131
|
+
"qwen/qwen3-coder:free": {"input": 0.0, "output": 0.0, "provider": "openrouter"},
|
|
132
|
+
"deepseek/deepseek-r1:free": {"input": 0.0, "output": 0.0, "provider": "openrouter"},
|
|
133
|
+
"meta-llama/llama-3-3-70b-instruct:free": {
|
|
134
|
+
"input": 0.0,
|
|
135
|
+
"output": 0.0,
|
|
136
|
+
"provider": "openrouter",
|
|
137
|
+
},
|
|
138
|
+
"openai/gpt-oss-120b:free": {"input": 0.0, "output": 0.0, "provider": "openrouter"},
|
|
139
|
+
"zhipuai/glm-4.7-flash:free": {"input": 0.0, "output": 0.0, "provider": "openrouter"},
|
|
140
|
+
# ===== Local =====
|
|
141
|
+
# Wildcard entry. Any model with `local/` prefix resolves here and costs $0.
|
|
142
|
+
"local/*": {"input": 0.0, "output": 0.0, "provider": "local", "is_local": True},
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def is_local_model(model: str) -> bool:
|
|
147
|
+
"""Return True for any model with the `local/` prefix."""
|
|
148
|
+
return model.startswith("local/")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def get_pricing(model: str) -> dict[str, float | str | bool] | None:
|
|
152
|
+
"""Look up the pricing entry for a model.
|
|
153
|
+
|
|
154
|
+
Local models always resolve to the `local/*` entry.
|
|
155
|
+
Returns None if the model is unknown (callers handle the error).
|
|
156
|
+
"""
|
|
157
|
+
if is_local_model(model):
|
|
158
|
+
return PRICING["local/*"]
|
|
159
|
+
return PRICING.get(model)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def calculate_cost(
|
|
163
|
+
model: str,
|
|
164
|
+
input_tokens: int,
|
|
165
|
+
output_tokens: int,
|
|
166
|
+
cached_tokens: int = 0,
|
|
167
|
+
) -> float:
|
|
168
|
+
"""Return the USD cost for a single completion.
|
|
169
|
+
|
|
170
|
+
Cached input tokens use the discounted `cached_input` rate if the model's
|
|
171
|
+
pricing entry includes one; otherwise they fall back to the normal input
|
|
172
|
+
rate. Local models always cost $0.
|
|
173
|
+
"""
|
|
174
|
+
if is_local_model(model):
|
|
175
|
+
return 0.0
|
|
176
|
+
|
|
177
|
+
pricing = PRICING.get(model)
|
|
178
|
+
if pricing is None:
|
|
179
|
+
raise UnknownModelError(
|
|
180
|
+
f"Unknown model: {model}. Run `cli-modelarium list-models` to see supported models."
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
cached_tokens = max(0, min(cached_tokens, input_tokens))
|
|
184
|
+
non_cached = input_tokens - cached_tokens
|
|
185
|
+
|
|
186
|
+
cost = (non_cached / 1_000_000) * float(pricing["input"])
|
|
187
|
+
|
|
188
|
+
if cached_tokens > 0:
|
|
189
|
+
cached_rate = pricing.get("cached_input", pricing["input"])
|
|
190
|
+
cost += (cached_tokens / 1_000_000) * float(cached_rate)
|
|
191
|
+
|
|
192
|
+
cost += (output_tokens / 1_000_000) * float(pricing["output"])
|
|
193
|
+
|
|
194
|
+
return cost
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def pricing_freshness_note() -> str:
|
|
198
|
+
"""Return the standard pricing-freshness disclaimer for user-facing output."""
|
|
199
|
+
return f"Note: Pricing data as of {PRICING_AS_OF}. Verify current pricing at provider websites."
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Small helpers shared across provider modules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def extract_retry_after(error: Any) -> float | None:
|
|
9
|
+
"""Read a numeric `Retry-After` header from an SDK exception's response.
|
|
10
|
+
|
|
11
|
+
Handles either case (`retry-after` or `Retry-After`). Returns None if the
|
|
12
|
+
header is missing or not a parseable number (HTTP-date format is ignored).
|
|
13
|
+
"""
|
|
14
|
+
response = getattr(error, "response", None)
|
|
15
|
+
if response is None:
|
|
16
|
+
return None
|
|
17
|
+
headers = getattr(response, "headers", None)
|
|
18
|
+
if headers is None:
|
|
19
|
+
return None
|
|
20
|
+
raw = headers.get("retry-after") or headers.get("Retry-After")
|
|
21
|
+
if raw is None:
|
|
22
|
+
return None
|
|
23
|
+
try:
|
|
24
|
+
return float(raw)
|
|
25
|
+
except (TypeError, ValueError):
|
|
26
|
+
return None
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Anthropic provider implementation.
|
|
2
|
+
|
|
3
|
+
Two things make Anthropic distinct from OpenAI-style providers:
|
|
4
|
+
|
|
5
|
+
1. The `system` prompt is a TOP-LEVEL parameter on `messages.create()`,
|
|
6
|
+
NOT a message with `role: "system"` inside the messages array.
|
|
7
|
+
2. `max_tokens` is REQUIRED on every call. We default to 4096 if the
|
|
8
|
+
caller doesn't specify one.
|
|
9
|
+
|
|
10
|
+
Streaming uses the `messages.stream()` async context manager. The final
|
|
11
|
+
usage payload (input/output/cache-read tokens) is read after iteration
|
|
12
|
+
via `stream.get_final_message()`.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import time
|
|
18
|
+
from collections.abc import AsyncIterator
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
import anthropic
|
|
22
|
+
from anthropic import AsyncAnthropic
|
|
23
|
+
|
|
24
|
+
from cli_modelarium.exceptions import (
|
|
25
|
+
AuthenticationError,
|
|
26
|
+
ProviderError,
|
|
27
|
+
ProviderOverloadedError,
|
|
28
|
+
RateLimitError,
|
|
29
|
+
)
|
|
30
|
+
from cli_modelarium.pricing import calculate_cost
|
|
31
|
+
from cli_modelarium.providers._utils import extract_retry_after
|
|
32
|
+
from cli_modelarium.providers.base import BaseProvider, CompletionResult, OnChunk
|
|
33
|
+
from cli_modelarium.security import redact_secrets
|
|
34
|
+
|
|
35
|
+
DEFAULT_MAX_TOKENS = 4096
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class AnthropicProvider(BaseProvider):
|
|
39
|
+
"""Provider using the official Anthropic Python SDK."""
|
|
40
|
+
|
|
41
|
+
name: str = "anthropic"
|
|
42
|
+
|
|
43
|
+
def __init__(self, api_key: str) -> None:
|
|
44
|
+
self.client = AsyncAnthropic(api_key=api_key)
|
|
45
|
+
|
|
46
|
+
def _build_kwargs(
|
|
47
|
+
self,
|
|
48
|
+
prompt: str,
|
|
49
|
+
model: str,
|
|
50
|
+
temperature: float,
|
|
51
|
+
system_prompt: str | None,
|
|
52
|
+
max_tokens: int = DEFAULT_MAX_TOKENS,
|
|
53
|
+
) -> dict[str, Any]:
|
|
54
|
+
kwargs: dict[str, Any] = {
|
|
55
|
+
"model": model,
|
|
56
|
+
"messages": [{"role": "user", "content": prompt}],
|
|
57
|
+
"temperature": temperature,
|
|
58
|
+
"max_tokens": max_tokens,
|
|
59
|
+
}
|
|
60
|
+
if system_prompt:
|
|
61
|
+
kwargs["system"] = system_prompt
|
|
62
|
+
return kwargs
|
|
63
|
+
|
|
64
|
+
async def stream(
|
|
65
|
+
self,
|
|
66
|
+
prompt: str,
|
|
67
|
+
model: str,
|
|
68
|
+
temperature: float,
|
|
69
|
+
system_prompt: str | None = None,
|
|
70
|
+
) -> AsyncIterator[str]:
|
|
71
|
+
kwargs = self._build_kwargs(prompt, model, temperature, system_prompt)
|
|
72
|
+
try:
|
|
73
|
+
async with self.client.messages.stream(**kwargs) as stream:
|
|
74
|
+
async for text in stream.text_stream:
|
|
75
|
+
yield text
|
|
76
|
+
except anthropic.APIError as e:
|
|
77
|
+
self._reraise(e)
|
|
78
|
+
|
|
79
|
+
async def complete(
|
|
80
|
+
self,
|
|
81
|
+
prompt: str,
|
|
82
|
+
model: str,
|
|
83
|
+
temperature: float,
|
|
84
|
+
system_prompt: str | None = None,
|
|
85
|
+
*,
|
|
86
|
+
on_chunk: OnChunk | None = None,
|
|
87
|
+
) -> CompletionResult:
|
|
88
|
+
kwargs = self._build_kwargs(prompt, model, temperature, system_prompt)
|
|
89
|
+
|
|
90
|
+
start = time.monotonic()
|
|
91
|
+
ttft_ms: float | None = None
|
|
92
|
+
chunks: list[str] = []
|
|
93
|
+
input_tokens = 0
|
|
94
|
+
output_tokens = 0
|
|
95
|
+
cached_tokens = 0
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
async with self.client.messages.stream(**kwargs) as stream:
|
|
99
|
+
async for text in stream.text_stream:
|
|
100
|
+
if ttft_ms is None:
|
|
101
|
+
ttft_ms = (time.monotonic() - start) * 1000
|
|
102
|
+
if on_chunk is not None:
|
|
103
|
+
on_chunk(text)
|
|
104
|
+
chunks.append(text)
|
|
105
|
+
final = await stream.get_final_message()
|
|
106
|
+
usage = final.usage
|
|
107
|
+
input_tokens = getattr(usage, "input_tokens", 0) or 0
|
|
108
|
+
output_tokens = getattr(usage, "output_tokens", 0) or 0
|
|
109
|
+
cached_tokens = getattr(usage, "cache_read_input_tokens", 0) or 0
|
|
110
|
+
except anthropic.APIError as e:
|
|
111
|
+
self._reraise(e)
|
|
112
|
+
|
|
113
|
+
latency_ms = (time.monotonic() - start) * 1000
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
cost = calculate_cost(model, input_tokens, output_tokens, cached_tokens)
|
|
117
|
+
except Exception:
|
|
118
|
+
cost = 0.0
|
|
119
|
+
|
|
120
|
+
return CompletionResult(
|
|
121
|
+
output="".join(chunks),
|
|
122
|
+
input_tokens=input_tokens,
|
|
123
|
+
output_tokens=output_tokens,
|
|
124
|
+
cached_tokens=cached_tokens,
|
|
125
|
+
cost_usd=cost,
|
|
126
|
+
latency_ms=latency_ms,
|
|
127
|
+
ttft_ms=ttft_ms,
|
|
128
|
+
model=model,
|
|
129
|
+
provider=self.name,
|
|
130
|
+
temperature=temperature,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def _reraise(self, error: anthropic.APIError) -> None:
|
|
134
|
+
"""Translate an Anthropic SDK error into a redacted Cli Modelarium exception."""
|
|
135
|
+
message = redact_secrets(str(error))
|
|
136
|
+
|
|
137
|
+
if isinstance(error, anthropic.AuthenticationError):
|
|
138
|
+
raise AuthenticationError(message, provider=self.name) from None
|
|
139
|
+
if isinstance(error, anthropic.RateLimitError):
|
|
140
|
+
retry_after = extract_retry_after(error)
|
|
141
|
+
raise RateLimitError(message, provider=self.name, retry_after=retry_after) from None
|
|
142
|
+
# Anthropic returns 529 when their service is overloaded - distinct from rate limits.
|
|
143
|
+
if (
|
|
144
|
+
isinstance(error, anthropic.APIStatusError)
|
|
145
|
+
and getattr(error, "status_code", None) == 529
|
|
146
|
+
):
|
|
147
|
+
raise ProviderOverloadedError(message, provider=self.name) from None
|
|
148
|
+
raise ProviderError(message, provider=self.name) from None
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Provider abstraction: every concrete provider implements this interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from collections.abc import AsyncIterator, Callable
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class CompletionResult:
|
|
12
|
+
"""The full result of a single completion call.
|
|
13
|
+
|
|
14
|
+
All fields default-initialized so partial results (on error, mid-stream
|
|
15
|
+
failure, or sync collection) remain valid dataclasses.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
output: str = ""
|
|
19
|
+
input_tokens: int = 0
|
|
20
|
+
output_tokens: int = 0
|
|
21
|
+
cached_tokens: int = 0
|
|
22
|
+
cost_usd: float = 0.0
|
|
23
|
+
latency_ms: float = 0.0
|
|
24
|
+
ttft_ms: float | None = None
|
|
25
|
+
model: str = ""
|
|
26
|
+
provider: str = ""
|
|
27
|
+
temperature: float = 0.0
|
|
28
|
+
error: str | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# Type alias for the per-chunk callback that the streaming orchestrator passes in.
|
|
32
|
+
OnChunk = Callable[[str], None]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class BaseProvider(ABC):
|
|
36
|
+
"""Abstract base class for every provider integration.
|
|
37
|
+
|
|
38
|
+
Subclasses implement `stream()` as an async generator that yields token
|
|
39
|
+
chunks. The default `complete()` collects those chunks but cannot capture
|
|
40
|
+
usage data - subclasses should override `complete()` to read the final
|
|
41
|
+
usage payload from the provider's response.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
name: str = "base"
|
|
45
|
+
|
|
46
|
+
@abstractmethod
|
|
47
|
+
async def stream(
|
|
48
|
+
self,
|
|
49
|
+
prompt: str,
|
|
50
|
+
model: str,
|
|
51
|
+
temperature: float,
|
|
52
|
+
system_prompt: str | None = None,
|
|
53
|
+
) -> AsyncIterator[str]:
|
|
54
|
+
"""Yield response token chunks as they arrive from the provider."""
|
|
55
|
+
...
|
|
56
|
+
|
|
57
|
+
async def complete(
|
|
58
|
+
self,
|
|
59
|
+
prompt: str,
|
|
60
|
+
model: str,
|
|
61
|
+
temperature: float,
|
|
62
|
+
system_prompt: str | None = None,
|
|
63
|
+
*,
|
|
64
|
+
on_chunk: OnChunk | None = None,
|
|
65
|
+
) -> CompletionResult:
|
|
66
|
+
"""Run a non-streaming completion.
|
|
67
|
+
|
|
68
|
+
Default implementation collects chunks from `stream()` but cannot
|
|
69
|
+
capture token usage. Subclasses should override to read usage data
|
|
70
|
+
from the final API response.
|
|
71
|
+
|
|
72
|
+
If `on_chunk` is provided it is invoked with each text chunk before
|
|
73
|
+
the chunk is appended to the accumulator. This lets the streaming
|
|
74
|
+
orchestrator surface partial output live without changing the public
|
|
75
|
+
return type.
|
|
76
|
+
"""
|
|
77
|
+
chunks: list[str] = []
|
|
78
|
+
async for chunk in self.stream(prompt, model, temperature, system_prompt):
|
|
79
|
+
if on_chunk is not None:
|
|
80
|
+
on_chunk(chunk)
|
|
81
|
+
chunks.append(chunk)
|
|
82
|
+
return CompletionResult(
|
|
83
|
+
output="".join(chunks),
|
|
84
|
+
model=model,
|
|
85
|
+
provider=self.name,
|
|
86
|
+
temperature=temperature,
|
|
87
|
+
)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""DeepSeek provider - uses the OpenAI SDK with a different base URL."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from cli_modelarium.providers.openai_provider import OpenAIProvider
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class DeepSeekProvider(OpenAIProvider):
|
|
9
|
+
"""DeepSeek models via the OpenAI-compatible endpoint at api.deepseek.com."""
|
|
10
|
+
|
|
11
|
+
name: str = "deepseek"
|
|
12
|
+
BASE_URL: str = "https://api.deepseek.com/v1"
|
|
13
|
+
|
|
14
|
+
def __init__(self, api_key: str) -> None:
|
|
15
|
+
super().__init__(api_key=api_key, base_url=self.BASE_URL)
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Google Gemini provider using the google-genai SDK.
|
|
2
|
+
|
|
3
|
+
API quirks compared to OpenAI-style providers:
|
|
4
|
+
|
|
5
|
+
- The Client is constructed via `genai.Client(api_key=...)`.
|
|
6
|
+
- Async work goes through `client.aio.models.*`.
|
|
7
|
+
- System prompts live inside the `config` dict as `system_instruction`,
|
|
8
|
+
not as a message with `role="system"`.
|
|
9
|
+
- Usage data is on the chunk's `usage_metadata` (with field names like
|
|
10
|
+
`prompt_token_count` rather than OpenAI's `prompt_tokens`).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import time
|
|
16
|
+
from collections.abc import AsyncIterator
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from google import genai
|
|
20
|
+
from google.genai import errors as genai_errors
|
|
21
|
+
|
|
22
|
+
from cli_modelarium.exceptions import (
|
|
23
|
+
AuthenticationError,
|
|
24
|
+
ProviderError,
|
|
25
|
+
RateLimitError,
|
|
26
|
+
)
|
|
27
|
+
from cli_modelarium.pricing import calculate_cost
|
|
28
|
+
from cli_modelarium.providers.base import BaseProvider, CompletionResult, OnChunk
|
|
29
|
+
from cli_modelarium.security import redact_secrets
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class GoogleProvider(BaseProvider):
|
|
33
|
+
"""Provider using the official google-genai SDK for Gemini models."""
|
|
34
|
+
|
|
35
|
+
name: str = "google"
|
|
36
|
+
|
|
37
|
+
def __init__(self, api_key: str) -> None:
|
|
38
|
+
self.client = genai.Client(api_key=api_key)
|
|
39
|
+
|
|
40
|
+
def _build_config(self, temperature: float, system_prompt: str | None) -> dict[str, Any]:
|
|
41
|
+
config: dict[str, Any] = {"temperature": temperature}
|
|
42
|
+
if system_prompt:
|
|
43
|
+
config["system_instruction"] = system_prompt
|
|
44
|
+
return config
|
|
45
|
+
|
|
46
|
+
async def stream(
|
|
47
|
+
self,
|
|
48
|
+
prompt: str,
|
|
49
|
+
model: str,
|
|
50
|
+
temperature: float,
|
|
51
|
+
system_prompt: str | None = None,
|
|
52
|
+
) -> AsyncIterator[str]:
|
|
53
|
+
config = self._build_config(temperature, system_prompt)
|
|
54
|
+
try:
|
|
55
|
+
response_stream = await self.client.aio.models.generate_content_stream(
|
|
56
|
+
model=model,
|
|
57
|
+
contents=prompt,
|
|
58
|
+
config=config,
|
|
59
|
+
)
|
|
60
|
+
async for chunk in response_stream:
|
|
61
|
+
if chunk.text:
|
|
62
|
+
yield chunk.text
|
|
63
|
+
except genai_errors.APIError as e:
|
|
64
|
+
self._reraise(e)
|
|
65
|
+
|
|
66
|
+
async def complete(
|
|
67
|
+
self,
|
|
68
|
+
prompt: str,
|
|
69
|
+
model: str,
|
|
70
|
+
temperature: float,
|
|
71
|
+
system_prompt: str | None = None,
|
|
72
|
+
*,
|
|
73
|
+
on_chunk: OnChunk | None = None,
|
|
74
|
+
) -> CompletionResult:
|
|
75
|
+
config = self._build_config(temperature, system_prompt)
|
|
76
|
+
|
|
77
|
+
start = time.monotonic()
|
|
78
|
+
ttft_ms: float | None = None
|
|
79
|
+
chunks: list[str] = []
|
|
80
|
+
input_tokens = 0
|
|
81
|
+
output_tokens = 0
|
|
82
|
+
cached_tokens = 0
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
response_stream = await self.client.aio.models.generate_content_stream(
|
|
86
|
+
model=model,
|
|
87
|
+
contents=prompt,
|
|
88
|
+
config=config,
|
|
89
|
+
)
|
|
90
|
+
async for chunk in response_stream:
|
|
91
|
+
if chunk.text:
|
|
92
|
+
if ttft_ms is None:
|
|
93
|
+
ttft_ms = (time.monotonic() - start) * 1000
|
|
94
|
+
if on_chunk is not None:
|
|
95
|
+
on_chunk(chunk.text)
|
|
96
|
+
chunks.append(chunk.text)
|
|
97
|
+
# Final chunk carries the usage_metadata.
|
|
98
|
+
usage = getattr(chunk, "usage_metadata", None)
|
|
99
|
+
if usage is not None:
|
|
100
|
+
input_tokens = getattr(usage, "prompt_token_count", 0) or 0
|
|
101
|
+
output_tokens = getattr(usage, "candidates_token_count", 0) or 0
|
|
102
|
+
cached_tokens = getattr(usage, "cached_content_token_count", 0) or 0
|
|
103
|
+
except genai_errors.APIError as e:
|
|
104
|
+
self._reraise(e)
|
|
105
|
+
|
|
106
|
+
latency_ms = (time.monotonic() - start) * 1000
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
cost = calculate_cost(model, input_tokens, output_tokens, cached_tokens)
|
|
110
|
+
except Exception:
|
|
111
|
+
cost = 0.0
|
|
112
|
+
|
|
113
|
+
return CompletionResult(
|
|
114
|
+
output="".join(chunks),
|
|
115
|
+
input_tokens=input_tokens,
|
|
116
|
+
output_tokens=output_tokens,
|
|
117
|
+
cached_tokens=cached_tokens,
|
|
118
|
+
cost_usd=cost,
|
|
119
|
+
latency_ms=latency_ms,
|
|
120
|
+
ttft_ms=ttft_ms,
|
|
121
|
+
model=model,
|
|
122
|
+
provider=self.name,
|
|
123
|
+
temperature=temperature,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
def _reraise(self, error: genai_errors.APIError) -> None:
|
|
127
|
+
"""Translate a google-genai SDK error into a redacted Cli Modelarium exception."""
|
|
128
|
+
message = redact_secrets(str(error))
|
|
129
|
+
# google-genai doesn't expose typed subclasses for 401/429 - route by HTTP code.
|
|
130
|
+
code = getattr(error, "code", None)
|
|
131
|
+
if code in (401, 403):
|
|
132
|
+
raise AuthenticationError(message, provider=self.name) from None
|
|
133
|
+
if code == 429:
|
|
134
|
+
raise RateLimitError(message, provider=self.name, retry_after=None) from None
|
|
135
|
+
raise ProviderError(message, provider=self.name) from None
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Groq provider - uses the OpenAI SDK with a different base URL."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from cli_modelarium.providers.openai_provider import OpenAIProvider
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class GroqProvider(OpenAIProvider):
|
|
9
|
+
"""Groq models via the OpenAI-compatible endpoint at api.groq.com."""
|
|
10
|
+
|
|
11
|
+
name: str = "groq"
|
|
12
|
+
BASE_URL: str = "https://api.groq.com/openai/v1"
|
|
13
|
+
|
|
14
|
+
def __init__(self, api_key: str) -> None:
|
|
15
|
+
super().__init__(api_key=api_key, base_url=self.BASE_URL)
|