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.
Files changed (65) hide show
  1. holt/__init__.py +0 -0
  2. holt/agent/__init__.py +0 -0
  3. holt/agent/entry.py +86 -0
  4. holt/agent/findings.py +49 -0
  5. holt/agent/landing.py +154 -0
  6. holt/agent/pipeline.py +244 -0
  7. holt/agent/progression.py +408 -0
  8. holt/agent/signals.py +220 -0
  9. holt/agent/stages.py +533 -0
  10. holt/agent/verdict.py +226 -0
  11. holt/agent/verify.py +140 -0
  12. holt/baseline.py +89 -0
  13. holt/baseline_matched.py +116 -0
  14. holt/cli.py +616 -0
  15. holt/discover.py +497 -0
  16. holt/evidence/__init__.py +3 -0
  17. holt/evidence/fixtures.py +154 -0
  18. holt/evidence/github_graphql.py +538 -0
  19. holt/evidence/provider.py +77 -0
  20. holt/evidence/redact.py +79 -0
  21. holt/issues.py +41 -0
  22. holt/model.py +516 -0
  23. holt/profile.py +126 -0
  24. holt/report.py +157 -0
  25. holt/tui/__init__.py +0 -0
  26. holt/tui/animation.py +84 -0
  27. holt/tui/app.py +294 -0
  28. holt/tui/clipboard.py +89 -0
  29. holt/tui/commands.py +134 -0
  30. holt/tui/discovery.py +305 -0
  31. holt/tui/env.py +49 -0
  32. holt/tui/events.py +245 -0
  33. holt/tui/mascot.py +121 -0
  34. holt/tui/models.py +590 -0
  35. holt/tui/observe.py +297 -0
  36. holt/tui/screens/__init__.py +35 -0
  37. holt/tui/screens/assessment.py +337 -0
  38. holt/tui/screens/confirm.py +62 -0
  39. holt/tui/screens/discover.py +444 -0
  40. holt/tui/screens/home.py +519 -0
  41. holt/tui/screens/inspector.py +106 -0
  42. holt/tui/screens/live.py +335 -0
  43. holt/tui/screens/models.py +393 -0
  44. holt/tui/screens/next_steps.py +425 -0
  45. holt/tui/screens/profile.py +129 -0
  46. holt/tui/session.py +711 -0
  47. holt/tui/store.py +458 -0
  48. holt/tui/theme.py +479 -0
  49. holt/tui/visual.py +33 -0
  50. holt/tui/widgets/__init__.py +0 -0
  51. holt/tui/widgets/candidates.py +78 -0
  52. holt/tui/widgets/claims.py +59 -0
  53. holt/tui/widgets/disclosure.py +121 -0
  54. holt/tui/widgets/evidence.py +121 -0
  55. holt/tui/widgets/masthead.py +122 -0
  56. holt/tui/widgets/recent.py +167 -0
  57. holt/tui/widgets/scrolling.py +38 -0
  58. holt/tui/widgets/stages.py +232 -0
  59. holt/types.py +48 -0
  60. holt_cli-0.1.0.dist-info/METADATA +198 -0
  61. holt_cli-0.1.0.dist-info/RECORD +65 -0
  62. holt_cli-0.1.0.dist-info/WHEEL +4 -0
  63. holt_cli-0.1.0.dist-info/entry_points.txt +2 -0
  64. holt_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
  65. 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
+ )