@tiens.nguyen/gu-cli 1.0.686

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 (43) hide show
  1. package/README.md +52 -0
  2. package/agent-model-command.mjs +259 -0
  3. package/agent-model-label.mjs +159 -0
  4. package/clear-state.mjs +149 -0
  5. package/client-expert-api.mjs +736 -0
  6. package/client-expert-run.mjs +892 -0
  7. package/client-expert-setup.mjs +616 -0
  8. package/coding-choice-tags.mjs +69 -0
  9. package/coding-key-prompt.mjs +229 -0
  10. package/coding-provider-setup.mjs +808 -0
  11. package/completed-flush.mjs +105 -0
  12. package/daemon-control.mjs +462 -0
  13. package/device-login.mjs +212 -0
  14. package/doctor-check.mjs +239 -0
  15. package/embed-model-command.mjs +157 -0
  16. package/first-run-steps.mjs +171 -0
  17. package/gonext_agent_chat.py +12299 -0
  18. package/gonext_mlx_embed.py +155 -0
  19. package/gonext_probe_agent.py +93 -0
  20. package/gonext_transcribe.py +130 -0
  21. package/gu-cli.mjs +4930 -0
  22. package/gu-repl.mjs +10326 -0
  23. package/job-pools.mjs +89 -0
  24. package/model-doctor.mjs +1494 -0
  25. package/node-version.mjs +40 -0
  26. package/ollama-setup.mjs +832 -0
  27. package/package.json +100 -0
  28. package/platform-tools.mjs +520 -0
  29. package/poll-errors.mjs +141 -0
  30. package/proxy-command.mjs +165 -0
  31. package/proxy-config.mjs +255 -0
  32. package/proxy-dispatcher.mjs +132 -0
  33. package/proxy-selftest.mjs +234 -0
  34. package/proxy-store.mjs +69 -0
  35. package/rag-job-config.mjs +59 -0
  36. package/rag-selftest.mjs +215 -0
  37. package/s3-setup.mjs +85 -0
  38. package/terminal-copy.mjs +248 -0
  39. package/terminal-hover.mjs +153 -0
  40. package/terminal-layout.mjs +2507 -0
  41. package/terminal-viewport.mjs +602 -0
  42. package/thinking_words.txt +1003 -0
  43. package/version-check.mjs +72 -0
