holt-cli 0.1.0__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.
- holt/__init__.py +0 -0
- holt/agent/__init__.py +0 -0
- holt/agent/entry.py +86 -0
- holt/agent/findings.py +49 -0
- holt/agent/landing.py +154 -0
- holt/agent/pipeline.py +244 -0
- holt/agent/progression.py +408 -0
- holt/agent/signals.py +220 -0
- holt/agent/stages.py +533 -0
- holt/agent/verdict.py +226 -0
- holt/agent/verify.py +140 -0
- holt/baseline.py +89 -0
- holt/baseline_matched.py +116 -0
- holt/cli.py +616 -0
- holt/discover.py +497 -0
- holt/evidence/__init__.py +3 -0
- holt/evidence/fixtures.py +154 -0
- holt/evidence/github_graphql.py +538 -0
- holt/evidence/provider.py +77 -0
- holt/evidence/redact.py +79 -0
- holt/issues.py +41 -0
- holt/model.py +516 -0
- holt/profile.py +126 -0
- holt/report.py +157 -0
- holt/tui/__init__.py +0 -0
- holt/tui/animation.py +84 -0
- holt/tui/app.py +294 -0
- holt/tui/clipboard.py +89 -0
- holt/tui/commands.py +134 -0
- holt/tui/discovery.py +305 -0
- holt/tui/env.py +49 -0
- holt/tui/events.py +245 -0
- holt/tui/mascot.py +121 -0
- holt/tui/models.py +590 -0
- holt/tui/observe.py +297 -0
- holt/tui/screens/__init__.py +35 -0
- holt/tui/screens/assessment.py +337 -0
- holt/tui/screens/confirm.py +62 -0
- holt/tui/screens/discover.py +444 -0
- holt/tui/screens/home.py +519 -0
- holt/tui/screens/inspector.py +106 -0
- holt/tui/screens/live.py +335 -0
- holt/tui/screens/models.py +393 -0
- holt/tui/screens/next_steps.py +425 -0
- holt/tui/screens/profile.py +129 -0
- holt/tui/session.py +711 -0
- holt/tui/store.py +458 -0
- holt/tui/theme.py +479 -0
- holt/tui/visual.py +33 -0
- holt/tui/widgets/__init__.py +0 -0
- holt/tui/widgets/candidates.py +78 -0
- holt/tui/widgets/claims.py +59 -0
- holt/tui/widgets/disclosure.py +121 -0
- holt/tui/widgets/evidence.py +121 -0
- holt/tui/widgets/masthead.py +122 -0
- holt/tui/widgets/recent.py +167 -0
- holt/tui/widgets/scrolling.py +38 -0
- holt/tui/widgets/stages.py +232 -0
- holt/types.py +48 -0
- holt_cli-0.1.0.dist-info/METADATA +198 -0
- holt_cli-0.1.0.dist-info/RECORD +65 -0
- holt_cli-0.1.0.dist-info/WHEEL +4 -0
- holt_cli-0.1.0.dist-info/entry_points.txt +2 -0
- holt_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
- holt_cli-0.1.0.dist-info/licenses/NOTICE +4 -0
holt/tui/models.py
ADDED
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
"""Choosing which model answers, and finding out whether it can.
|
|
2
|
+
|
|
3
|
+
`holt models` on the command line takes a provider and a model id and trusts you
|
|
4
|
+
to know both. An interface can do better: ask the provider what it actually has,
|
|
5
|
+
and try a real call before you find out mid-run that the endpoint is down.
|
|
6
|
+
|
|
7
|
+
Everything here is Textual-free and synchronous, so it can be tested without a
|
|
8
|
+
terminal and run on a worker thread by the screen that uses it. Nothing in this
|
|
9
|
+
file decides *what a model costs* or *what the defaults are* — `holt.model` owns
|
|
10
|
+
both, and this asks it.
|
|
11
|
+
|
|
12
|
+
Two facts this module exists to keep in front of the user:
|
|
13
|
+
|
|
14
|
+
* **A non-default configuration breaks replay of the committed recordings.**
|
|
15
|
+
Those were recorded under the pinned ids, and the engine fails loudly rather
|
|
16
|
+
than serving another model's answers. That is correct behaviour and it is not
|
|
17
|
+
a surprise anybody should get later.
|
|
18
|
+
* **An unpriced model records cost as zero.** Not "free" — unknown. The
|
|
19
|
+
interface says so wherever a price would otherwise appear.
|
|
20
|
+
|
|
21
|
+
Only models that can hold a conversation are offered. Every stage of the engine
|
|
22
|
+
calls `chat.completions`, so an embedding or reranking model listed here is not
|
|
23
|
+
a choice, it is a trap — Ollama in particular serves `nomic-embed-text` from the
|
|
24
|
+
same `/v1/models` as everything else, and picking it fails at the first stage
|
|
25
|
+
with a message about the model not supporting generate. What is filtered out is
|
|
26
|
+
counted and reported rather than silently dropped.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import os
|
|
32
|
+
import time
|
|
33
|
+
from dataclasses import dataclass, field
|
|
34
|
+
|
|
35
|
+
from holt import model as model_module
|
|
36
|
+
|
|
37
|
+
#: How long any network call here may take. Short: this is an interactive
|
|
38
|
+
#: screen, and a provider that cannot answer in this long is a provider you
|
|
39
|
+
#: want to be told about rather than wait for.
|
|
40
|
+
TIMEOUT_S = 12.0
|
|
41
|
+
|
|
42
|
+
#: Hard stop on every outbound call in this module.
|
|
43
|
+
#:
|
|
44
|
+
#: Listing a provider is cheap, but testing a connection is a real inference
|
|
45
|
+
#: request, and against a local endpoint that means loading a model into RAM on
|
|
46
|
+
#: the user's machine. That must only ever happen because someone pressed the
|
|
47
|
+
#: key for it — never from a test, a script, or a screen doing it eagerly. The
|
|
48
|
+
#: tests set this, so the suite physically cannot reach a provider.
|
|
49
|
+
NO_NETWORK_ENV = "HOLT_NO_NETWORK"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def network_allowed() -> bool:
|
|
53
|
+
return os.environ.get(NO_NETWORK_ENV, "") in ("", "0", "false", "False")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
_BLOCKED = "Network calls are disabled here (HOLT_NO_NETWORK)."
|
|
57
|
+
|
|
58
|
+
#: Shown when a provider cannot be listed. Curated rather than invented: these
|
|
59
|
+
#: are ids the SDKs document, used only as a starting point the user can edit,
|
|
60
|
+
#: and always labelled as not having come from the provider.
|
|
61
|
+
FALLBACK_MODELS: dict[str, tuple[str, ...]] = {
|
|
62
|
+
"anthropic": ("claude-opus-5", "claude-sonnet-5", "claude-haiku-4-5-20251001"),
|
|
63
|
+
"openai": (model_module.SMALL, model_module.LARGE),
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ─── which models are actually models you can talk to ───────────────────────
|
|
68
|
+
|
|
69
|
+
#: Ollama's own word, in `/api/show`, for a model that can be generated from.
|
|
70
|
+
#: An embedding model reports `["embedding"]` and nothing else.
|
|
71
|
+
OLLAMA_CHAT_CAPABILITY = "completion"
|
|
72
|
+
|
|
73
|
+
#: Substrings that mark an id as something other than a chat model, used only
|
|
74
|
+
#: where the provider will not say. Deliberately short: these are the families
|
|
75
|
+
#: that actually turn up in a `/v1/models` listing next to chat models, and a
|
|
76
|
+
#: longer list of guesses would start hiding models that do work. The cost of a
|
|
77
|
+
#: miss here is one clear error from the provider; the cost of over-matching is
|
|
78
|
+
#: a model you own and cannot select.
|
|
79
|
+
NON_CHAT_MARKERS: tuple[str, ...] = (
|
|
80
|
+
"embed", # nomic-embed-text, mxbai-embed-large, text-embedding-3-small
|
|
81
|
+
"rerank", # bge-reranker, and the Ollama ports of it
|
|
82
|
+
"whisper", # speech to text
|
|
83
|
+
"transcribe", # gpt-4o-transcribe, gpt-4o-mini-transcribe — also speech
|
|
84
|
+
"diarize", # gpt-4o-transcribe-diarize
|
|
85
|
+
"dall-e", # images
|
|
86
|
+
"gpt-image", # images, under a name that otherwise reads as a chat model
|
|
87
|
+
"sora", # video
|
|
88
|
+
"realtime", # a websocket session, not a chat completion
|
|
89
|
+
"tts-", # openai's tts-1, tts-1-hd
|
|
90
|
+
"-tts",
|
|
91
|
+
"moderation", # omni-moderation-latest, text-moderation-*
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def looks_like_chat(model_id: str) -> bool:
|
|
96
|
+
"""A name-based guess, for providers that do not publish capabilities."""
|
|
97
|
+
lowered = model_id.lower()
|
|
98
|
+
return not any(marker in lowered for marker in NON_CHAT_MARKERS)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ─── the order they are offered in ──────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
#: Sunk to the bottom rather than hidden. These are real chat models and
|
|
104
|
+
#: selecting one is allowed; they are simply not what anybody scrolling this
|
|
105
|
+
#: list is looking for, and alphabetical order put `babbage-002` above `gpt-5`.
|
|
106
|
+
#: Hiding them would be a different and worse decision — the provider offers
|
|
107
|
+
#: them, so the list says so.
|
|
108
|
+
DEPRIORITISED: tuple[str, ...] = (
|
|
109
|
+
"babbage",
|
|
110
|
+
"davinci",
|
|
111
|
+
"-instruct",
|
|
112
|
+
"-preview",
|
|
113
|
+
"-audio",
|
|
114
|
+
"chatgpt-",
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def rank(model: Model) -> tuple:
|
|
119
|
+
"""Sort key. Lower sorts first.
|
|
120
|
+
|
|
121
|
+
"Popular" is not a fact this tool has any way of knowing, so it is not
|
|
122
|
+
guessed at. What it *does* know is which models it can state a cost for and
|
|
123
|
+
which one it is pinned to, and those are exactly the ones worth reaching
|
|
124
|
+
first — a model holt cannot price is one whose runs record as $0, which is
|
|
125
|
+
a worse starting point than any popularity ordering would fix.
|
|
126
|
+
|
|
127
|
+
Four tiers, then alphabetical inside each so the order is stable and a
|
|
128
|
+
second look finds a model where the first one left it.
|
|
129
|
+
"""
|
|
130
|
+
pinned = model.id in (model_module.SMALL, model_module.LARGE)
|
|
131
|
+
lowered = model.id.lower()
|
|
132
|
+
legacy = any(marker in lowered for marker in DEPRIORITISED)
|
|
133
|
+
if pinned:
|
|
134
|
+
tier = 0
|
|
135
|
+
elif legacy:
|
|
136
|
+
tier = 3
|
|
137
|
+
elif model.priced:
|
|
138
|
+
tier = 1
|
|
139
|
+
else:
|
|
140
|
+
tier = 2
|
|
141
|
+
return (tier, model.id)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def in_offer_order(models: list[Model]) -> list[Model]:
|
|
145
|
+
return sorted(models, key=rank)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def matches(model: Model, needle: str) -> bool:
|
|
149
|
+
"""Substring, case-insensitive. A provider with 80 ids needs a filter.
|
|
150
|
+
|
|
151
|
+
Deliberately not fuzzy: you are looking for a model whose name you already
|
|
152
|
+
partly know, and a fuzzy match that surfaces `gpt-4o` for "o1" would make
|
|
153
|
+
the list less trustworthy rather than more helpful.
|
|
154
|
+
"""
|
|
155
|
+
return needle.strip().lower() in model.id.lower()
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@dataclass(slots=True)
|
|
159
|
+
class Provider:
|
|
160
|
+
name: str
|
|
161
|
+
key_env: str
|
|
162
|
+
base_url: str
|
|
163
|
+
|
|
164
|
+
@property
|
|
165
|
+
def key_set(self) -> bool:
|
|
166
|
+
return bool(os.environ.get(self.key_env))
|
|
167
|
+
|
|
168
|
+
@property
|
|
169
|
+
def needs_base_url(self) -> bool:
|
|
170
|
+
"""`openai-compatible` is only meaningful once you say where."""
|
|
171
|
+
return self.name == "openai-compatible" and not self.base_url
|
|
172
|
+
|
|
173
|
+
def status(self) -> str:
|
|
174
|
+
"""One line about whether this provider could be used right now."""
|
|
175
|
+
if self.needs_base_url:
|
|
176
|
+
return "set a base url first"
|
|
177
|
+
if self.name == "ollama":
|
|
178
|
+
# Local, and usually keyless. Whether it is running is a question
|
|
179
|
+
# only a connection test can answer, so do not imply either way.
|
|
180
|
+
return self.base_url
|
|
181
|
+
if self.key_set:
|
|
182
|
+
return f"{self.key_env} is set"
|
|
183
|
+
return f"{self.key_env} is not set"
|
|
184
|
+
|
|
185
|
+
@property
|
|
186
|
+
def usable(self) -> bool:
|
|
187
|
+
if self.needs_base_url:
|
|
188
|
+
return False
|
|
189
|
+
return self.key_set or self.name == "ollama"
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def providers(config: model_module.ModelsConfig | None = None) -> list[Provider]:
|
|
193
|
+
"""Every provider the engine knows, with the current config filled in."""
|
|
194
|
+
config = config or model_module.load_models_config()
|
|
195
|
+
out = []
|
|
196
|
+
for name, preset in model_module.PROVIDER_PRESETS.items():
|
|
197
|
+
# The configured values win, but only for the provider they belong to.
|
|
198
|
+
key_env = preset.get("api_key_env", "OPENAI_API_KEY")
|
|
199
|
+
base_url = preset.get("base_url", "")
|
|
200
|
+
if config.provider == name:
|
|
201
|
+
key_env = config.resolved_key_env()
|
|
202
|
+
base_url = config.resolved_base_url()
|
|
203
|
+
out.append(Provider(name=name, key_env=key_env, base_url=base_url))
|
|
204
|
+
return out
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
@dataclass(slots=True)
|
|
208
|
+
class Model:
|
|
209
|
+
id: str
|
|
210
|
+
priced: bool = False
|
|
211
|
+
#: True when the provider told us about it, False when it came from
|
|
212
|
+
#: `FALLBACK_MODELS`. The screen labels the difference.
|
|
213
|
+
from_provider: bool = True
|
|
214
|
+
#: USD per million tokens, (input, output). `None` when this build has no
|
|
215
|
+
#: rate for the id — which is not the same as the model being free.
|
|
216
|
+
rates: tuple[float, float] | None = None
|
|
217
|
+
#: False when the rate came from the snapshot a floating alias points at.
|
|
218
|
+
exact: bool = True
|
|
219
|
+
|
|
220
|
+
@property
|
|
221
|
+
def pricing(self) -> str:
|
|
222
|
+
"""The actual rate, because "priced" answers nobody's question.
|
|
223
|
+
|
|
224
|
+
A reader choosing a model is deciding what a run will cost. "priced"
|
|
225
|
+
told them a price exists somewhere and made them go and find it.
|
|
226
|
+
"""
|
|
227
|
+
if self.rates is None:
|
|
228
|
+
return "unpriced — cost recorded as 0"
|
|
229
|
+
rate_in, rate_out = self.rates
|
|
230
|
+
figure = f"${rate_in:.2f} in / ${rate_out:.2f} out per M tokens"
|
|
231
|
+
# `≈` because an alias can be repointed under us. Better a rate marked
|
|
232
|
+
# approximate than a confident number that quietly goes stale.
|
|
233
|
+
return figure if self.exact else f"≈ {figure}"
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
@dataclass(slots=True)
|
|
237
|
+
class Listing:
|
|
238
|
+
models: list[Model] = field(default_factory=list)
|
|
239
|
+
error: str = ""
|
|
240
|
+
#: Set when the list is `FALLBACK_MODELS` rather than the provider's answer.
|
|
241
|
+
guessed: bool = False
|
|
242
|
+
#: How many the provider offered that cannot hold a conversation. Reported,
|
|
243
|
+
#: because a list shorter than `ollama list` needs to say why.
|
|
244
|
+
hidden: int = 0
|
|
245
|
+
|
|
246
|
+
@property
|
|
247
|
+
def ok(self) -> bool:
|
|
248
|
+
return not self.error
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def list_models(provider: Provider) -> Listing:
|
|
252
|
+
"""Ask the provider what it has. Never raises; the error is the result.
|
|
253
|
+
|
|
254
|
+
Runs on a worker thread. A provider that is unreachable, unauthorised or
|
|
255
|
+
simply not running produces a sentence, not a traceback.
|
|
256
|
+
"""
|
|
257
|
+
if not network_allowed():
|
|
258
|
+
return _fallback(provider, _BLOCKED)
|
|
259
|
+
if provider.needs_base_url:
|
|
260
|
+
return Listing(error="Set a base url for this provider first.")
|
|
261
|
+
|
|
262
|
+
key = os.environ.get(provider.key_env, "")
|
|
263
|
+
if not key and provider.name != "ollama":
|
|
264
|
+
return _fallback(provider, f"{provider.key_env} is not set.")
|
|
265
|
+
|
|
266
|
+
try:
|
|
267
|
+
if provider.name == "anthropic":
|
|
268
|
+
ids = _list_anthropic(key)
|
|
269
|
+
else:
|
|
270
|
+
ids = _list_openai_wire(provider, key)
|
|
271
|
+
chat = _chat_only(provider, ids)
|
|
272
|
+
except Exception as exc: # noqa: BLE001 - reported to the user as prose
|
|
273
|
+
return _fallback(provider, explain(exc, provider))
|
|
274
|
+
|
|
275
|
+
if not ids:
|
|
276
|
+
return _fallback(provider, "The provider returned no models.")
|
|
277
|
+
|
|
278
|
+
hidden = len(ids) - len(chat)
|
|
279
|
+
if not chat:
|
|
280
|
+
# Everything it has is an embedding model or similar. Said plainly:
|
|
281
|
+
# an empty list under a working connection otherwise reads as a bug.
|
|
282
|
+
return Listing(
|
|
283
|
+
error=(
|
|
284
|
+
f"{provider.name} has {len(ids)} model"
|
|
285
|
+
f"{'' if len(ids) == 1 else 's'} and none of them can hold a "
|
|
286
|
+
"conversation. Holt calls chat completions at every stage; "
|
|
287
|
+
"`ollama pull qwen3` gets something it can use."
|
|
288
|
+
if provider.name == "ollama"
|
|
289
|
+
else f"{provider.name} offered {len(ids)} model"
|
|
290
|
+
f"{'' if len(ids) == 1 else 's'} and none of them can hold a "
|
|
291
|
+
"conversation. Holt calls chat completions at every stage."
|
|
292
|
+
),
|
|
293
|
+
hidden=len(ids),
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
return Listing(models=in_offer_order([_model(i) for i in chat]), hidden=hidden)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _chat_only(provider: Provider, ids: list[str]) -> list[str]:
|
|
300
|
+
"""Just the ids that can be chatted with.
|
|
301
|
+
|
|
302
|
+
Asked of the provider where the provider will answer, and guessed from the
|
|
303
|
+
name only where it will not. Ollama publishes capabilities per model and is
|
|
304
|
+
also the provider that most needs the filter, since a local install
|
|
305
|
+
routinely holds an embedding model pulled for something else entirely.
|
|
306
|
+
"""
|
|
307
|
+
if provider.name == "ollama":
|
|
308
|
+
capabilities = _ollama_capabilities(provider, ids)
|
|
309
|
+
return [
|
|
310
|
+
model_id
|
|
311
|
+
for model_id in ids
|
|
312
|
+
# `None` means Ollama would not say for this one — fall back to the
|
|
313
|
+
# name rather than dropping a model on no evidence.
|
|
314
|
+
if (
|
|
315
|
+
looks_like_chat(model_id)
|
|
316
|
+
if capabilities.get(model_id) is None
|
|
317
|
+
else OLLAMA_CHAT_CAPABILITY in capabilities[model_id]
|
|
318
|
+
)
|
|
319
|
+
]
|
|
320
|
+
return [model_id for model_id in ids if looks_like_chat(model_id)]
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _ollama_native(base_url: str) -> str:
|
|
324
|
+
"""`http://host:11434/v1` → `http://host:11434`.
|
|
325
|
+
|
|
326
|
+
Capabilities are not part of the OpenAI-compatible surface, so this one
|
|
327
|
+
question is asked of Ollama's own API at the same host.
|
|
328
|
+
"""
|
|
329
|
+
root = (base_url or "http://localhost:11434/v1").rstrip("/")
|
|
330
|
+
return root[: -len("/v1")] if root.endswith("/v1") else root
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _ollama_capabilities(
|
|
334
|
+
provider: Provider, ids: list[str]
|
|
335
|
+
) -> dict[str, tuple[str, ...] | None]:
|
|
336
|
+
"""What each model can do, according to Ollama. Never raises.
|
|
337
|
+
|
|
338
|
+
`/api/show` reads the manifest on disk — it does not load the model into
|
|
339
|
+
memory, which is the only reason this is acceptable to do for every model
|
|
340
|
+
the moment somebody opens the list. The one call in this file that *does*
|
|
341
|
+
load a model is `test_connection`, and that happens because a key was
|
|
342
|
+
pressed for it.
|
|
343
|
+
|
|
344
|
+
A model Ollama will not answer for maps to `None`, which is a different
|
|
345
|
+
thing from "has no capabilities" and is treated differently by the caller.
|
|
346
|
+
"""
|
|
347
|
+
import httpx
|
|
348
|
+
|
|
349
|
+
root = _ollama_native(provider.base_url)
|
|
350
|
+
found: dict[str, tuple[str, ...] | None] = {}
|
|
351
|
+
try:
|
|
352
|
+
with httpx.Client(timeout=TIMEOUT_S) as client:
|
|
353
|
+
for model_id in ids:
|
|
354
|
+
try:
|
|
355
|
+
response = client.post(
|
|
356
|
+
f"{root}/api/show", json={"model": model_id}
|
|
357
|
+
)
|
|
358
|
+
response.raise_for_status()
|
|
359
|
+
raw = response.json().get("capabilities") or []
|
|
360
|
+
# An Ollama old enough not to report capabilities returns
|
|
361
|
+
# nothing here. That is "would not say", not "can do
|
|
362
|
+
# nothing", and hiding every model over it would be wrong.
|
|
363
|
+
found[model_id] = tuple(str(c) for c in raw) or None
|
|
364
|
+
except Exception: # noqa: BLE001 - one model, not the listing
|
|
365
|
+
found[model_id] = None
|
|
366
|
+
except Exception: # noqa: BLE001 - no native API here; names it is
|
|
367
|
+
return {}
|
|
368
|
+
return found
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _model(model_id: str, from_provider: bool = True) -> Model:
|
|
372
|
+
"""One row, with whatever `holt.model` knows about what it costs."""
|
|
373
|
+
rates, exact = model_module.resolve_price(model_id)
|
|
374
|
+
return Model(
|
|
375
|
+
id=model_id,
|
|
376
|
+
priced=rates is not None,
|
|
377
|
+
from_provider=from_provider,
|
|
378
|
+
rates=rates,
|
|
379
|
+
exact=exact,
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _fallback(provider: Provider, error: str) -> Listing:
|
|
384
|
+
"""What we know without asking, clearly labelled as exactly that."""
|
|
385
|
+
known = FALLBACK_MODELS.get(provider.name, ())
|
|
386
|
+
return Listing(
|
|
387
|
+
models=[_model(i, from_provider=False) for i in known],
|
|
388
|
+
error=error,
|
|
389
|
+
guessed=bool(known),
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _list_openai_wire(provider: Provider, key: str) -> list[str]:
|
|
394
|
+
from openai import OpenAI
|
|
395
|
+
|
|
396
|
+
client = OpenAI(
|
|
397
|
+
api_key=key or "not-needed",
|
|
398
|
+
base_url=provider.base_url or None,
|
|
399
|
+
timeout=TIMEOUT_S,
|
|
400
|
+
max_retries=0,
|
|
401
|
+
)
|
|
402
|
+
return sorted({m.id for m in client.models.list()})
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _list_anthropic(key: str) -> list[str]:
|
|
406
|
+
from anthropic import Anthropic
|
|
407
|
+
|
|
408
|
+
client = Anthropic(api_key=key, timeout=TIMEOUT_S, max_retries=0)
|
|
409
|
+
return sorted({m.id for m in client.models.list(limit=100)})
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
@dataclass(slots=True)
|
|
413
|
+
class Probe:
|
|
414
|
+
ok: bool
|
|
415
|
+
detail: str
|
|
416
|
+
seconds: float = 0.0
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def test_connection(provider: Provider, model_id: str) -> Probe:
|
|
420
|
+
"""Make the smallest real call this provider allows, and time it.
|
|
421
|
+
|
|
422
|
+
Listing models proves the endpoint is reachable and the key is accepted.
|
|
423
|
+
This proves the *chosen model* answers, which is a different question and
|
|
424
|
+
the one that bites during a run.
|
|
425
|
+
"""
|
|
426
|
+
if not network_allowed():
|
|
427
|
+
return Probe(False, _BLOCKED)
|
|
428
|
+
key = os.environ.get(provider.key_env, "")
|
|
429
|
+
if provider.needs_base_url:
|
|
430
|
+
return Probe(False, "Set a base url for this provider first.")
|
|
431
|
+
if not key and provider.name != "ollama":
|
|
432
|
+
return Probe(False, f"{provider.key_env} is not set.")
|
|
433
|
+
if not model_id:
|
|
434
|
+
return Probe(False, "Choose a model first.")
|
|
435
|
+
|
|
436
|
+
started = time.monotonic()
|
|
437
|
+
try:
|
|
438
|
+
if provider.name == "anthropic":
|
|
439
|
+
from anthropic import Anthropic
|
|
440
|
+
|
|
441
|
+
Anthropic(api_key=key, timeout=TIMEOUT_S, max_retries=0).messages.create(
|
|
442
|
+
model=model_id,
|
|
443
|
+
max_tokens=1,
|
|
444
|
+
messages=[{"role": "user", "content": "ping"}],
|
|
445
|
+
)
|
|
446
|
+
else:
|
|
447
|
+
_ping_openai(provider, key, model_id)
|
|
448
|
+
except Exception as exc: # noqa: BLE001 - the point is to report it
|
|
449
|
+
return Probe(False, explain(exc, provider), time.monotonic() - started)
|
|
450
|
+
|
|
451
|
+
elapsed = time.monotonic() - started
|
|
452
|
+
return Probe(True, f"{model_id} answered in {elapsed:.1f}s", elapsed)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _ping_openai(provider: Provider, key: str, model_id: str) -> None:
|
|
456
|
+
"""One chat completion, under whichever token-cap parameter is accepted.
|
|
457
|
+
|
|
458
|
+
The GPT-5 family rejects `max_tokens` outright and wants
|
|
459
|
+
`max_completion_tokens`; OpenAI-compatible servers that predate the rename
|
|
460
|
+
know only `max_tokens`. Neither name reaches every provider this screen can
|
|
461
|
+
be pointed at, so the current one goes first and the older one only after
|
|
462
|
+
the server names that parameter as the thing it refused. Any other failure
|
|
463
|
+
is the answer to the question this probe asked, and is raised as itself.
|
|
464
|
+
"""
|
|
465
|
+
from openai import OpenAI
|
|
466
|
+
|
|
467
|
+
client = OpenAI(
|
|
468
|
+
api_key=key or "not-needed",
|
|
469
|
+
base_url=provider.base_url or None,
|
|
470
|
+
timeout=TIMEOUT_S,
|
|
471
|
+
max_retries=0,
|
|
472
|
+
)
|
|
473
|
+
# A cap of 16 rather than 1: reasoning models spend the cap on thinking
|
|
474
|
+
# before emitting a visible token, and some refuse a cap smaller than that.
|
|
475
|
+
# Sixteen output tokens is a fraction of a cent on the priciest model here.
|
|
476
|
+
names = ("max_completion_tokens", "max_tokens")
|
|
477
|
+
for index, name in enumerate(names):
|
|
478
|
+
try:
|
|
479
|
+
client.chat.completions.create(
|
|
480
|
+
model=model_id,
|
|
481
|
+
messages=[{"role": "user", "content": "ping"}],
|
|
482
|
+
**{name: 16},
|
|
483
|
+
)
|
|
484
|
+
return
|
|
485
|
+
except Exception as exc: # noqa: BLE001 - retried below, or re-raised
|
|
486
|
+
if index == len(names) - 1 or not _rejected_parameter(exc, name):
|
|
487
|
+
raise
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _rejected_parameter(exc: BaseException, name: str) -> bool:
|
|
491
|
+
"""Did the server refuse the request *because of* this parameter?
|
|
492
|
+
|
|
493
|
+
Only then is the other spelling worth a second call. A 401, a 404 or a rate
|
|
494
|
+
limit says nothing about parameter names, and retrying one of those would
|
|
495
|
+
spend a second call to arrive at the same failure under a different name.
|
|
496
|
+
"""
|
|
497
|
+
lowered = str(exc).lower()
|
|
498
|
+
if name not in lowered:
|
|
499
|
+
return False
|
|
500
|
+
return any(
|
|
501
|
+
phrase in lowered
|
|
502
|
+
for phrase in (
|
|
503
|
+
"unsupported",
|
|
504
|
+
"not supported",
|
|
505
|
+
"unrecognized",
|
|
506
|
+
"unknown",
|
|
507
|
+
"extra inputs",
|
|
508
|
+
"unexpected keyword",
|
|
509
|
+
)
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def explain(exc: BaseException, provider: Provider) -> str:
|
|
514
|
+
"""An error a person can act on.
|
|
515
|
+
|
|
516
|
+
The failures that actually happen when pointing a tool at a model endpoint
|
|
517
|
+
get a sentence saying what to do. Anything else is reported as itself: a
|
|
518
|
+
confident wrong guess is worse than the raw text.
|
|
519
|
+
"""
|
|
520
|
+
text = str(exc)
|
|
521
|
+
lowered = text.lower()
|
|
522
|
+
name = type(exc).__name__
|
|
523
|
+
|
|
524
|
+
if "connection" in lowered or "connect" in lowered or "refused" in lowered:
|
|
525
|
+
if provider.name == "ollama":
|
|
526
|
+
return (
|
|
527
|
+
f"Nothing is listening on {provider.base_url}. "
|
|
528
|
+
"Start Ollama with `ollama serve`."
|
|
529
|
+
)
|
|
530
|
+
return f"Could not reach {provider.base_url or provider.name}."
|
|
531
|
+
if "timeout" in lowered or "timed out" in lowered:
|
|
532
|
+
return f"{provider.name} did not answer within {TIMEOUT_S:.0f}s."
|
|
533
|
+
if "401" in text or "unauthorized" in lowered or "authentication" in lowered:
|
|
534
|
+
return f"{provider.key_env} was rejected by {provider.name}."
|
|
535
|
+
if "403" in text or "permission" in lowered:
|
|
536
|
+
return f"{provider.key_env} is not permitted to do this."
|
|
537
|
+
if "404" in text or "not_found" in lowered or "does not exist" in lowered:
|
|
538
|
+
return "That model does not exist for this provider or key."
|
|
539
|
+
if "429" in text or "rate" in lowered and "limit" in lowered:
|
|
540
|
+
return f"{provider.name} is rate limiting. Wait a moment."
|
|
541
|
+
return f"{name}: {text[:160]}"
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
# ─── applying a choice ──────────────────────────────────────────────────────
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def apply(provider: Provider, model_id: str) -> model_module.ModelsConfig:
|
|
548
|
+
"""Save the choice and opt this process into it.
|
|
549
|
+
|
|
550
|
+
Writes the same `models.toml` the command line writes, so choosing here and
|
|
551
|
+
choosing with `holt models` are the same act.
|
|
552
|
+
"""
|
|
553
|
+
config = model_module.load_models_config()
|
|
554
|
+
config.provider = provider.name
|
|
555
|
+
config.model = model_id
|
|
556
|
+
config.base_url = provider.base_url if provider.base_url else ""
|
|
557
|
+
config.api_key_env = provider.key_env
|
|
558
|
+
model_module.save_models_config(config)
|
|
559
|
+
model_module.enable_user_models_config(config)
|
|
560
|
+
return config
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def reset() -> model_module.ModelsConfig:
|
|
564
|
+
"""Back to the pinned defaults the benchmark was measured on."""
|
|
565
|
+
path = model_module.models_config_path()
|
|
566
|
+
try:
|
|
567
|
+
if path.exists():
|
|
568
|
+
path.unlink()
|
|
569
|
+
except OSError:
|
|
570
|
+
pass
|
|
571
|
+
default = model_module.ModelsConfig()
|
|
572
|
+
model_module.enable_user_models_config(default)
|
|
573
|
+
return default
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def replay_warning(config: model_module.ModelsConfig) -> str:
|
|
577
|
+
"""What a non-default configuration costs you. Empty when on the defaults.
|
|
578
|
+
|
|
579
|
+
Not phrased as an error, because it is not one — it is the reproducibility
|
|
580
|
+
guarantee doing its job. But it is never hidden either: finding out that
|
|
581
|
+
`--replay` no longer works, during a demo, would be the worse outcome.
|
|
582
|
+
"""
|
|
583
|
+
if config.is_default():
|
|
584
|
+
return ""
|
|
585
|
+
return (
|
|
586
|
+
"Not the defaults. The committed recordings were made with the pinned "
|
|
587
|
+
"models, so replaying them under this configuration fails loudly rather "
|
|
588
|
+
"than serving another model's answers. Recordings you make now replay "
|
|
589
|
+
"under this configuration."
|
|
590
|
+
)
|