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.
- loom_code/__init__.py +22 -0
- loom_code/_post_commit.py +119 -0
- loom_code/agent.py +544 -0
- loom_code/approval.py +616 -0
- loom_code/browse/__init__.py +291 -0
- loom_code/browse/act.py +467 -0
- loom_code/browse/observe.py +249 -0
- loom_code/browse/session.py +96 -0
- loom_code/browse/verify.py +194 -0
- loom_code/checkpoint.py +283 -0
- loom_code/cli.py +495 -0
- loom_code/code_index.py +703 -0
- loom_code/compact.py +143 -0
- loom_code/consent.py +47 -0
- loom_code/credentials.py +527 -0
- loom_code/edit_tool.py +635 -0
- loom_code/extensions.py +522 -0
- loom_code/file_history.py +322 -0
- loom_code/file_tools.py +93 -0
- loom_code/git_hook.py +200 -0
- loom_code/grep_tool.py +430 -0
- loom_code/hooks.py +297 -0
- loom_code/loominit/__init__.py +23 -0
- loom_code/loominit/_ast_walk.py +429 -0
- loom_code/loominit/_files.py +284 -0
- loom_code/loominit/_graph.py +141 -0
- loom_code/loominit/_resolve.py +392 -0
- loom_code/loominit/_tests_map.py +108 -0
- loom_code/loominit/extractor.py +332 -0
- loom_code/loominit/repomap.py +225 -0
- loom_code/loominit/schema.py +242 -0
- loom_code/lsp_tools.py +396 -0
- loom_code/mcp_host.py +79 -0
- loom_code/operator.py +449 -0
- loom_code/paste.py +97 -0
- loom_code/paths.py +52 -0
- loom_code/permissions.py +177 -0
- loom_code/project.py +104 -0
- loom_code/prompts.py +451 -0
- loom_code/render.py +783 -0
- loom_code/repl.py +4080 -0
- loom_code/rules.py +267 -0
- loom_code/sandboxed_bash.py +176 -0
- loom_code/scribe.py +88 -0
- loom_code/skills/__init__.py +16 -0
- loom_code/skills/graphify/SKILL.md +97 -0
- loom_code/skills/graphify/tools.py +570 -0
- loom_code/trust.py +216 -0
- loom_code/turn.py +169 -0
- loom_code/web_fetch.py +370 -0
- loom_code/workers.py +758 -0
- loom_code/worktree.py +134 -0
- loom_code-0.1.1.dist-info/METADATA +224 -0
- loom_code-0.1.1.dist-info/RECORD +58 -0
- loom_code-0.1.1.dist-info/WHEEL +5 -0
- loom_code-0.1.1.dist-info/entry_points.txt +2 -0
- loom_code-0.1.1.dist-info/licenses/LICENSE +21 -0
- loom_code-0.1.1.dist-info/top_level.txt +1 -0
loom_code/credentials.py
ADDED
|
@@ -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
|