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,94 @@
|
|
|
1
|
+
"""Local model provider for Ollama, LM Studio, vLLM, llama.cpp, etc.
|
|
2
|
+
|
|
3
|
+
All major local LLM tools expose an OpenAI-compatible REST API. This
|
|
4
|
+
provider inherits from `OpenAIProvider` and overrides only:
|
|
5
|
+
|
|
6
|
+
* the base URL (default Ollama at localhost:11434)
|
|
7
|
+
* `_transform_model()` to strip the `local/` prefix before sending to
|
|
8
|
+
the SDK (`local/llama-3.3-70b` -> `llama-3.3-70b` on the wire)
|
|
9
|
+
* a localhost-only URL guard - this is a SECURITY feature, not a
|
|
10
|
+
convenience. The CLI treats "local" as meaning local; a typo'd remote
|
|
11
|
+
URL would silently send local prompts to a third party. Refused.
|
|
12
|
+
|
|
13
|
+
Streaming, system prompts, and on_chunk callbacks all work without further
|
|
14
|
+
overrides via the inherited OpenAIProvider behaviour.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from typing import Any
|
|
20
|
+
from urllib.parse import urlparse
|
|
21
|
+
|
|
22
|
+
import httpx
|
|
23
|
+
|
|
24
|
+
from cli_modelarium.exceptions import LocalURLError
|
|
25
|
+
from cli_modelarium.providers.openai_provider import OpenAIProvider
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class LocalProvider(OpenAIProvider):
|
|
29
|
+
"""Provider for any OpenAI-compatible LLM server running on localhost."""
|
|
30
|
+
|
|
31
|
+
name: str = "local"
|
|
32
|
+
DEFAULT_URL: str = "http://localhost:11434/v1"
|
|
33
|
+
LOCAL_HOSTS: frozenset[str] = frozenset({"localhost", "127.0.0.1", "::1", "0.0.0.0"})
|
|
34
|
+
DUMMY_API_KEY: str = "not-required"
|
|
35
|
+
DISCOVERY_TIMEOUT_SECONDS: float = 5.0
|
|
36
|
+
|
|
37
|
+
def __init__(self, base_url: str | None = None) -> None:
|
|
38
|
+
url = base_url or self.DEFAULT_URL
|
|
39
|
+
validated = self._validate_local_url(url)
|
|
40
|
+
super().__init__(api_key=self.DUMMY_API_KEY, base_url=validated)
|
|
41
|
+
|
|
42
|
+
def _transform_model(self, model: str) -> str:
|
|
43
|
+
"""Strip the `local/` routing prefix before sending to the SDK."""
|
|
44
|
+
return model.removeprefix("local/")
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def _validate_local_url(cls, url: str) -> str:
|
|
48
|
+
"""Return `url` if it parses as an http(s) URL targeting a localhost host.
|
|
49
|
+
|
|
50
|
+
Raises LocalURLError otherwise with an actionable message. This is the
|
|
51
|
+
security boundary - a misconfigured URL must never silently leak local
|
|
52
|
+
data to a remote endpoint.
|
|
53
|
+
"""
|
|
54
|
+
parsed = urlparse(url)
|
|
55
|
+
if parsed.scheme not in ("http", "https"):
|
|
56
|
+
raise LocalURLError(
|
|
57
|
+
f"Local URL must use http or https (got scheme {parsed.scheme!r} in {url!r}).\n"
|
|
58
|
+
f" Example: http://localhost:11434/v1"
|
|
59
|
+
)
|
|
60
|
+
if parsed.hostname not in cls.LOCAL_HOSTS:
|
|
61
|
+
allowed = ", ".join(sorted(cls.LOCAL_HOSTS))
|
|
62
|
+
raise LocalURLError(
|
|
63
|
+
f"Local URL must point to localhost "
|
|
64
|
+
f"(got hostname {parsed.hostname!r} in {url!r}).\n"
|
|
65
|
+
f" Allowed hostnames: {allowed}\n"
|
|
66
|
+
f" For remote OpenAI-compatible endpoints, use a real "
|
|
67
|
+
f"provider integration,\n"
|
|
68
|
+
f" not the local provider - this guard prevents accidental "
|
|
69
|
+
f"data leakage."
|
|
70
|
+
)
|
|
71
|
+
return url
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
async def discover_models(cls, base_url: str | None = None) -> list[dict[str, Any]]:
|
|
75
|
+
"""Query the local server's `/models` endpoint and return the entries.
|
|
76
|
+
|
|
77
|
+
Returns the `data` array from an OpenAI-compatible `/models` response:
|
|
78
|
+
[{"id": "...", "created": ..., "owned_by": "...", ...}, ...]
|
|
79
|
+
|
|
80
|
+
URL validation runs first - a non-localhost URL raises LocalURLError
|
|
81
|
+
before any network I/O happens. Network errors (connection refused,
|
|
82
|
+
timeout, malformed response) propagate so callers can render a friendly
|
|
83
|
+
"no local server" message.
|
|
84
|
+
"""
|
|
85
|
+
url = cls._validate_local_url(base_url or cls.DEFAULT_URL)
|
|
86
|
+
models_url = url.rstrip("/") + "/models"
|
|
87
|
+
async with httpx.AsyncClient(timeout=cls.DISCOVERY_TIMEOUT_SECONDS) as client:
|
|
88
|
+
response = await client.get(models_url)
|
|
89
|
+
response.raise_for_status()
|
|
90
|
+
payload = response.json()
|
|
91
|
+
data = payload.get("data", [])
|
|
92
|
+
if not isinstance(data, list):
|
|
93
|
+
return []
|
|
94
|
+
return data
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Mistral provider using the official mistralai SDK.
|
|
2
|
+
|
|
3
|
+
Notes on the SDK layout in mistralai 2.4.7:
|
|
4
|
+
|
|
5
|
+
The spec's `from mistralai import Mistral` import path does not exist
|
|
6
|
+
in 2.4.7 - the top-level package is now a namespace package with
|
|
7
|
+
subpackages for each cloud host (azure, gcp, generic). The canonical
|
|
8
|
+
client lives at `mistralai.client.Mistral`.
|
|
9
|
+
|
|
10
|
+
Stream events wrap the chunk in a `CompletionEvent` whose `.data`
|
|
11
|
+
attribute is the OpenAI-style `CompletionChunk` with the usual
|
|
12
|
+
`choices[0].delta.content` shape.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import time
|
|
18
|
+
from collections.abc import AsyncIterator
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from mistralai.client import Mistral
|
|
22
|
+
from mistralai.client.errors.sdkerror import SDKError
|
|
23
|
+
|
|
24
|
+
from cli_modelarium.exceptions import (
|
|
25
|
+
AuthenticationError,
|
|
26
|
+
ProviderError,
|
|
27
|
+
RateLimitError,
|
|
28
|
+
)
|
|
29
|
+
from cli_modelarium.pricing import calculate_cost
|
|
30
|
+
from cli_modelarium.providers._utils import extract_retry_after
|
|
31
|
+
from cli_modelarium.providers.base import BaseProvider, CompletionResult, OnChunk
|
|
32
|
+
from cli_modelarium.security import redact_secrets
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class MistralProvider(BaseProvider):
|
|
36
|
+
"""Provider using the official Mistral Python SDK."""
|
|
37
|
+
|
|
38
|
+
name: str = "mistral"
|
|
39
|
+
|
|
40
|
+
def __init__(self, api_key: str) -> None:
|
|
41
|
+
self.client = Mistral(api_key=api_key)
|
|
42
|
+
|
|
43
|
+
def _build_messages(self, prompt: str, system_prompt: str | None) -> list[dict[str, str]]:
|
|
44
|
+
messages: list[dict[str, str]] = []
|
|
45
|
+
if system_prompt:
|
|
46
|
+
messages.append({"role": "system", "content": system_prompt})
|
|
47
|
+
messages.append({"role": "user", "content": prompt})
|
|
48
|
+
return messages
|
|
49
|
+
|
|
50
|
+
async def stream(
|
|
51
|
+
self,
|
|
52
|
+
prompt: str,
|
|
53
|
+
model: str,
|
|
54
|
+
temperature: float,
|
|
55
|
+
system_prompt: str | None = None,
|
|
56
|
+
) -> AsyncIterator[str]:
|
|
57
|
+
messages = self._build_messages(prompt, system_prompt)
|
|
58
|
+
try:
|
|
59
|
+
response_stream = await self.client.chat.stream_async(
|
|
60
|
+
model=model,
|
|
61
|
+
messages=messages,
|
|
62
|
+
temperature=temperature,
|
|
63
|
+
)
|
|
64
|
+
async for event in response_stream:
|
|
65
|
+
content = _extract_chunk_text(event)
|
|
66
|
+
if content:
|
|
67
|
+
yield content
|
|
68
|
+
except SDKError as e:
|
|
69
|
+
self._reraise(e)
|
|
70
|
+
|
|
71
|
+
async def complete(
|
|
72
|
+
self,
|
|
73
|
+
prompt: str,
|
|
74
|
+
model: str,
|
|
75
|
+
temperature: float,
|
|
76
|
+
system_prompt: str | None = None,
|
|
77
|
+
*,
|
|
78
|
+
on_chunk: OnChunk | None = None,
|
|
79
|
+
) -> CompletionResult:
|
|
80
|
+
messages = self._build_messages(prompt, system_prompt)
|
|
81
|
+
|
|
82
|
+
start = time.monotonic()
|
|
83
|
+
ttft_ms: float | None = None
|
|
84
|
+
chunks: list[str] = []
|
|
85
|
+
input_tokens = 0
|
|
86
|
+
output_tokens = 0
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
response_stream = await self.client.chat.stream_async(
|
|
90
|
+
model=model,
|
|
91
|
+
messages=messages,
|
|
92
|
+
temperature=temperature,
|
|
93
|
+
)
|
|
94
|
+
async for event in response_stream:
|
|
95
|
+
content = _extract_chunk_text(event)
|
|
96
|
+
if content:
|
|
97
|
+
if ttft_ms is None:
|
|
98
|
+
ttft_ms = (time.monotonic() - start) * 1000
|
|
99
|
+
if on_chunk is not None:
|
|
100
|
+
on_chunk(content)
|
|
101
|
+
chunks.append(content)
|
|
102
|
+
usage = _extract_chunk_usage(event)
|
|
103
|
+
if usage is not None:
|
|
104
|
+
input_tokens, output_tokens = usage
|
|
105
|
+
except SDKError as e:
|
|
106
|
+
self._reraise(e)
|
|
107
|
+
|
|
108
|
+
latency_ms = (time.monotonic() - start) * 1000
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
cost = calculate_cost(model, input_tokens, output_tokens)
|
|
112
|
+
except Exception:
|
|
113
|
+
cost = 0.0
|
|
114
|
+
|
|
115
|
+
return CompletionResult(
|
|
116
|
+
output="".join(chunks),
|
|
117
|
+
input_tokens=input_tokens,
|
|
118
|
+
output_tokens=output_tokens,
|
|
119
|
+
cost_usd=cost,
|
|
120
|
+
latency_ms=latency_ms,
|
|
121
|
+
ttft_ms=ttft_ms,
|
|
122
|
+
model=model,
|
|
123
|
+
provider=self.name,
|
|
124
|
+
temperature=temperature,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
def _reraise(self, error: SDKError) -> None:
|
|
128
|
+
"""Translate a Mistral SDK error into a redacted Cli Modelarium exception."""
|
|
129
|
+
message = redact_secrets(str(error))
|
|
130
|
+
status_code = _extract_status_code(error)
|
|
131
|
+
|
|
132
|
+
if status_code in (401, 403):
|
|
133
|
+
raise AuthenticationError(message, provider=self.name) from None
|
|
134
|
+
if status_code == 429:
|
|
135
|
+
retry_after = extract_retry_after(error)
|
|
136
|
+
raise RateLimitError(message, provider=self.name, retry_after=retry_after) from None
|
|
137
|
+
raise ProviderError(message, provider=self.name) from None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _extract_chunk_text(event: Any) -> str | None:
|
|
141
|
+
"""Pull `data.choices[0].delta.content` out of a streamed CompletionEvent."""
|
|
142
|
+
data = getattr(event, "data", None)
|
|
143
|
+
if data is None:
|
|
144
|
+
return None
|
|
145
|
+
choices = getattr(data, "choices", None)
|
|
146
|
+
if not choices:
|
|
147
|
+
return None
|
|
148
|
+
delta = getattr(choices[0], "delta", None)
|
|
149
|
+
if delta is None:
|
|
150
|
+
return None
|
|
151
|
+
return getattr(delta, "content", None)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _extract_chunk_usage(event: Any) -> tuple[int, int] | None:
|
|
155
|
+
"""Pull (input_tokens, output_tokens) out of a streamed CompletionEvent's usage payload."""
|
|
156
|
+
data = getattr(event, "data", None)
|
|
157
|
+
if data is None:
|
|
158
|
+
return None
|
|
159
|
+
usage = getattr(data, "usage", None)
|
|
160
|
+
if usage is None:
|
|
161
|
+
return None
|
|
162
|
+
input_tokens = getattr(usage, "prompt_tokens", 0) or 0
|
|
163
|
+
output_tokens = getattr(usage, "completion_tokens", 0) or 0
|
|
164
|
+
return input_tokens, output_tokens
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _extract_status_code(error: SDKError) -> int | None:
|
|
168
|
+
"""Pull the HTTP status code from an SDKError's wrapped httpx response."""
|
|
169
|
+
response = getattr(error, "raw_response", None)
|
|
170
|
+
if response is None:
|
|
171
|
+
return None
|
|
172
|
+
return getattr(response, "status_code", None)
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""OpenAI provider implementation.
|
|
2
|
+
|
|
3
|
+
Designed to be subclassed by the OpenAI-compatible providers (xAI, DeepSeek,
|
|
4
|
+
Groq, OpenRouter, Local) by overriding `name`, the constructor (to pass a
|
|
5
|
+
different base_url), and optionally `_transform_model()`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import time
|
|
11
|
+
from collections.abc import AsyncIterator
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import openai
|
|
15
|
+
from openai import AsyncOpenAI
|
|
16
|
+
|
|
17
|
+
from cli_modelarium.exceptions import (
|
|
18
|
+
AuthenticationError,
|
|
19
|
+
ProviderError,
|
|
20
|
+
ProviderOverloadedError,
|
|
21
|
+
RateLimitError,
|
|
22
|
+
)
|
|
23
|
+
from cli_modelarium.pricing import calculate_cost
|
|
24
|
+
from cli_modelarium.providers._utils import extract_retry_after
|
|
25
|
+
from cli_modelarium.providers.base import BaseProvider, CompletionResult, OnChunk
|
|
26
|
+
from cli_modelarium.security import redact_secrets
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class OpenAIProvider(BaseProvider):
|
|
30
|
+
"""Provider using the official OpenAI Python SDK."""
|
|
31
|
+
|
|
32
|
+
name: str = "openai"
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
api_key: str,
|
|
37
|
+
base_url: str | None = None,
|
|
38
|
+
default_headers: dict[str, str] | None = None,
|
|
39
|
+
) -> None:
|
|
40
|
+
client_kwargs: dict[str, Any] = {"api_key": api_key}
|
|
41
|
+
if base_url is not None:
|
|
42
|
+
client_kwargs["base_url"] = base_url
|
|
43
|
+
if default_headers is not None:
|
|
44
|
+
client_kwargs["default_headers"] = default_headers
|
|
45
|
+
self.client = AsyncOpenAI(**client_kwargs)
|
|
46
|
+
|
|
47
|
+
def _transform_model(self, model: str) -> str:
|
|
48
|
+
"""Subclass hook for providers that need to rewrite the model ID (e.g. LocalProvider)."""
|
|
49
|
+
return model
|
|
50
|
+
|
|
51
|
+
def _build_messages(self, prompt: str, system_prompt: str | None) -> list[dict[str, str]]:
|
|
52
|
+
messages: list[dict[str, str]] = []
|
|
53
|
+
if system_prompt:
|
|
54
|
+
messages.append({"role": "system", "content": system_prompt})
|
|
55
|
+
messages.append({"role": "user", "content": prompt})
|
|
56
|
+
return messages
|
|
57
|
+
|
|
58
|
+
async def stream(
|
|
59
|
+
self,
|
|
60
|
+
prompt: str,
|
|
61
|
+
model: str,
|
|
62
|
+
temperature: float,
|
|
63
|
+
system_prompt: str | None = None,
|
|
64
|
+
) -> AsyncIterator[str]:
|
|
65
|
+
messages = self._build_messages(prompt, system_prompt)
|
|
66
|
+
actual_model = self._transform_model(model)
|
|
67
|
+
try:
|
|
68
|
+
response = await self.client.chat.completions.create(
|
|
69
|
+
model=actual_model,
|
|
70
|
+
messages=messages,
|
|
71
|
+
temperature=temperature,
|
|
72
|
+
stream=True,
|
|
73
|
+
)
|
|
74
|
+
async for chunk in response:
|
|
75
|
+
if chunk.choices and chunk.choices[0].delta.content:
|
|
76
|
+
yield chunk.choices[0].delta.content
|
|
77
|
+
except openai.APIError as e:
|
|
78
|
+
self._reraise(e)
|
|
79
|
+
|
|
80
|
+
async def complete(
|
|
81
|
+
self,
|
|
82
|
+
prompt: str,
|
|
83
|
+
model: str,
|
|
84
|
+
temperature: float,
|
|
85
|
+
system_prompt: str | None = None,
|
|
86
|
+
*,
|
|
87
|
+
on_chunk: OnChunk | None = None,
|
|
88
|
+
) -> CompletionResult:
|
|
89
|
+
"""Run a completion via streaming with usage included.
|
|
90
|
+
|
|
91
|
+
Streaming with `stream_options={"include_usage": True}` gives us both
|
|
92
|
+
token-by-token output (for TTFT) and final usage numbers in one call.
|
|
93
|
+
|
|
94
|
+
If `on_chunk` is provided it is called with each text chunk as it
|
|
95
|
+
arrives, so the streaming orchestrator can surface output live.
|
|
96
|
+
"""
|
|
97
|
+
messages = self._build_messages(prompt, system_prompt)
|
|
98
|
+
actual_model = self._transform_model(model)
|
|
99
|
+
|
|
100
|
+
start = time.monotonic()
|
|
101
|
+
ttft_ms: float | None = None
|
|
102
|
+
chunks: list[str] = []
|
|
103
|
+
input_tokens = 0
|
|
104
|
+
output_tokens = 0
|
|
105
|
+
cached_tokens = 0
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
response = await self.client.chat.completions.create(
|
|
109
|
+
model=actual_model,
|
|
110
|
+
messages=messages,
|
|
111
|
+
temperature=temperature,
|
|
112
|
+
stream=True,
|
|
113
|
+
stream_options={"include_usage": True},
|
|
114
|
+
)
|
|
115
|
+
async for chunk in response:
|
|
116
|
+
if chunk.choices and chunk.choices[0].delta.content:
|
|
117
|
+
text = chunk.choices[0].delta.content
|
|
118
|
+
if ttft_ms is None:
|
|
119
|
+
ttft_ms = (time.monotonic() - start) * 1000
|
|
120
|
+
if on_chunk is not None:
|
|
121
|
+
on_chunk(text)
|
|
122
|
+
chunks.append(text)
|
|
123
|
+
if getattr(chunk, "usage", None) is not None:
|
|
124
|
+
input_tokens = chunk.usage.prompt_tokens or 0
|
|
125
|
+
output_tokens = chunk.usage.completion_tokens or 0
|
|
126
|
+
details = getattr(chunk.usage, "prompt_tokens_details", None)
|
|
127
|
+
if details is not None:
|
|
128
|
+
cached_tokens = getattr(details, "cached_tokens", 0) or 0
|
|
129
|
+
except openai.APIError as e:
|
|
130
|
+
self._reraise(e)
|
|
131
|
+
|
|
132
|
+
latency_ms = (time.monotonic() - start) * 1000
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
cost = calculate_cost(model, input_tokens, output_tokens, cached_tokens)
|
|
136
|
+
except Exception:
|
|
137
|
+
cost = 0.0
|
|
138
|
+
|
|
139
|
+
return CompletionResult(
|
|
140
|
+
output="".join(chunks),
|
|
141
|
+
input_tokens=input_tokens,
|
|
142
|
+
output_tokens=output_tokens,
|
|
143
|
+
cached_tokens=cached_tokens,
|
|
144
|
+
cost_usd=cost,
|
|
145
|
+
latency_ms=latency_ms,
|
|
146
|
+
ttft_ms=ttft_ms,
|
|
147
|
+
model=model,
|
|
148
|
+
provider=self.name,
|
|
149
|
+
temperature=temperature,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
def _reraise(self, error: openai.APIError) -> None:
|
|
153
|
+
"""Translate an OpenAI SDK error into a redacted Cli Modelarium exception."""
|
|
154
|
+
message = redact_secrets(str(error))
|
|
155
|
+
|
|
156
|
+
if isinstance(error, openai.AuthenticationError):
|
|
157
|
+
raise AuthenticationError(message, provider=self.name) from None
|
|
158
|
+
if isinstance(error, openai.RateLimitError):
|
|
159
|
+
retry_after = extract_retry_after(error)
|
|
160
|
+
raise RateLimitError(message, provider=self.name, retry_after=retry_after) from None
|
|
161
|
+
if isinstance(error, openai.APIStatusError) and getattr(error, "status_code", None) == 529:
|
|
162
|
+
raise ProviderOverloadedError(message, provider=self.name) from None
|
|
163
|
+
raise ProviderError(message, provider=self.name) from None
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""OpenRouter provider - OpenAI-compatible meta-provider with custom headers.
|
|
2
|
+
|
|
3
|
+
OpenRouter aggregates models from many vendors behind one OpenAI-compatible
|
|
4
|
+
API. They expect two extra headers identifying the calling application:
|
|
5
|
+
|
|
6
|
+
HTTP-Referer - URL of the calling app (for analytics/leaderboard)
|
|
7
|
+
X-Title - human-readable app name
|
|
8
|
+
|
|
9
|
+
We pass these as `default_headers` on the AsyncOpenAI client so they go on
|
|
10
|
+
every request without per-call wiring.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from cli_modelarium.providers.openai_provider import OpenAIProvider
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class OpenRouterProvider(OpenAIProvider):
|
|
19
|
+
"""OpenRouter models via the OpenAI-compatible endpoint at openrouter.ai."""
|
|
20
|
+
|
|
21
|
+
name: str = "openrouter"
|
|
22
|
+
BASE_URL: str = "https://openrouter.ai/api/v1"
|
|
23
|
+
DEFAULT_HEADERS: dict[str, str] = {
|
|
24
|
+
"HTTP-Referer": "https://github.com/lavellehatcherjr/cli-modelarium",
|
|
25
|
+
"X-Title": "Cli Modelarium",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
def __init__(self, api_key: str) -> None:
|
|
29
|
+
super().__init__(
|
|
30
|
+
api_key=api_key,
|
|
31
|
+
base_url=self.BASE_URL,
|
|
32
|
+
default_headers=self.DEFAULT_HEADERS,
|
|
33
|
+
)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""xAI (Grok) 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 XAIProvider(OpenAIProvider):
|
|
9
|
+
"""xAI Grok models via the OpenAI-compatible endpoint at api.x.ai."""
|
|
10
|
+
|
|
11
|
+
name: str = "xai"
|
|
12
|
+
BASE_URL: str = "https://api.x.ai/v1"
|
|
13
|
+
|
|
14
|
+
def __init__(self, api_key: str) -> None:
|
|
15
|
+
super().__init__(api_key=api_key, base_url=self.BASE_URL)
|