loom-code 0.1.1__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 (58) hide show
  1. loom_code/__init__.py +22 -0
  2. loom_code/_post_commit.py +119 -0
  3. loom_code/agent.py +544 -0
  4. loom_code/approval.py +616 -0
  5. loom_code/browse/__init__.py +291 -0
  6. loom_code/browse/act.py +467 -0
  7. loom_code/browse/observe.py +249 -0
  8. loom_code/browse/session.py +96 -0
  9. loom_code/browse/verify.py +194 -0
  10. loom_code/checkpoint.py +283 -0
  11. loom_code/cli.py +495 -0
  12. loom_code/code_index.py +703 -0
  13. loom_code/compact.py +143 -0
  14. loom_code/consent.py +47 -0
  15. loom_code/credentials.py +527 -0
  16. loom_code/edit_tool.py +635 -0
  17. loom_code/extensions.py +522 -0
  18. loom_code/file_history.py +322 -0
  19. loom_code/file_tools.py +93 -0
  20. loom_code/git_hook.py +200 -0
  21. loom_code/grep_tool.py +430 -0
  22. loom_code/hooks.py +297 -0
  23. loom_code/loominit/__init__.py +23 -0
  24. loom_code/loominit/_ast_walk.py +429 -0
  25. loom_code/loominit/_files.py +284 -0
  26. loom_code/loominit/_graph.py +141 -0
  27. loom_code/loominit/_resolve.py +392 -0
  28. loom_code/loominit/_tests_map.py +108 -0
  29. loom_code/loominit/extractor.py +332 -0
  30. loom_code/loominit/repomap.py +225 -0
  31. loom_code/loominit/schema.py +242 -0
  32. loom_code/lsp_tools.py +396 -0
  33. loom_code/mcp_host.py +79 -0
  34. loom_code/operator.py +449 -0
  35. loom_code/paste.py +97 -0
  36. loom_code/paths.py +52 -0
  37. loom_code/permissions.py +177 -0
  38. loom_code/project.py +104 -0
  39. loom_code/prompts.py +451 -0
  40. loom_code/render.py +783 -0
  41. loom_code/repl.py +4080 -0
  42. loom_code/rules.py +267 -0
  43. loom_code/sandboxed_bash.py +176 -0
  44. loom_code/scribe.py +88 -0
  45. loom_code/skills/__init__.py +16 -0
  46. loom_code/skills/graphify/SKILL.md +97 -0
  47. loom_code/skills/graphify/tools.py +570 -0
  48. loom_code/trust.py +216 -0
  49. loom_code/turn.py +169 -0
  50. loom_code/web_fetch.py +370 -0
  51. loom_code/workers.py +758 -0
  52. loom_code/worktree.py +134 -0
  53. loom_code-0.1.1.dist-info/METADATA +224 -0
  54. loom_code-0.1.1.dist-info/RECORD +58 -0
  55. loom_code-0.1.1.dist-info/WHEEL +5 -0
  56. loom_code-0.1.1.dist-info/entry_points.txt +2 -0
  57. loom_code-0.1.1.dist-info/licenses/LICENSE +21 -0
  58. loom_code-0.1.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,527 @@
