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.
Files changed (34) hide show
  1. cli_modelarium/__init__.py +6 -0
  2. cli_modelarium/__main__.py +8 -0
  3. cli_modelarium/assertions.py +596 -0
  4. cli_modelarium/banner.py +96 -0
  5. cli_modelarium/batch.py +425 -0
  6. cli_modelarium/cli.py +2577 -0
  7. cli_modelarium/exceptions.py +88 -0
  8. cli_modelarium/hallucination.py +384 -0
  9. cli_modelarium/io_safety.py +112 -0
  10. cli_modelarium/judging.py +469 -0
  11. cli_modelarium/models_registry.py +138 -0
  12. cli_modelarium/output_formatters.py +1108 -0
  13. cli_modelarium/pricing.py +199 -0
  14. cli_modelarium/providers/__init__.py +7 -0
  15. cli_modelarium/providers/_utils.py +26 -0
  16. cli_modelarium/providers/anthropic_provider.py +148 -0
  17. cli_modelarium/providers/base.py +87 -0
  18. cli_modelarium/providers/deepseek_provider.py +15 -0
  19. cli_modelarium/providers/google_provider.py +135 -0
  20. cli_modelarium/providers/groq_provider.py +15 -0
  21. cli_modelarium/providers/local_provider.py +94 -0
  22. cli_modelarium/providers/mistral_provider.py +172 -0
  23. cli_modelarium/providers/openai_provider.py +163 -0
  24. cli_modelarium/providers/openrouter_provider.py +33 -0
  25. cli_modelarium/providers/xai_provider.py +15 -0
  26. cli_modelarium/run_statistics.py +1202 -0
  27. cli_modelarium/security.py +202 -0
  28. cli_modelarium/streaming.py +416 -0
  29. cli_modelarium-0.1.3.dist-info/METADATA +764 -0
  30. cli_modelarium-0.1.3.dist-info/RECORD +34 -0
  31. cli_modelarium-0.1.3.dist-info/WHEEL +4 -0
  32. cli_modelarium-0.1.3.dist-info/entry_points.txt +2 -0
  33. cli_modelarium-0.1.3.dist-info/licenses/LICENSE +201 -0
  34. 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,7 @@
1
+ """Provider abstraction layer for Cli Modelarium."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from cli_modelarium.providers.base import BaseProvider, CompletionResult
6
+
7
+ __all__ = ["BaseProvider", "CompletionResult"]
@@ -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)