@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.
- package/README.md +52 -0
- package/agent-model-command.mjs +259 -0
- package/agent-model-label.mjs +159 -0
- package/clear-state.mjs +149 -0
- package/client-expert-api.mjs +736 -0
- package/client-expert-run.mjs +892 -0
- package/client-expert-setup.mjs +616 -0
- package/coding-choice-tags.mjs +69 -0
- package/coding-key-prompt.mjs +229 -0
- package/coding-provider-setup.mjs +808 -0
- package/completed-flush.mjs +105 -0
- package/daemon-control.mjs +462 -0
- package/device-login.mjs +212 -0
- package/doctor-check.mjs +239 -0
- package/embed-model-command.mjs +157 -0
- package/first-run-steps.mjs +171 -0
- package/gonext_agent_chat.py +12299 -0
- package/gonext_mlx_embed.py +155 -0
- package/gonext_probe_agent.py +93 -0
- package/gonext_transcribe.py +130 -0
- package/gu-cli.mjs +4930 -0
- package/gu-repl.mjs +10326 -0
- package/job-pools.mjs +89 -0
- package/model-doctor.mjs +1494 -0
- package/node-version.mjs +40 -0
- package/ollama-setup.mjs +832 -0
- package/package.json +100 -0
- package/platform-tools.mjs +520 -0
- package/poll-errors.mjs +141 -0
- package/proxy-command.mjs +165 -0
- package/proxy-config.mjs +255 -0
- package/proxy-dispatcher.mjs +132 -0
- package/proxy-selftest.mjs +234 -0
- package/proxy-store.mjs +69 -0
- package/rag-job-config.mjs +59 -0
- package/rag-selftest.mjs +215 -0
- package/s3-setup.mjs +85 -0
- package/terminal-copy.mjs +248 -0
- package/terminal-hover.mjs +153 -0
- package/terminal-layout.mjs +2507 -0
- package/terminal-viewport.mjs +602 -0
- package/thinking_words.txt +1003 -0
- 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
|
+
}
|