@@ -0,0 +1,808 @@
1
+ /**
2
+ * Adding an OpenAI-compatible coding provider from the terminal (task #223).
3
+ *
4
+ * node --test tests/coding-provider-setup.test.mjs
5
+ *
6
+ * The DECISIONS live here and the presentation lives in gu-repl.mjs, the same split
7
+ * ollama-setup.mjs / reconfigureOllamaServer uses — so what a URL means and what a model list is
8
+ * worth can be tested without a terminal, and first-run setup and /model cannot disagree.
9
+ *
10
+ * WHY THE URL RULES ARE NOT ollama-setup's. normalizeOllamaUrl DISCARDS the path, and it is right
11
+ * to: Ollama serves /v1 and /api at the root of the box, so ".../v1/chat/completions" pasted from
12
+ * a log means the box. An OpenAI-compatible provider is the opposite — the path is part of the
13
+ * address. https://openrouter.ai/api/v1 is OpenRouter's documented base, and dropping the /api
14
+ * from it 404s every call. So this keeps the path and mirrors the API's normalizeOpenAiV1Root
15
+ * instead, including that function's one hard-won exception.
16
+ */
17
+
18
+ /** Cloud instance-metadata addresses. Never a model server, always the first thing an SSRF
19
+ * probe reaches for, and the one block worth having in a product whose normal target is a
20
+ * machine on your own LAN. */
21
+ const METADATA_HOSTS = new Set([
22
+ "metadata.google.internal",
23
+ "metadata",
24
+ "100.100.100.200", // Alibaba Cloud
25
+ "fd00:ec2::254", // AWS IMDS over IPv6
26
+ ]);
27
+
28
+ const IPV4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
29
+
30
+ /** Loopback, LAN, or a name that can only resolve inside a network. */
31
+ export function hostLocality(hostname) {
32
+ let host = String(hostname ?? "").toLowerCase().replace(/\.$/, "");
33
+ // A PORT MAY COME ALONG. Callers hand this both a bare hostname (from URL.hostname) and a
34
+ // provider LABEL, which carries the port precisely so two local servers can be told apart —
35
+ // and "192.168.1.45:11434" read as a hostname has a dot and no IPv4 match, so it came out
36
+ // PUBLIC. That inverted every locality decision for exactly the machines this product is
37
+ // built around. Bracketed IPv6 keeps its colons; only a trailing :port is stripped.
38
+ if (host.startsWith("[")) host = host.replace(/](:\d+)$/, "]");
39
+ else host = host.replace(/:\d+$/, "");
40
+ if (!host) return "invalid";
41
+ if (host === "localhost" || host === "::1" || host === "[::1]") return "loopback";
42
+ const v4 = host.match(IPV4);
43
+ if (v4) {
44
+ const [a, b] = [Number(v4[1]), Number(v4[2])];
45
+ if ([a, b, Number(v4[3]), Number(v4[4])].some((n) => n > 255)) return "invalid";
46
+ if (a === 127) return "loopback";
47
+ if (a === 169 && b === 254) return "link-local"; // includes 169.254.169.254 (IMDS)
48
+ if (a === 10) return "private";
49
+ if (a === 172 && b >= 16 && b <= 31) return "private";
50
+ if (a === 192 && b === 168) return "private";
51
+ return "public";
52
+ }
53
+ // IPv6 in brackets: fc00::/7 is the unique-local range.
54
+ if (host.startsWith("[")) {
55
+ const inner = host.slice(1, -1);
56
+ if (/^f[cd]/.test(inner)) return "private";
57
+ if (/^fe80:/.test(inner)) return "link-local";
58
+ return "public";
59
+ }
60
+ // A single-label name, or one of the names that exist only inside a network.
61
+ if (!host.includes(".")) return "private";
62
+ if (/\.(local|lan|internal|home|intranet|localdomain)$/.test(host)) return "private";
63
+ return "public";
64
+ }
65
+
66
+ /**
67
+ * Normalize and VET a provider base URL.
68
+ *
69
+ * Returns { ok: true, url, host, locality, schemeAssumed } or { ok: false, why }.
70
+ *
71
+ * WHERE THIS DEPARTS FROM THE TASK'S LETTER, deliberately. Requirement 6 says "require https for
72
+ * a non-loopback host". Taken literally that refuses http://192.168.1.45:11434 — which is not an
73
+ * edge case here, it is the product's normal configuration, already stored in thousands of
74
+ * existing coding-backend rows, and the whole point of a local-first agent. A rule that refuses
75
+ * the main use case would not be obeyed; it would be worked around.
76
+ *
77
+ * So the line is drawn where the risk actually is: a PUBLIC host over http means the API key is
78
+ * posted across the internet in clear text, and that is refused. A loopback or LAN host over
79
+ * http is allowed without comment, because the traffic never leaves a network the user controls
80
+ * and because refusing it would break local vLLM, LM Studio and Ollama.
81
+ *
82
+ * The SSRF half of that requirement gets the block that is worth having: instance metadata. In a
83
+ * product whose legitimate targets include 10.0.0.0/8 and localhost, refusing "internal
84
+ * addresses" wholesale is meaningless — but 169.254.169.254 and metadata.google.internal are
85
+ * never a model server, and they are what an SSRF probe asks for first.
86
+ */
87
+ export function normalizeProviderUrl(raw) {
88
+ const text = String(raw ?? "").trim();
89
+ if (!text) return { ok: false, why: "empty" };
90
+ if (/\s/.test(text)) return { ok: false, why: "spaces" };
91
+
92
+ const hadScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(text);
93
+ // Reject a scheme we cannot speak BEFORE prefixing, or "ftp://box" becomes
94
+ // "https://ftp://box" and fails with a baffling message.
95
+ if (hadScheme && !/^https?:\/\//i.test(text)) return { ok: false, why: "scheme" };
96
+ // No scheme: guess from what the host looks like, and SAY that we guessed. https for something
97
+ // that looks like a hosted provider, http for a LAN box — the same split ollama-setup makes,
98
+ // for the same reason (one guess is right for one shape of address and wrong for the other).
99
+ const guessed = hostLocality(text.split("/")[0].split(":")[0]) === "public" ? "https" : "http";
100
+ const withScheme = hadScheme ? text : `${guessed}://${text}`;
101
+
102
+ let u;
103
+ try {
104
+ u = new URL(withScheme);
105
+ } catch {
106
+ return { ok: false, why: "unparseable" };
107
+ }
108
+ if (!u.hostname) return { ok: false, why: "unparseable" };
109
+ // A key or password in the URL would be STORED in settings and DISPLAYED back — a secret must
110
+ // only ever arrive through the key field, which is write-only and masked.
111
+ if (u.username || u.password) return { ok: false, why: "credentials" };
112
+
113
+ const host = u.hostname.replace(/\.$/, "").toLowerCase();
114
+ const locality = hostLocality(host);
115
+ if (locality === "invalid") return { ok: false, why: "unparseable" };
116
+ if (METADATA_HOSTS.has(host) || locality === "link-local") {
117
+ return { ok: false, why: "metadata" };
118
+ }
119
+ if (locality === "public" && u.protocol === "http:") {
120
+ return { ok: false, why: "insecure" };
121
+ }
122
+
123
+ const port = u.port ? `:${u.port}` : "";
124
+ // The PATH IS KEPT — see the file header. Trailing slashes go, then /v1 is ensured with the
125
+ // API's own rule, including the "/api/v1 is not an Ollama path" exception.
126
+ const path = u.pathname.replace(/\/+$/, "");
127
+ const base = `${u.protocol}//${host}${port}${path}`;
128
+ return {
129
+ ok: true,
130
+ url: ensureV1Root(base),
131
+ host,
132
+ locality,
133
+ schemeAssumed: !hadScheme,
134
+ };
135
+ }
136
+
137
+ /**
138
+ * Ensure a base URL ends at its OpenAI-compatible /v1 root.
139
+ *
140
+ * MIRRORS normalizeOpenAiV1Root (api/src/app.ts) and _normalize_openai_base
141
+ * (gonext_agent_chat.py). All three run over the same stored URL — this one when it is typed,
142
+ * the API when it is saved, python at turn time — so a difference between them means a URL that
143
+ * is accepted here and silently rewritten later.
144
+ *
145
+ * A bare "/api/v1" SURVIVES: the Ollama strip takes a native verb ("/api/generate"), and making
146
+ * that verb optional is what used to eat OpenRouter's "/api/v1" and 404 every call.
147
+ */
148
+ export function ensureV1Root(base) {
149
+ const stripped = String(base ?? "")
150
+ .replace(/\/+$/, "")
151
+ .replace(/\/api(?:\/(?:generate|chat|tags|embeddings|embed)(?:\/v1)?)?$/i, "");
152
+ return /\/v1$/i.test(stripped) ? stripped : `${stripped}/v1`;
153
+ }
154
+
155
+ /** What to say about a URL we refused. One line; the caller adds the gutter. */
156
+ export function describeUrlProblem(why, typed = "") {
157
+ switch (why) {
158
+ case "empty":
159
+ return ["type the provider's base URL, e.g. https://api.openai.com/v1"];
160
+ case "spaces":
161
+ return ["that has a space in it — a URL cannot"];
162
+ case "scheme":
163
+ return ["only http:// and https:// addresses work here"];
164
+ case "credentials":
165
+ return [
166
+ "take the key out of the URL — it would be stored and shown back",
167
+ "paste the address only; the key is asked for next and kept hidden",
168
+ ];
169
+ case "metadata":
170
+ return [
171
+ "that is a cloud metadata address, not a model server",
172
+ "if you meant a machine on your network, use its own IP or name",
173
+ ];
174
+ case "insecure":
175
+ return [
176
+ `http:// to a public host would send your API key across the internet in clear text`,
177
+ `use https://${typed.replace(/^https?:\/\//i, "")} — or a LAN address, where http is fine`,
178
+ ];
179
+ default:
180
+ return ["that is not an address I can use — try https://api.openai.com/v1"];
181
+ }
182
+ }
183
+
184
+ /** Where the model list lives for a normalized /v1 root. */
185
+ export function modelsUrlFor(url) {
186
+ return `${String(url ?? "").replace(/\/+$/, "")}/models`;
187
+ }
188
+
189
+ /** mlx_lm's placeholder, which is a mapping and not a model anyone can pick. */
190
+ export const PLACEHOLDER_MODEL = "default_model";
191
+
192
+ /** The sanitizer stores at most this many models per backend, so offering more would quietly
193
+ * drop the tail — better to say the list was long than to lose part of it invisibly. */
194
+ export const MODEL_CAP = 100;
195
+
196
+ /**
197
+ * What a GET {url}/v1/models body is actually worth.
198
+ *
199
+ * Returns { models, why, total } — `why` names the case so the caller can SAY it:
200
+ * "" a usable list
201
+ * "empty" parsed fine, nothing in it
202
+ * "unreadable" not the shape an OpenAI-compatible /v1/models returns
203
+ * "placeholder" the single "default_model" mapping mlx_lm advertises
204
+ * "capped" more than MODEL_CAP; `models` is the first MODEL_CAP and `total` the real count
205
+ *
206
+ * TRUSTED ONLY AS A SUGGESTION, and this is the reason the caller must never block on it. Known
207
+ * liars, all from this repo's own history: mlx_lm advertises its whole HF cache plus a
208
+ * "default_model" placeholder; aggregators return hundreds; some endpoints refuse to list at all
209
+ * without a key — and a LISTED model is still not a usable CODER, since the agent's protocol is a
210
+ * literal <code> block and a model that spells it differently loses its reasoning silently.
211
+ */
212
+ /**
213
+ * Has the provider itself said this model is finished? (task #223 follow-up.)
214
+ *
215
+ * ASKED FOR after a RETIRED model was picked from a list that still advertised it, and the turn
216
+ * died on `model_not_found`. When an endpoint publishes lifecycle metadata, believing it is
217
+ * strictly better than finding out mid-task.
218
+ *
219
+ * ABSENCE MEANS ACTIVE — the rule the request came with, and the only safe default. Most
220
+ * endpoints publish nothing (OpenAI's public /v1/models returns id/object/created/owned_by and
221
+ * no lifecycle at all), so treating a missing field as "retired" would empty the picker for
222
+ * almost everyone. Anything unparseable is treated the same way: keep it, and let the real
223
+ * request be the judge.
224
+ *
225
+ * A DATE IN THE FUTURE IS NOT A RETIREMENT. A model announced for shutdown next year is usable
226
+ * today, and hiding it would be this filter overruling the provider's own schedule.
227
+ *
228
+ * `created` is deliberately NOT consulted: it is when the model appeared, and old is not dead.
229
+ */
230
+ export function modelRetired(row, now = Date.now()) {
231
+ if (!row || typeof row !== "object") return false;
232
+ // A flag, where one is given. Several gateways use one of these instead of a date.
233
+ const status = String(row.status ?? row.lifecycle_status ?? row.lifecycle ?? "").toLowerCase();
234
+ if (/^(deprecated|retired|disabled|sunset|removed|inactive)$/.test(status)) return true;
235
+ if (row.deprecated === true || row.retired === true) return true;
236
+ for (const field of [row.shutdown_date, row.shutdownDate, row.deprecation, row.sunset_date]) {
237
+ if (field === undefined || field === null || field === "") continue;
238
+ // Epoch seconds, epoch millis, or an ISO string — providers use all three.
239
+ const raw = typeof field === "object" ? (field.date ?? field.at ?? "") : field;
240
+ if (raw === undefined || raw === null || raw === "") continue;
241
+ let ms = NaN;
242
+ if (typeof raw === "number") ms = raw > 1e12 ? raw : raw * 1000;
243
+ else if (/^\d+$/.test(String(raw))) {
244
+ const n = Number(raw);
245
+ ms = n > 1e12 ? n : n * 1000;
246
+ } else ms = new Date(String(raw)).getTime();
247
+ if (!Number.isFinite(ms)) continue; // unparseable → not a claim we can act on
248
+ if (ms <= now) return true;
249
+ }
250
+ return false;
251
+ }
252
+
253
+ export function parseProviderModels(body, now = Date.now()) {
254
+ const rows = Array.isArray(body?.data) ? body.data : Array.isArray(body) ? body : null;
255
+ if (!rows) return { models: [], why: "unreadable", total: 0, retired: 0 };
256
+ const ids = [];
257
+ const seen = new Set();
258
+ let retired = 0;
259
+ for (const r of rows) {
260
+ const id = typeof r === "string" ? r.trim() : typeof r?.id === "string" ? r.id.trim() : "";
261
+ if (!id || seen.has(id)) continue;
262
+ seen.add(id);
263
+ // BELIEVE THE PROVIDER when it says a model is done. Offering one that is already retired is
264
+ // offering a choice whose only outcome is a 404 several steps into a task.
265
+ if (modelRetired(r, now)) {
266
+ retired++;
267
+ continue;
268
+ }
269
+ ids.push(id);
270
+ }
271
+ if (ids.length === 0) return { models: [], why: "empty", total: 0, retired };
272
+ // ONLY when it is the whole list: an MLX host that lists its cache AND the placeholder is
273
+ // perfectly usable — you just must not pick the placeholder. One entry and nothing else means
274
+ // the server told us nothing about what it has loaded.
275
+ if (ids.length === 1 && ids[0] === PLACEHOLDER_MODEL) {
276
+ return { models: [], why: "placeholder", total: 1, retired };
277
+ }
278
+ const usable = ids.filter((id) => id !== PLACEHOLDER_MODEL);
279
+ if (usable.length > MODEL_CAP) {
280
+ return { models: usable.slice(0, MODEL_CAP), why: "capped", total: usable.length, retired };
281
+ }
282
+ return { models: usable, why: "", total: usable.length, retired };
283
+ }
284
+
285
+ /** What to say about a list we could not use. The flow continues in every case — a failed probe
286
+ * must never be the end of the road, only the end of the convenience. */
287
+ export function describeModelProblem(why, { host = "", total = 0 } = {}) {
288
+ switch (why) {
289
+ case "empty":
290
+ return [`${host} answered but listed no models — type the model id yourself`];
291
+ // `retired` is reported ALONGSIDE any of these, by retiredNote below.
292
+ case "unreadable":
293
+ return [`${host} did not answer with a model list — type the model id yourself`];
294
+ case "placeholder":
295
+ return [
296
+ `${host} only reports "${PLACEHOLDER_MODEL}", which is a placeholder, not a model`,
297
+ "type the model id the server expects",
298
+ ];
299
+ case "capped":
300
+ return [`${host} listed ${total} models — showing the first ${MODEL_CAP}`];
301
+ case "all-retired":
302
+ return [
303
+ `${host} lists models, but every one of them is marked retired`,
304
+ "type the model id you want — the list is not offering a usable one",
305
+ ];
306
+ default:
307
+ return [];
308
+ }
309
+ }
310
+
311
+ /**
312
+ * Why a probe failed, in words that point at the cause.
313
+ *
314
+ * A 401 HERE IS THE MOST USEFUL ANSWER THIS FLOW CAN GIVE: it means the address is right and the
315
+ * key is wrong, found at setup instead of several steps into a turn where it surfaces as a coder
316
+ * that silently dropped to the chat model.
317
+ */
318
+ export function describeProbeFailure({ status = 0, err = null } = {}) {
319
+ if (status === 401 || status === 403) {
320
+ return { fatal: true, lines: ["the server rejected that key (HTTP " + status + ")"] };
321
+ }
322
+ if (status === 404) {
323
+ return {
324
+ fatal: false,
325
+ lines: ["no /v1/models on that address — it may still work; check the URL's path"],
326
+ };
327
+ }
328
+ if (status >= 500) {
329
+ return { fatal: false, lines: [`the server answered HTTP ${status}`] };
330
+ }
331
+ if (status) return { fatal: false, lines: [`HTTP ${status} from that address`] };
332
+ const msg = String(err?.message ?? err ?? "").toLowerCase();
333
+ if (msg.includes("abort") || msg.includes("timeout")) {
334
+ return { fatal: false, lines: ["no answer in time — the address may be wrong, or /proxy"] };
335
+ }
336
+ if (msg.includes("certificate") || msg.includes("self-signed")) {
337
+ return { fatal: false, lines: ["its TLS certificate could not be verified"] };
338
+ }
339
+ return { fatal: false, lines: ["could not reach it — check the address and /proxy"] };
340
+ }
341
+
342
+ /**
343
+ * A hosted provider needs a CONTEXT WINDOW set (requirement 11).
344
+ *
345
+ * A hosted /v1/models returns id/object/created/owned_by and no size, so gu falls back to
346
+ * assuming 24,000 prompt tokens and compacts there. On a large hosted model that throws history
347
+ * away every single turn for no reason, and produces a bill with no explanation. Worth one line
348
+ * after the provider is saved — and only for a hosted one, since a local server is usually
349
+ * small-windowed anyway and the advice would be noise.
350
+ */
351
+ export function shouldMentionContextWindow(locality) {
352
+ return locality === "public";
353
+ }
354
+
355
+ /**
356
+ * The API stores at most this many backend rows (sanitizeCodingBackends caps at 8).
357
+ *
358
+ * MIRRORED ON PURPOSE, because exceeding it is SILENT: the save returns 200 and the row beyond
359
+ * the cap is simply not there — the exact failure shape this whole task was filed around. Better
360
+ * to refuse with a reason than to appear to succeed.
361
+ */
362
+ export const MAX_BACKENDS = 8;
363
+
364
+ /**
365
+ * The backend rows an account ALREADY has, in the shape a save must preserve.
366
+ *
367
+ * WHY THIS IS NOT JUST `settings.agentCodingBackends`. A pre-#123 account has no array at all —
368
+ * its single coding backend lives in the legacy agentCodingModel* fields, and the API synthesizes
369
+ * it on read. Writing `[newRow]` for such an account would mean the array now exists and holds
370
+ * only the new provider, so the backend the user has been coding with all along VANISHES from
371
+ * /model while its legacy fields sit there unread. Synthesizing it here, exactly as
372
+ * codingBackends() does, makes "add" additive for every account rather than only for recent ones.
373
+ */
374
+ export function existingBackendRows(settings) {
375
+ const rows = Array.isArray(settings?.agentCodingBackends) ? settings.agentCodingBackends : [];
376
+ const clean = rows.filter((b) => b && typeof b === "object" && String(b.url ?? "").trim());
377
+ if (clean.length > 0) return clean;
378
+ const url = String(settings?.agentCodingModelUrl ?? "").trim();
379
+ if (!url) return [];
380
+ const kind = String(settings?.agentCodingModelKind ?? "").trim();
381
+ return [
382
+ {
383
+ // No id: the API identifies a pre-#223 row by its KIND, which keeps every stored pick of
384
+ // `openai::kimi-k3` resolving. Inventing one here would change that id for no reason.
385
+ kind,
386
+ enabled: true,
387
+ url,
388
+ defaultModel: String(settings?.agentCodingModelName ?? "").trim(),
389
+ models: Array.isArray(settings?.agentCodingModelAllowed)
390
+ ? settings.agentCodingModelAllowed.filter((m) => typeof m === "string")
391
+ : [],
392
+ },
393
+ ];
394
+ }
395
+
396
+ /**
397
+ * An id for a NEW provider row, minted on this side.
398
+ *
399
+ * MINTED HERE RATHER THAN READ BACK, because the flow needs it twice in a row: the backend is
400
+ * saved first, then the key is attached to it by id — and POST /coding-keys refuses a backendId
401
+ * naming a row that does not exist. Sending an explicit id (which the API preserves) makes that
402
+ * one round trip instead of save-then-fetch-and-guess-which-row-is-mine.
403
+ *
404
+ * AVOIDS THE KIND NAMES TOO: "openai", "ollama", "local" and "auto" are what the API synthesizes
405
+ * for a row that carries no id, so taking one of them could collide with a pre-#223 row that is
406
+ * not in the list yet.
407
+ */
408
+ export function mintProviderId(existingIds = []) {
409
+ const taken = new Set([...existingIds, "openai", "ollama", "local", "auto", ""]);
410
+ for (let i = 0; i < 50; i++) {
411
+ const id = `prov_${Math.random().toString(36).slice(2, 10)}`;
412
+ if (!taken.has(id)) return id;
413
+ }
414
+ return `prov_${Date.now().toString(36)}`;
415
+ }
416
+
417
+ /** The row to append for a new OpenAI-compatible provider. */
418
+ export function newProviderRow({ id, url, model }) {
419
+ return {
420
+ id,
421
+ kind: "openai",
422
+ enabled: true,
423
+ url: String(url ?? ""),
424
+ defaultModel: String(model ?? "").trim(),
425
+ // Empty, not "everything the probe listed": a listed model is not a usable coder, and an
426
+ // allow-list full of untested names makes /model long and no more useful. More can be added
427
+ // by running this again or from the web.
428
+ models: [],
429
+ };
430
+ }
431
+
432
+ /**
433
+ * The WIRE ID for a freshly added provider's model — what the folder stores and sends.
434
+ *
435
+ * THE QUALIFICATION RULE IS THE API'S, AND GETTING IT WRONG HERE BREAKS THE NEXT TURN.
436
+ * codingModelChoiceList qualifies ids as `${rowId}::${model}` only when there is more than one
437
+ * backend to disambiguate; with exactly one it keeps the BARE model name, so that existing REPLs
438
+ * and stored picks keep resolving. So on an account that had NO backends, the provider just added
439
+ * is the only one, and storing "prov_x::gpt-4o" would be a pick resolveCodingModelOverride has
440
+ * never heard of — it is not an exact id, it is not a bare model name, and the turn fails with
441
+ * "not in your allowed coding models" on a provider that was configured seconds earlier.
442
+ *
443
+ * `backendCount` is the number of backends the account will have AFTER the save — enabled, with a
444
+ * URL, the same rows codingBackends() would return.
445
+ */
446
+ export function wireIdFor({ backendId, model, backendCount }) {
447
+ const m = String(model ?? "").trim();
448
+ if (!m) return "";
449
+ return Number(backendCount) > 1 ? `${backendId}::${m}` : m;
450
+ }
451
+
452
+ /** How many of these rows the API will actually offer models from. */
453
+ export function usableRowCount(rows) {
454
+ return (Array.isArray(rows) ? rows : []).filter(
455
+ (b) => b && b.enabled !== false && String(b.url ?? "").trim()
456
+ ).length;
457
+ }
458
+
459
+ /**
460
+ * The keys that may actually be used with a given provider (task #223).
461
+ *
462
+ * MIRRORS THE API'S ELIGIBILITY RULE (resolveCodingApiKey): keys bound to this backend, then
463
+ * UNBOUND ones — every key stored before providers could be told apart, and the derived legacy
464
+ * one, which remain usable anywhere. Bound first, so the row the picker highlights first is the
465
+ * one the API would actually resolve to.
466
+ *
467
+ * WHY THE PICKER MUST FILTER AT ALL. The API refuses to send a key belonging to another provider,
468
+ * so offering one here would be offering a choice that silently does nothing: the turn would run
469
+ * with a substituted key, or with none. A list that includes options which cannot be chosen is
470
+ * worse than a shorter list.
471
+ *
472
+ * NO backendId MEANS DO NOT FILTER. That is an API older than the field, and hiding keys on a
473
+ * guess would be worse than showing all of them, which is exactly what happened before.
474
+ */
475
+ export function keysForBackend(keys, backendId) {
476
+ const list = Array.isArray(keys) ? keys : [];
477
+ const want = String(backendId ?? "").trim();
478
+ if (!want) return list;
479
+ return [
480
+ ...list.filter((k) => k?.backendId === want),
481
+ ...list.filter((k) => !k?.backendId),
482
+ ];
483
+ }
484
+
485
+ /**
486
+ * The URL to offer when someone opens this flow (task #223 follow-up, reported by the user:
487
+ * "it says 'provider URL', please give default one, everyone know that right?").
488
+ *
489
+ * https://api.openai.com/v1 is the one address in this space that needs no research — it is what
490
+ * "OpenAI-compatible" is named after, and typing it out is pure ceremony for the commonest case.
491
+ *
492
+ * WHY THIS IS NOT THE CURATED TABLE I ARGUED AGAINST. A hardcoded MODEL list would be a whitelist
493
+ * that goes stale and rejects what the user actually has; this is one overtypeable default for a
494
+ * single field, offered before anything is known, and wrong at worst by one keystroke.
495
+ *
496
+ * RETURNS "" WHEN IT IS ALREADY CONFIGURED. Offering a default that will immediately be refused
497
+ * as a duplicate is worse than offering none — it invites an Enter press whose only outcome is an
498
+ * error message.
499
+ */
500
+ export const DEFAULT_PROVIDER_URL = "https://api.openai.com/v1";
501
+
502
+ export function defaultProviderUrl(existingRows = []) {
503
+ const taken = (Array.isArray(existingRows) ? existingRows : []).some(
504
+ (b) => ensureV1Root(String(b?.url ?? "")) === DEFAULT_PROVIDER_URL
505
+ );
506
+ return taken ? "" : DEFAULT_PROVIDER_URL;
507
+ }
508
+
509
+ /** The host of a stored URL, for display in a list of providers. Not the tag rule — just enough
510
+ * to tell two rows apart on screen when their models are both unhelpful. */
511
+ export function providerHost(url) {
512
+ try {
513
+ const u = new URL(String(url ?? ""));
514
+ return u.port ? `${u.hostname}:${u.port}` : u.hostname;
515
+ } catch {
516
+ return String(url ?? "");
517
+ }
518
+ }
519
+
520
+ /**
521
+ * The provider rows this flow may EDIT (task #223 follow-up).
522
+ *
523
+ * WHY THIS EXISTS AT ALL: the flow could add a provider and nothing could change one afterwards.
524
+ * A typo in the model id — reported, as "dfdf" — was permanent: /model offers the row but cannot
525
+ * edit it, re-adding the same URL is refused as a duplicate, and web Settings draws ONE CARD PER
526
+ * KIND, so a second openai row is not even visible there. Add-only was a dead end and this is the
527
+ * exit from it.
528
+ *
529
+ * OPENAI ROWS ONLY. An Ollama or MLX row is owned by its own flow ("change the Ollama server this
530
+ * machine uses…"), which sets URL, models and roles together; editing one field of it from here
531
+ * would half-configure a thing that has its own notion of complete.
532
+ */
533
+ export function manageableProviders(rows) {
534
+ const out = [];
535
+ const taken = new Set();
536
+ for (const b of Array.isArray(rows) ? rows : []) {
537
+ if (!b || typeof b !== "object") continue;
538
+ if (String(b.kind ?? "") !== "openai") continue;
539
+ const url = String(b.url ?? "").trim();
540
+ if (!url) continue;
541
+ // The same identity rule the API uses, so an id shown here is the id it will patch.
542
+ const id = String(b.id ?? "").trim() || (taken.has("openai") ? "" : "openai");
543
+ if (!id || taken.has(id)) continue;
544
+ taken.add(id);
545
+ out.push({
546
+ id,
547
+ url,
548
+ model: String(b.defaultModel ?? "").trim(),
549
+ host: providerHost(url),
550
+ enabled: b.enabled !== false,
551
+ });
552
+ }
553
+ return out;
554
+ }
555
+
556
+ /** Replace one row's default model, leaving every other row untouched. */
557
+ export function patchProviderModel(rows, id, model) {
558
+ const want = String(model ?? "").trim();
559
+ return (Array.isArray(rows) ? rows : []).map((b) =>
560
+ rowMatches(b, id) ? { ...b, defaultModel: want } : b
561
+ );
562
+ }
563
+
564
+ /** Replace one row's URL. The caller normalizes and clash-checks first. */
565
+ export function patchProviderUrl(rows, id, url) {
566
+ const want = String(url ?? "").trim();
567
+ return (Array.isArray(rows) ? rows : []).map((b) =>
568
+ rowMatches(b, id) ? { ...b, url: want } : b
569
+ );
570
+ }
571
+
572
+ /** Drop one row. */
573
+ export function removeProvider(rows, id) {
574
+ return (Array.isArray(rows) ? rows : []).filter((b) => !rowMatches(b, id));
575
+ }
576
+
577
+ /** A row is the one named when its id matches — or, for a row that carries none, when the id IS
578
+ * its kind, which is exactly how the API identifies a pre-#223 row. */
579
+ function rowMatches(b, id) {
580
+ if (!b || typeof b !== "object") return false;
581
+ const own = String(b.id ?? "").trim();
582
+ return own ? own === id : String(b.kind ?? "") === id;
583
+ }
584
+
585
+ /**
586
+ * The keys that would be STRANDED by removing a provider.
587
+ *
588
+ * They are not merely unused: a key bound to a row that no longer exists is ineligible for every
589
+ * provider AND filtered out of every picker, so it can never be selected, never be used, and
590
+ * never be deleted from the terminal again. Removing a provider therefore has to take its keys
591
+ * with it — and say so first, because deleting a credential is not undoable.
592
+ */
593
+ export function keysBoundTo(keys, backendId) {
594
+ const want = String(backendId ?? "").trim();
595
+ if (!want) return [];
596
+ return (Array.isArray(keys) ? keys : []).filter((k) => k?.backendId === want);
597
+ }
598
+
599
+ /**
600
+ * Which of a provider's models could plausibly WRITE CODE (task #223 follow-up).
601
+ *
602
+ * REPORTED with the list to prove it: api.openai.com reports 129 ids, and the picker offered all
603
+ * of them — embeddings, moderation, TTS, transcription, image, video, realtime, search. Not one
604
+ * of those speaks /v1/chat/completions, so picking one produces a coder that fails on its first
605
+ * turn, and finding the handful that work means reading a hundred rows.
606
+ *
607
+ * A HEURISTIC ON NAMES, AND THEREFORE NEVER A GATE. The caller must always offer the full list
608
+ * behind one more keypress: this cannot know what a private gateway calls its models, and a
609
+ * filter that hides the only usable model with no way back is worse than no filter. Same rule as
610
+ * the probe itself — a suggestion, not validation.
611
+ *
612
+ * EXCLUSIONS ONLY, NO ALLOW-LIST. "Which ids are coders" goes stale the day a provider ships a
613
+ * new family; "an id with `embedding` in it is not a chat model" does not.
614
+ */
615
+ const NOT_A_CHAT_MODEL = [
616
+ /embed/i,
617
+ /moderation/i,
618
+ /\btts\b|-tts|text-to-speech/i,
619
+ /whisper|transcribe|audio|realtime|voice/i,
620
+ /image|vision-preview|dall-?e|sora/i,
621
+ /search(-preview|-api)?$|-search-/i,
622
+ /rerank|guard|classifier/i,
623
+ // OpenAI's original completion endpoints — they take a prompt, not messages.
624
+ /^(davinci|babbage|curie|ada)(-|$)|instruct/i,
625
+ ];
626
+
627
+ export function codingModelCandidates(models) {
628
+ const all = (Array.isArray(models) ? models : []).filter((m) => typeof m === "string" && m.trim());
629
+ const kept = all.filter((m) => !NOT_A_CHAT_MODEL.some((re) => re.test(m)));
630
+ // EVERYTHING FILTERED OUT MEANS THE HEURISTIC IS WRONG FOR THIS SERVER, not that the server has
631
+ // no coders. Hand back the whole list rather than an empty picker.
632
+ if (kept.length === 0) return { models: all, filtered: 0 };
633
+ return { models: kept, filtered: all.length - kept.length };
634
+ }
635
+
636
+ /** One line when the provider itself said some of its models are finished (task #223 follow-up).
637
+ * Said out loud rather than silently shortening the list: a picker that quietly drops entries
638
+ * looks like a provider with fewer models, which is a different and wrong story. */
639
+ export function retiredNote(retired, host = "") {
640
+ const n = Number(retired) || 0;
641
+ if (n <= 0) return "";
642
+ return `${n} retired model${n === 1 ? "" : "s"} hidden${host ? ` — ${host} says they are shut down` : ""}`;
643
+ }
644
+
645
+ /**
646
+ * Does this look like an API key at all? (task #223 follow-up, asked for directly.)
647
+ *
648
+ * Returns { level: "" | "hint" | "warn", note } — a JUDGEMENT ABOUT SHAPE, never a network call
649
+ * and never a refusal. Only the provider can say whether a key is valid; all this can catch is
650
+ * the class of mistake that is obvious on sight: an empty box, "abc", "aaa", a placeholder left
651
+ * in from a README. Catching those at the prompt saves a turn that would otherwise die on a 401
652
+ * several steps in.
653
+ *
654
+ * A LOCAL SERVER IS EXEMPT FROM THE PLACEHOLDER RULES, and that is the important part. llama.cpp,
655
+ * vLLM, LM Studio and Ollama accept ANY string — "abc" is a perfectly good key there, and warning
656
+ * about it would be this function telling the user their working setup is wrong. Only the
657
+ * structural checks (blank, whitespace) apply to a LAN or loopback address.
658
+ *
659
+ * The `sk-` hint is deliberately the ONLY host-specific rule: api.openai.com is the default this
660
+ * flow now offers, so a key that cannot be one of its is worth a word. It is a hint, not a gate —
661
+ * a gateway proxying OpenAI may well issue its own format.
662
+ */
663
+ const PLACEHOLDER_KEYS =
664
+ /^(test|dummy|none|null|nil|abc|xyz|foo|bar|key|apikey|api[-_]?key|secret|password|changeme|placeholder|your[-_]?(api[-_]?)?key(here)?|<[^>]*>|x+|sk-x+|\.+|-+)$/i;
665
+
666
+ export function describeApiKeyShape(key, { host = "", locality = "" } = {}) {
667
+ const k = String(key ?? "");
668
+ const trimmed = k.trim();
669
+ if (!trimmed) return { level: "warn", note: "no key was entered" };
670
+ // STRUCTURAL, and true everywhere — a key with a space in it is nearly always a paste that
671
+ // took the surrounding text with it.
672
+ if (/\s/.test(k)) {
673
+ return { level: "warn", note: "that has whitespace in it — a pasted key usually should not" };
674
+ }
675
+ const local = locality === "loopback" || locality === "private";
676
+ if (local) return { level: "", note: "" };
677
+
678
+ if (PLACEHOLDER_KEYS.test(trimmed)) {
679
+ return { level: "warn", note: `"${trimmed}" looks like a placeholder, not a key` };
680
+ }
681
+ // One repeated character ("aaaa", "1111"), which no provider issues.
682
+ if (trimmed.length > 1 && new Set(trimmed).size === 1) {
683
+ return { level: "warn", note: "that is the same character repeated — not a key" };
684
+ }
685
+ if (trimmed.length < 16) {
686
+ return { level: "warn", note: `that is only ${trimmed.length} characters — keys are longer` };
687
+ }
688
+ if (/openai\.com$/i.test(host) && !/^sk-/.test(trimmed)) {
689
+ return { level: "hint", note: "OpenAI keys start with \"sk-\" — check you pasted the whole one" };
690
+ }
691
+ return { level: "", note: "" };
692
+ }
693
+
694
+ /**
695
+ * WHICH OF THE PROVIDERS WE JUST SAVED DID THE API NOT GIVE BACK? (reported live)
696
+ *
697
+ * "whenever i add or change an openai-compatible provider, it replace the old one in this list
698
+ * and only keep 1 for ollama default, one for openai-compatible, can we allow multiple ones".
699
+ *
700
+ * It was not a limit anyone chose. An API older than row ids identifies a backend by its KIND —
701
+ * its choice ids read `openai::gpt-5`, not `prov_9ez1q69s::gpt-5` — so a second openai row
702
+ * collides with the first and evicts it. The save answers 200 either way, and the add flow's
703
+ * existing check only asks whether the model it JUST added is offered, which it is. Nothing
704
+ * noticed that a different provider had disappeared, so the loss was silent and looked like a
705
+ * deliberate one-per-kind rule.
706
+ *
707
+ * COMPARED BY URL, not by id, deliberately. The id is exactly what the two API versions disagree
708
+ * about — ours are minted `prov_…`, an old one answers with the kind — so keying on it would
709
+ * report every row as lost against an old API and none against a new one. The URL is the one
710
+ * thing both versions carry and neither rewrites, beyond the /v1 normalising this module already
711
+ * owns.
712
+ *
713
+ * ROWS WITH NO MODEL ARE NOT LOST, they are empty: a backend offers one choice per model, so a
714
+ * row saved with nothing to pick from correctly produces no choice on ANY version. Counting it
715
+ * as lost would fire this warning on a perfectly good save.
716
+ *
717
+ * @param sentRows the rows just written (each {url, defaultModel?, models?})
718
+ * @param choices codingChoices as the API reports them AFTER the write
719
+ * @returns the URLs that went in and did not come back, in the order they were sent
720
+ */
721
+ export function providerUrlsLost(sentRows, choices) {
722
+ // EMPTY IN, EMPTY OUT. ensureV1Root("") answers "/v1" — a truthy path, not a URL — and feeding
723
+ // that into the set made one url-less choice look like a real backend, so every row that had
724
+ // actually been saved was then reported as evicted. Caught by the junk-input test.
725
+ const norm = (u) => {
726
+ const t = String(u ?? "").trim();
727
+ return t ? ensureV1Root(t).toLowerCase() : "";
728
+ };
729
+ const offered = new Set();
730
+ for (const c of Array.isArray(choices) ? choices : []) {
731
+ const u = norm(c?.url);
732
+ if (u) offered.add(u);
733
+ }
734
+ // Nothing came back at all: the API is unreachable or answered a shape we do not understand.
735
+ // That is not evidence of eviction, and guessing it is would turn a network blip into a
736
+ // confident claim about the user's data.
737
+ if (offered.size === 0) return [];
738
+ const out = [];
739
+ for (const r of Array.isArray(sentRows) ? sentRows : []) {
740
+ const hasModel =
741
+ String(r?.defaultModel ?? "").trim() !== "" ||
742
+ (Array.isArray(r?.models) && r.models.some((m) => String(m ?? "").trim()));
743
+ if (!hasModel) continue;
744
+ const u = norm(r?.url);
745
+ if (u && !offered.has(u) && !out.includes(u)) out.push(u);
746
+ }
747
+ return out;
748
+ }
749
+
750
+ /**
751
+ * Does this API still identify a backend by its KIND rather than by a row id?
752
+ *
753
+ * The tell is in the choices it returns: a new API reports `backendId` on every one. Used to
754
+ * explain WHY a provider was evicted, rather than only that it was — "restart your API" is an
755
+ * instruction, and an instruction without its reason is the kind of thing that gets ignored
756
+ * twice before someone acts on it.
757
+ */
758
+ export function apiKeysBackendsByKind(choices) {
759
+ const list = Array.isArray(choices) ? choices : [];
760
+ if (list.length === 0) return false;
761
+ return list.every((c) => !String(c?.backendId ?? "").trim());
762
+ }
763
+
764
+ /**
765
+ * WHICH PROVIDERS STOPPED BEING OFFERED ACROSS A SAVE? (reported live)
766
+ *
767
+ * The stronger half of providerUrlsLost, and the one that catches the case that actually bit:
768
+ * an eviction the client never had a chance to prevent.
769
+ *
770
+ * providerUrlsLost compares what we SENT against what came back, so it can only notice a row we
771
+ * knew about. The real loss was upstream of that — GET /api/worker/settings did not return
772
+ * agentCodingBackends at all, so existingBackendRows fell back to the single legacy row, the
773
+ * previously-added provider was never in `existing`, and the save wrote it out of existence. It
774
+ * was absent from the sent rows too, so nothing downstream could miss it.
775
+ *
776
+ * Comparing the API's OWN answer before and against after needs no such knowledge: a provider
777
+ * that was being offered and now is not has been lost, whoever dropped it and whatever version
778
+ * of the API is running. That makes this the check that survives an API older OR newer than this
779
+ * client.
780
+ *
781
+ * `expectUrl` is the provider being added — it is expected to appear, never to disappear, and
782
+ * passing it keeps a re-save of the SAME provider (a model change) from reporting itself.
783
+ */
784
+ export function providersNoLongerOffered(before, after, expectUrl = "") {
785
+ const norm = (u) => {
786
+ const t = String(u ?? "").trim();
787
+ return t ? ensureV1Root(t).toLowerCase() : "";
788
+ };
789
+ const urls = (list) => {
790
+ const out = new Set();
791
+ for (const c of Array.isArray(list) ? list : []) {
792
+ const u = norm(c?.url);
793
+ if (u) out.add(u);
794
+ }
795
+ return out;
796
+ };
797
+ const was = urls(before);
798
+ const now = urls(after);
799
+ // NOTHING BEFORE, or nothing after: no comparison is possible. A failed probe must not be
800
+ // reported as the user's providers having been deleted.
801
+ if (was.size === 0 || now.size === 0) return [];
802
+ const keep = norm(expectUrl);
803
+ const gone = [];
804
+ for (const u of was) {
805
+ if (u !== keep && !now.has(u)) gone.push(u);
806
+ }
807
+ return gone;
808
+ }