1
+ """User-level API-key storage for loom-code.
2
+
3
+ Persists keys at ``~/.loom-code/credentials`` (chmod 600) so users
4
+ don't have to ``export OPENAI_API_KEY=...`` in every new shell.
5
+ Same pattern ``gh``, ``aws cli``, ``aider`` use.
6
+
7
+ Flow:
8
+
9
+ 1. On startup, :func:`load_credentials` reads the file and sets
10
+ any missing env vars. Env always wins — if the user already
11
+ ``export``ed something in their shell, we don't overwrite it.
12
+ 2. :func:`ensure_key_for_model` checks if the chosen model has a
13
+ key it can use. If not, prompts the user inline (hidden
14
+ input), saves to the credentials file, and updates env.
15
+ 3. loomflow's model resolver reads env in the normal way — it
16
+ has no idea this layer exists.
17
+
18
+ Security: the file is written with ``chmod 600`` (user-only
19
+ read/write). Plaintext on disk; an OS keyring would be stronger
20
+ but adds a dependency we don't have yet. Worth revisiting.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import getpass
26
+ import os
27
+ from pathlib import Path
28
+
29
+ from rich.console import Console
30
+
31
+ # User-level config. Per-project state stays at ``<project>/.loom/``
32
+ # (lowercase + dot, no hyphen) — the hyphen here is the on-purpose
33
+ # differentiator: "this is loom-code's GLOBAL config, not a project."
34
+ _CREDENTIALS_DIR = Path.home() / ".loom-code"
35
+ _CREDENTIALS_FILE = _CREDENTIALS_DIR / "credentials"
36
+ # Remembers the user's chosen model across sessions, so launching
37
+ # loom-code reuses the last model (via /model or /set_model) instead of
38
+ # always reverting to the built-in default.
39
+ _MODEL_FILE = _CREDENTIALS_DIR / "model"
40
+
41
+
42
+ def save_preferred_model(model: str) -> None:
43
+ """Persist the chosen model to ``~/.loom-code/model``. Best-effort."""
44
+ try:
45
+ _CREDENTIALS_DIR.mkdir(parents=True, exist_ok=True)
46
+ _MODEL_FILE.write_text(model.strip() + "\n", encoding="utf-8")
47
+ except OSError:
48
+ pass
49
+
50
+
51
+ def load_preferred_model() -> str | None:
52
+ """The model saved on a previous run, or None if none/unreadable."""
53
+ try:
54
+ m = _MODEL_FILE.read_text(encoding="utf-8").strip()
55
+ return m or None
56
+ except OSError:
57
+ return None
58
+
59
+ # --- LiteLLM provider registry ------------------------------------------
60
+ #
61
+ # loomflow routes ``litellm/<provider>/<model>`` through LiteLLM, which
62
+ # reads a provider-specific API key from the environment. Stock loomflow
63
+ # knows the *routing*; loom-code adds the friendly layer on top — so a
64
+ # user who picks one of these providers gets the same "paste your key +
65
+ # here's where to get one" flow as OpenAI/Anthropic, instead of a raw
66
+ # LiteLLM KeyError.
67
+ #
68
+ # Keyed by LiteLLM provider slug (the segment after ``litellm/``). Each
69
+ # entry is (env var LiteLLM reads, where to sign up). NVIDIA NIM is here
70
+ # because build.nvidia.com hands out a FREE tier — the cheapest way to
71
+ # drive loom-code, and what we use for the Terminal-Bench run. Adding a
72
+ # provider is one line: no other code changes needed, since the whole
73
+ # credential/prompt path is table-driven off this dict.
74
+ #
75
+ # Deliberately NOT exhaustive — only providers we've verified route
76
+ # cleanly. An unknown ``litellm/<x>/...`` still returns None from
77
+ # required_env_for_model (we don't guess and risk prompting for the
78
+ # wrong key), preserving the "advanced users are on their own" escape
79
+ # hatch for anything not listed here.
80
+ _BUILTIN_LITELLM_PROVIDERS: dict[str, tuple[str, str]] = {
81
+ "nvidia_nim": ("NVIDIA_NIM_API_KEY", "https://build.nvidia.com"),
82
+ "groq": ("GROQ_API_KEY", "https://console.groq.com/keys"),
83
+ "together_ai": ("TOGETHERAI_API_KEY", "https://api.together.xyz/settings/api-keys"),
84
+ "gemini": ("GEMINI_API_KEY", "https://aistudio.google.com/apikey"),
85
+ "mistral": ("MISTRAL_API_KEY", "https://console.mistral.ai/api-keys"),
86
+ "deepseek": ("DEEPSEEK_API_KEY", "https://platform.deepseek.com/api_keys"),
87
+ }
88
+
89
+
90
+ # Context-window sizes (tokens) for models loomflow's context_window_for
91
+ # doesn't recognise — chiefly litellm-routed providers. Without this,
92
+ # an unknown model falls back to a conservative 8192, which makes
93
+ # auto-compact fire far too early (compacting live state into a lossy
94
+ # summary mid-task). Keyed by a substring matched case-insensitively
95
+ # against the model string; first match wins, so order longest/most-
96
+ # specific first. Users can override per-model via
97
+ # ``auto_compact_at_tokens`` or a ``[[provider]] context_window`` block.
98
+ _MODEL_CONTEXT_WINDOWS: list[tuple[str, int]] = [
99
+ # NVIDIA Nemotron family (build.nvidia.com) — 128K context.
100
+ ("nemotron", 128_000),
101
+ # Groq-hosted Llama 3.3 / 3.1 — 128K.
102
+ ("llama-3.3", 128_000),
103
+ ("llama-3.1", 128_000),
104
+ # DeepSeek chat/coder — 64K.
105
+ ("deepseek", 64_000),
106
+ ]
107
+
108
+
109
+ def context_window_override(model: str) -> int | None:
110
+ """A known context-window size for ``model`` when loomflow's own
111
+ ``context_window_for`` would fall back to its conservative default.
112
+
113
+ Returns None if we have no better number (caller keeps its default).
114
+ Consulted before the fallback so litellm-routed models (NVIDIA
115
+ Nemotron, Groq Llama, ...) get a realistic compaction threshold.
116
+ A user ``[[provider]]`` block may add ``context_window`` to override.
117
+ """
118
+ lower = model.lower()
119
+ slug = _litellm_provider_slug(model)
120
+ if slug is not None:
121
+ _, user_windows = _load_user_provider_windows()
122
+ if slug in user_windows:
123
+ return user_windows[slug]
124
+ for needle, size in _MODEL_CONTEXT_WINDOWS:
125
+ if needle in lower:
126
+ return size
127
+ return None
128
+
129
+
130
+ def _load_user_provider_windows() -> tuple[dict[str, str], dict[str, int]]:
131
+ """Read optional ``context_window`` from user ``[[provider]]`` blocks.
132
+ Returns ``({}, {slug: window})`` — the first element is a placeholder
133
+ kept for shape symmetry with other loaders. Lenient, never crashes.
134
+ """
135
+ windows: dict[str, int] = {}
136
+ settings = _CREDENTIALS_DIR / "settings.toml"
137
+ try:
138
+ import tomllib
139
+
140
+ data = tomllib.loads(settings.read_text(encoding="utf-8"))
141
+ except (OSError, ValueError):
142
+ return {}, windows
143
+ raw = data.get("provider")
144
+ if not isinstance(raw, list):
145
+ return {}, windows
146
+ for entry in raw:
147
+ if not isinstance(entry, dict):
148
+ continue
149
+ slug = str(entry.get("slug", "")).strip()
150
+ cw = entry.get("context_window")
151
+ if slug and isinstance(cw, int) and cw > 0:
152
+ windows[slug] = cw
153
+ return {}, windows
154
+
155
+
156
+ def _load_user_providers() -> dict[str, tuple[str, str]]:
157
+ """Read ``[[provider]]`` blocks from ``~/.loom-code/settings.toml`` so a
158
+ user can register ANY OpenAI-compatible / LiteLLM provider WITHOUT
159
+ editing loom-code's source — the general "integrate any API" hatch.
160
+
161
+ Each block::
162
+
163
+ [[provider]]
164
+ slug = "myproxy" # the litellm/<slug>/ segment
165
+ env = "MYPROXY_API_KEY" # env var LiteLLM reads for the key
166
+ signup = "https://..." # optional, shown in the key prompt
167
+ alias = "myai" # optional, short --model prefix
168
+ # For a bare OpenAI-compatible endpoint, point litellm at it:
169
+ # slug = "openai" with api_base set via env, per LiteLLM docs.
170
+
171
+ Returns ``{slug: (env, signup)}``. Lenient: a missing file, bad TOML,
172
+ or a malformed block yields ``{}`` / skips the block rather than
173
+ crashing — same never-abort posture as the extensions discovery.
174
+ User entries OVERRIDE built-ins of the same slug (lets a user repoint
175
+ a provider's env var). Aliases are collected separately.
176
+ """
177
+ out, _ = _load_user_providers_and_aliases()
178
+ return out
179
+
180
+
181
+ def _load_user_providers_and_aliases() -> (
182
+ tuple[dict[str, tuple[str, str]], dict[str, tuple[str, bool]]]
183
+ ):
184
+ """Backing loader for both the provider registry and the alias map.
185
+
186
+ Returns ``(providers, aliases)`` where ``aliases`` maps a short
187
+ ``--model`` prefix to ``(slug, keep_prefix=False)``. Kept private;
188
+ :func:`litellm_providers` and :func:`normalize_model` consume it.
189
+ """
190
+ providers: dict[str, tuple[str, str]] = {}
191
+ aliases: dict[str, tuple[str, bool]] = {}
192
+ settings = _CREDENTIALS_DIR / "settings.toml"
193
+ try:
194
+ import tomllib
195
+
196
+ data = tomllib.loads(settings.read_text(encoding="utf-8"))
197
+ except (OSError, ValueError):
198
+ return providers, aliases
199
+ raw = data.get("provider")
200
+ if not isinstance(raw, list):
201
+ return providers, aliases
202
+ for entry in raw:
203
+ if not isinstance(entry, dict):
204
+ continue
205
+ slug = str(entry.get("slug", "")).strip()
206
+ env = str(entry.get("env", "")).strip()
207
+ if not slug or not env:
208
+ continue
209
+ signup = str(entry.get("signup", "")).strip() or (
210
+ "your provider's dashboard"
211
+ )
212
+ providers[slug] = (env, signup)
213
+ alias = str(entry.get("alias", "")).strip().lower()
214
+ if alias:
215
+ aliases[alias] = (slug, False)
216
+ return providers, aliases
217
+
218
+
219
+ def patient_retry_policy_for(model: str):
220
+ """A more patient retry schedule for litellm-routed providers, or
221
+ ``None`` to accept loomflow's default (3 attempts) elsewhere.
222
+
223
+ Free tiers rate-limit hard — NVIDIA NIM allows 40 req/min — and a
224
+ multi-agent turn bursts several calls, so the stock 3-attempt /
225
+ 30s-cap schedule can exhaust inside one limit window and surface a
226
+ RateLimitError the user didn't deserve. 6 attempts with a 90s cap
227
+ rides out a full window. First-party APIs (OpenAI/Anthropic) have
228
+ high limits; the default is right for them, so return None.
229
+
230
+ Returned object is loomflow's ``RetryPolicy`` (imported lazily so
231
+ this module stays importable without loomflow, e.g. in docs
232
+ tooling); typed loosely for the same reason.
233
+ """
234
+ # A pre-constructed Model OBJECT (tests pass ScriptedModel; adapters
235
+ # are legal too) isn't litellm-routed by us — leave its retry
236
+ # behaviour to whoever built it.
237
+ if not isinstance(model, str):
238
+ return None
239
+ if not model.lower().startswith("litellm/"):
240
+ return None
241
+ from loomflow.governance.retry import RetryPolicy
242
+
243
+ return RetryPolicy(
244
+ max_attempts=6,
245
+ initial_delay_s=2.0,
246
+ max_delay_s=90.0,
247
+ multiplier=2.0,
248
+ jitter=0.2,
249
+ )
250
+
251
+
252
+ def quiet_litellm_model_warnings(model: str) -> None:
253
+ """Silence loomflow's two "unknown model" ``UserWarning``s for a
254
+ litellm-routed model, where they're expected noise rather than a
255
+ real problem:
256
+
257
+ * ``context_window_for: unknown model ...`` — loom-code supplies the
258
+ real window via :func:`context_window_override`, so loomflow's
259
+ conservative fallback is never actually used.
260
+ * ``cost estimation: unknown model ...`` — providers like NVIDIA's
261
+ free tier have no entry in loomflow's pricing table; reporting
262
+ $0.00 is correct for a free model, and budget caps are moot.
263
+
264
+ Scoped to litellm models ONLY: a native ``gpt-*``/``claude-*`` that
265
+ somehow warns is a genuine signal we must not hide. No-op otherwise.
266
+ """
267
+ if not model.lower().startswith("litellm/"):
268
+ return
269
+ import warnings
270
+
271
+ for pattern in (
272
+ r"context_window_for: unknown model.*",
273
+ r"cost estimation: unknown model.*",
274
+ ):
275
+ warnings.filterwarnings(
276
+ "ignore", message=pattern, category=UserWarning
277
+ )
278
+
279
+
280
+ def litellm_providers() -> dict[str, tuple[str, str]]:
281
+ """The effective provider registry: built-ins overlaid with any
282
+ user-declared ``[[provider]]`` blocks. Recomputed on each call so a
283
+ settings.toml edit takes effect without restarting the process."""
284
+ merged = dict(_BUILTIN_LITELLM_PROVIDERS)
285
+ merged.update(_load_user_providers())
286
+ return merged
287
+
288
+
289
+ def _litellm_provider_slug(model: str) -> str | None:
290
+ """For a ``litellm/<provider>/<model>`` string, return the provider
291
+ slug (``nvidia_nim``, ``groq``, ...) if it's one in the effective
292
+ registry (built-ins + user-declared), else None. Case-insensitive on
293
+ the prefix; the slug is matched exactly."""
294
+ lower = model.lower()
295
+ if not lower.startswith("litellm/"):
296
+ return None
297
+ rest = model[len("litellm/"):]
298
+ slug = rest.split("/", 1)[0]
299
+ return slug if slug in litellm_providers() else None
300
+
301
+
302
+ # First-party signup links. LiteLLM provider links are merged in at
303
+ # lookup time by :func:`signup_url_for` so user-declared providers get
304
+ # their link too, without a module-load-time snapshot going stale.
305
+ _FIRST_PARTY_SIGNUP_URL = {
306
+ "OPENAI_API_KEY": "https://platform.openai.com/api-keys",
307
+ "ANTHROPIC_API_KEY": "https://console.anthropic.com/settings/keys",
308
+ }
309
+
310
+
311
+ def signup_url_for(env_name: str) -> str:
312
+ """Where to get a key for ``env_name`` — first-party or any provider
313
+ in the effective registry. Falls back to a generic hint."""
314
+ if env_name in _FIRST_PARTY_SIGNUP_URL:
315
+ return _FIRST_PARTY_SIGNUP_URL[env_name]
316
+ for env, url in litellm_providers().values():
317
+ if env == env_name:
318
+ return url
319
+ return "your provider's dashboard"
320
+
321
+
322
+ def normalize_model(model: str) -> str:
323
+ """Expand short provider aliases into the ``litellm/<provider>/``
324
+ form loomflow's resolver understands.
325
+
326
+ Lets a user type the friendly ``nvidia/nemotron-...`` instead of the
327
+ verbose ``litellm/nvidia_nim/nvidia/nemotron-...``. Only rewrites a
328
+ leading ``<alias>/`` for an alias we recognise; everything else —
329
+ already-prefixed ``litellm/...``, native ``gpt-*``/``claude-*``,
330
+ ``ollama/...`` — passes through untouched. Idempotent: running it on
331
+ an already-normalised string is a no-op.
332
+
333
+ ``nvidia`` is the alias for the ``nvidia_nim`` LiteLLM provider (the
334
+ provider slug isn't an obvious thing to type). NVIDIA's own model
335
+ IDs are vendor-namespaced (``nvidia/nemotron-...``, ``meta/llama-...``),
336
+ so the ``nvidia`` alias PRESERVES the segment as part of the model
337
+ ID rather than consuming it — ``nvidia/nemotron-x`` becomes
338
+ ``litellm/nvidia_nim/nvidia/nemotron-x``, not ``.../nemotron-x``.
339
+ Aliases whose provider uses flat model IDs consume the segment.
340
+ """
341
+ # alias -> (litellm provider slug, keep_prefix). keep_prefix=True
342
+ # re-attaches the alias segment to the model ID (for providers with
343
+ # vendor-namespaced IDs like NVIDIA); False consumes it. Built-in
344
+ # aliases first; user-declared ``[[provider]] alias = ...`` entries
345
+ # merged on top so a custom provider gets a short prefix too.
346
+ aliases: dict[str, tuple[str, bool]] = {
347
+ "nvidia": ("nvidia_nim", True),
348
+ "nvidia_nim": ("nvidia_nim", False),
349
+ "groq": ("groq", False),
350
+ "together": ("together_ai", False),
351
+ "together_ai": ("together_ai", False),
352
+ "gemini": ("gemini", False),
353
+ "mistral": ("mistral", False),
354
+ "deepseek": ("deepseek", False),
355
+ }
356
+ _, user_aliases = _load_user_providers_and_aliases()
357
+ aliases.update(user_aliases)
358
+ spec = model.strip()
359
+ if spec.lower().startswith("litellm/"):
360
+ return spec
361
+ head, sep, rest = spec.partition("/")
362
+ entry = aliases.get(head.lower())
363
+ if sep and entry and rest:
364
+ slug, keep_prefix = entry
365
+ model_id = f"{head}/{rest}" if keep_prefix else rest
366
+ return f"litellm/{slug}/{model_id}"
367
+ return spec
368
+
369
+
370
+ def load_credentials() -> None:
371
+ """Read ``~/.loom-code/credentials`` and populate any env vars
372
+ that aren't already set. Silent no-op if the file is missing.
373
+
374
+ The file format is plain ``KEY=value`` lines, comments with
375
+ ``#``, blank lines ignored. Surrounding quotes on the value
376
+ (single or double) are stripped.
377
+ """
378
+ if not _CREDENTIALS_FILE.exists():
379
+ return
380
+ for raw in _CREDENTIALS_FILE.read_text(encoding="utf-8").splitlines():
381
+ line = raw.strip()
382
+ if not line or line.startswith("#") or "=" not in line:
383
+ continue
384
+ name, value = line.split("=", 1)
385
+ name = name.strip()
386
+ value = value.strip().strip('"').strip("'")
387
+ # Env wins — if the user already ``export``ed something,
388
+ # we don't second-guess them.
389
+ if name and value and not os.environ.get(name):
390
+ os.environ[name] = value
391
+
392
+
393
+ def save_credential(name: str, value: str) -> None:
394
+ """Write or update ``name=value`` in
395
+ ``~/.loom-code/credentials`` with ``chmod 600``. The dir is
396
+ created if missing. Preserves comments + other entries; only
397
+ replaces the line for ``name`` if it already exists."""
398
+ _CREDENTIALS_DIR.mkdir(parents=True, exist_ok=True)
399
+ out: list[str] = []
400
+ replaced = False
401
+ if _CREDENTIALS_FILE.exists():
402
+ for raw in _CREDENTIALS_FILE.read_text(encoding="utf-8").splitlines():
403
+ stripped = raw.strip()
404
+ if (
405
+ stripped
406
+ and not stripped.startswith("#")
407
+ and "=" in stripped
408
+ and stripped.split("=", 1)[0].strip() == name
409
+ ):
410
+ out.append(f"{name}={value}")
411
+ replaced = True
412
+ else:
413
+ out.append(raw)
414
+ if not replaced:
415
+ out.append(f"{name}={value}")
416
+ _CREDENTIALS_FILE.write_text("\n".join(out) + "\n", encoding="utf-8")
417
+ # User-only read/write; cheap defence vs. accidental world-
418
+ # readability (e.g. if HOME ends up shared). POSIX-only — on
419
+ # Windows chmod can't express owner-only perms (the file inherits
420
+ # NTFS ACLs from the user profile dir, which is already private).
421
+ if os.name == "posix":
422
+ _CREDENTIALS_FILE.chmod(0o600)
423
+
424
+
425
+ def required_env_for_model(model: str) -> str | None:
426
+ """Which env var loomflow's resolver needs to talk to ``model``.
427
+
428
+ Returns ``None`` for models that need no key (local Ollama,
429
+ EchoModel, and ``litellm/<provider>`` where the answer
430
+ depends on which provider — we can't reliably guess).
431
+ """
432
+ lower = model.lower()
433
+ if lower == "echo":
434
+ return None
435
+ if lower.startswith("ollama/"):
436
+ return None
437
+ if lower.startswith("litellm/"):
438
+ # A KNOWN LiteLLM provider (nvidia_nim, groq, ...) maps to its
439
+ # env var so we prompt for the right key with a signup link.
440
+ # An UNKNOWN litellm/<x>/... still returns None — advanced
441
+ # users routing through an unlisted provider set the env
442
+ # themselves, and we won't surprise them with a wrong-key
443
+ # prompt we can't reliably name.
444
+ slug = _litellm_provider_slug(model)
445
+ if slug is not None:
446
+ return litellm_providers()[slug][0]
447
+ return None
448
+ if "claude" in lower:
449
+ return "ANTHROPIC_API_KEY"
450
+ # Everything else (gpt-*, o-series, etc.) → OpenAI.
451
+ return "OPENAI_API_KEY"
452
+
453
+
454
+ def cheap_model_for(model: str) -> str | None:
455
+ """The CHEAP, fast sibling of ``model`` in the SAME provider —
456
+ for low-stakes utility calls (compaction summaries, tool-result
457
+ compression, /goal's DONE/NOT_DONE checker).
458
+
459
+ Staying in-provider avoids switching to an account that may be
460
+ unfunded (a set key doesn't prove credits). Returns ``None``
461
+ when no cheap sibling is usable (local / litellm / echo models,
462
+ or the cheap model's key isn't set) — callers fall back to the
463
+ main model.
464
+ """
465
+ lower = model.lower()
466
+ # Escape BEFORE the substring checks: ``litellm/anthropic/claude-*``
467
+ # contains "claude" but is routed through a proxy/Bedrock the user
468
+ # chose — silently sending compaction summaries or tool output
469
+ # direct to api.anthropic.com would bypass that routing. Local and
470
+ # fake models have no cheap sibling either.
471
+ if lower == "echo" or lower.startswith(("ollama/", "litellm/")):
472
+ return None
473
+ if "claude" in lower:
474
+ target = "claude-haiku-4-5"
475
+ elif lower.startswith(("gpt", "o1", "o3", "o4")):
476
+ target = "gpt-4.1-mini"
477
+ else:
478
+ # Unknown provider — don't guess.
479
+ return None
480
+ if lower == target:
481
+ return target
482
+ env = required_env_for_model(target)
483
+ if env is None or os.environ.get(env):
484
+ return target
485
+ return None
486
+
487
+
488
+ def ensure_key_for_model(model: str, console: Console) -> bool:
489
+ """If ``model`` needs a key that isn't set, prompt the user
490
+ for one (hidden input), save it, and load it into env. Returns
491
+ ``True`` when we can proceed, ``False`` if the user cancelled.
492
+
493
+ Sync because it's called from ``main()`` before any async
494
+ event loop is running — and ``getpass`` is sync-blocking
495
+ anyway.
496
+ """
497
+ env_name = required_env_for_model(model)
498
+ if env_name is None:
499
+ return True
500
+ if os.environ.get(env_name):
501
+ return True
502
+
503
+ console.print()
504
+ console.print(
505
+ f" [yellow]No {env_name} set.[/yellow] "
506
+ f"loom-code needs it to use [cyan]{model}[/cyan]."
507
+ )
508
+ signup = signup_url_for(env_name)
509
+ console.print(f" Get one at [dim]{signup}[/dim].\n")
510
+ try:
511
+ # getpass hides the input so the key doesn't appear in
512
+ # the terminal or shell history.
513
+ value = getpass.getpass(f" Paste your {env_name}: ")
514
+ except (EOFError, KeyboardInterrupt):
515
+ console.print("\n [dim]cancelled[/dim]")
516
+ return False
517
+ value = value.strip()
518
+ if not value:
519
+ console.print(" [yellow]no key entered — aborting[/yellow]")
520
+ return False
521
+ save_credential(env_name, value)
522
+ os.environ[env_name] = value
523
+ console.print(
524
+ f" [green]✓[/green] saved to "
525
+ f"[dim]{_CREDENTIALS_FILE}[/dim] (chmod 600)\n"
526
+ )
527
+ return True