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,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)