@tiens.nguyen/gu-cli 1.0.721 → 1.0.722
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/add-server.mjs +207 -0
- package/agent-model-command.mjs +3 -1
- package/coding-provider-setup.mjs +23 -0
- package/gonext_agent_chat.py +15 -13
- package/gu-repl.mjs +608 -831
- package/package.json +3 -2
- package/server-keys.mjs +97 -0
- package/ollama-host-snippets.mjs +0 -36
package/gu-repl.mjs
CHANGED
|
@@ -494,11 +494,11 @@ const fmtTokens = (n) => {
|
|
|
494
494
|
*/
|
|
495
495
|
const PICK_HINT = "↑/↓ then Enter · Esc to cancel";
|
|
496
496
|
const PICK_MANY_HINT = "↑/↓ · space toggles · ctrl+a all · Enter saves · Esc cancels";
|
|
497
|
-
// How many EXTRA
|
|
498
|
-
//
|
|
499
|
-
//
|
|
500
|
-
//
|
|
501
|
-
const MAX_ALT_CODERS =
|
|
497
|
+
// How many EXTRA models one server offers alongside its default. Task #245 made it a rule, not a
|
|
498
|
+
// readability cap: a server URL offers AT MOST 3 models, default included (MAX_MODELS_PER_SERVER
|
|
499
|
+
// in coding-provider-setup.mjs and api/src/store/settingsTypes.ts — the API refuses a row that
|
|
500
|
+
// grows past it). A server saved with more before the rule keeps them: see extraModelRoom.
|
|
501
|
+
const MAX_ALT_CODERS = 2;
|
|
502
502
|
const BULLET = "●"; // a neutral completed action (informational step)
|
|
503
503
|
const CHECK = "✓"; // a step that succeeded
|
|
504
504
|
const CROSS = "✗"; // a step that FAILED
|
|
@@ -4542,6 +4542,46 @@ async function saveSessionCodingKeyId(cwd) {
|
|
|
4542
4542
|
await persistCodingChoiceToServer(cwd);
|
|
4543
4543
|
}
|
|
4544
4544
|
|
|
4545
|
+
/** This folder's own key for a role (task #245), from the session-file cache. */
|
|
4546
|
+
async function loadSessionFolderKeyId(cwd, role) {
|
|
4547
|
+
const { roleOf } = await import("./server-keys.mjs");
|
|
4548
|
+
try {
|
|
4549
|
+
const raw = JSON.parse(await readFile(sessionFilePath(cwd), "utf8"));
|
|
4550
|
+
const v = raw?.[roleOf(role).field];
|
|
4551
|
+
return typeof v === "string" ? v.trim() : "";
|
|
4552
|
+
} catch {
|
|
4553
|
+
return "";
|
|
4554
|
+
}
|
|
4555
|
+
}
|
|
4556
|
+
|
|
4557
|
+
/**
|
|
4558
|
+
* THIS FOLDER'S OWN KEY FOR ONE ROLE (task #245) — the one place a folder's pick is written, for
|
|
4559
|
+
* all three roles. The session file is the offline cache; the database is the record, and its
|
|
4560
|
+
* write carries ONLY this role's field, which the API merges — so picking the agent's key can
|
|
4561
|
+
* never wipe the coder's. "" = back to the server's default.
|
|
4562
|
+
*/
|
|
4563
|
+
async function saveFolderKeyPick(role, id) {
|
|
4564
|
+
const { roleOf } = await import("./server-keys.mjs");
|
|
4565
|
+
const field = roleOf(role).field;
|
|
4566
|
+
if (role === "agent") sessionAgentKeyId = id;
|
|
4567
|
+
else if (role === "embed") sessionEmbedKeyId = id;
|
|
4568
|
+
else sessionCodingKeyId = id;
|
|
4569
|
+
const cwd = resolve(process.cwd());
|
|
4570
|
+
try {
|
|
4571
|
+
await updateSessionFile(cwd, (raw) => {
|
|
4572
|
+
raw[field] = id;
|
|
4573
|
+
raw.updatedAt = new Date().toISOString();
|
|
4574
|
+
});
|
|
4575
|
+
} catch (err) {
|
|
4576
|
+
console.error(dim(`(key choice save failed: ${err.message})`));
|
|
4577
|
+
}
|
|
4578
|
+
try {
|
|
4579
|
+
await saveCodingChoice(folderHashFor(cwd), { [field]: id });
|
|
4580
|
+
} catch (err) {
|
|
4581
|
+
console.error(dim(`(key choice could not reach the database: ${err.message})`));
|
|
4582
|
+
}
|
|
4583
|
+
}
|
|
4584
|
+
|
|
4545
4585
|
async function loadSessionCodingKeyId(cwd) {
|
|
4546
4586
|
try {
|
|
4547
4587
|
const raw = JSON.parse(await readFile(sessionFilePath(cwd), "utf8"));
|
|
@@ -5053,7 +5093,7 @@ async function reconfigureOllamaServer() {
|
|
|
5053
5093
|
const ticks = await pickManyFromList(
|
|
5054
5094
|
rest.map((m) => `${m.model}${am.formatSize(m.sizeBytes) ? dim(` ${am.formatSize(m.sizeBytes)}`) : ""}`),
|
|
5055
5095
|
rest.map((m, i) => (already.has(m.model) ? i : -1)).filter((i) => i >= 0),
|
|
5056
|
-
{ max: MAX_ALT_CODERS }
|
|
5096
|
+
{ max: Math.max(MAX_ALT_CODERS, rest.filter((m) => already.has(m.model)).length) }
|
|
5057
5097
|
);
|
|
5058
5098
|
// null is Escape and cancels the command; an EMPTY list is a legitimate "offer none".
|
|
5059
5099
|
if (ticks === null) {
|
|
@@ -5119,12 +5159,14 @@ async function reconfigureOllamaServer() {
|
|
|
5119
5159
|
const keys = await fetchCodingKeys();
|
|
5120
5160
|
const ps = await import("./coding-provider-setup.mjs");
|
|
5121
5161
|
for (const u of cloudUrls) {
|
|
5122
|
-
const
|
|
5123
|
-
|
|
5124
|
-
.find((b) => ps.ensureV1Root(String(b.url ?? "")).toLowerCase() === ps.ensureV1Root(u).toLowerCase());
|
|
5125
|
-
if (row && !am.boundKeyFor(keys, row.id)) {
|
|
5162
|
+
const found = ps.providerRowForUrl(ps.existingBackendRows(fresh ?? {}), u);
|
|
5163
|
+
if (found && !am.boundKeyFor(keys, found.id)) {
|
|
5126
5164
|
console.log(yellow(` ${hostOf(u)} is the Ollama cloud — it needs its own API key (the model list worked without one).`));
|
|
5127
|
-
|
|
5165
|
+
// The shared key prompt (task #245); the server's first key becomes its default.
|
|
5166
|
+
const { name, secret } = await askServerKey({ host: hostOf(u) });
|
|
5167
|
+
if (secret && !(await withPendingStatus("saving the key", () => addCodingKey(name, secret, found.id)))) {
|
|
5168
|
+
console.log(red(" couldn't save the key — add it with ctrl+k.\n"));
|
|
5169
|
+
}
|
|
5128
5170
|
}
|
|
5129
5171
|
}
|
|
5130
5172
|
}
|
|
@@ -5234,7 +5276,8 @@ async function pickExtraModels(rest, preChecked = []) {
|
|
|
5234
5276
|
const ticks = await pickManyFromList(
|
|
5235
5277
|
rest,
|
|
5236
5278
|
rest.map((m, i) => (already.has(m) ? i : -1)).filter((i) => i >= 0),
|
|
5237
|
-
|
|
5279
|
+
// A server saved with more than the cap before #245 keeps them; it just cannot grow.
|
|
5280
|
+
{ max: Math.max(MAX_ALT_CODERS, rest.filter((m) => already.has(m)).length) }
|
|
5238
5281
|
);
|
|
5239
5282
|
// null is Escape and cancels; an EMPTY list is a legitimate "offer none".
|
|
5240
5283
|
if (ticks === null) return null;
|
|
@@ -5323,7 +5366,7 @@ async function manageCodingProviders() {
|
|
|
5323
5366
|
const providers = ps.manageableProviders(rows);
|
|
5324
5367
|
// NOTHING TO MANAGE IS NOT A MENU. Straight to the add flow, exactly as before.
|
|
5325
5368
|
if (providers.length === 0) {
|
|
5326
|
-
await
|
|
5369
|
+
await addServer({ role: "coding", dialect: "openai" });
|
|
5327
5370
|
return;
|
|
5328
5371
|
}
|
|
5329
5372
|
|
|
@@ -5340,7 +5383,7 @@ async function manageCodingProviders() {
|
|
|
5340
5383
|
return;
|
|
5341
5384
|
}
|
|
5342
5385
|
if (idx === ADD) {
|
|
5343
|
-
await
|
|
5386
|
+
await addServer({ role: "coding", dialect: "openai" });
|
|
5344
5387
|
return;
|
|
5345
5388
|
}
|
|
5346
5389
|
|
|
@@ -5539,324 +5582,292 @@ async function removeCodingProvider(ps, rows, p) {
|
|
|
5539
5582
|
}
|
|
5540
5583
|
|
|
5541
5584
|
/**
|
|
5542
|
-
*
|
|
5543
|
-
*
|
|
5544
|
-
*
|
|
5545
|
-
*
|
|
5546
|
-
*
|
|
5547
|
-
*
|
|
5548
|
-
*
|
|
5549
|
-
*
|
|
5550
|
-
*
|
|
5551
|
-
*
|
|
5552
|
-
*
|
|
5553
|
-
*
|
|
5554
|
-
*
|
|
5555
|
-
*
|
|
5556
|
-
*
|
|
5557
|
-
*
|
|
5558
|
-
*
|
|
5559
|
-
*
|
|
5560
|
-
*
|
|
5561
|
-
*
|
|
5562
|
-
*
|
|
5585
|
+
* ADD A MODEL SERVER — ONE SCREEN FOR THE CODER, THE AGENT AND THE EMBEDDER (task #245).
|
|
5586
|
+
*
|
|
5587
|
+
* Replaces three screens that asked the same things three ways (/model's add-provider,
|
|
5588
|
+
* /agent-model's Ollama server, /embed-model's Ollama and provider arms). Same questions, same
|
|
5589
|
+
* order, every role:
|
|
5590
|
+
*
|
|
5591
|
+
* 1. the server's URL — forgiving about what is pasted, and says what it used;
|
|
5592
|
+
* 2. its key — REUSED when the server already has one (a key belongs to the server, not the
|
|
5593
|
+
* model), asked only when the server needs one (an Ollama on this machine or the LAN never
|
|
5594
|
+
* does; a local vLLM may skip it). The FIRST key entered is what lists the models;
|
|
5595
|
+
* 3. its models, listed WITH that key — a real round trip, so a wrong URL or key fails HERE
|
|
5596
|
+
* rather than mid-turn. A refused key saves nothing;
|
|
5597
|
+
* 4. the model — coders filtered, embedders first, the agent sees everything (add-server.mjs);
|
|
5598
|
+
* 5. save the server row, then its key (the API makes a server's first key its default), then
|
|
5599
|
+
* the role's own setting.
|
|
5600
|
+
*
|
|
5601
|
+
* What differs per role or per server kind is decided in add-server.mjs, not here.
|
|
5602
|
+
*
|
|
5603
|
+
* role "coding" | "agent" | "embed"
|
|
5604
|
+
* dialect "openai" (an OpenAI-compatible /v1 server) | "ollama" (an Ollama: /api/tags)
|
|
5605
|
+
* url skip the URL question (a server picked from a list, or the embedder's current one)
|
|
5606
|
+
* model skip listing and picking (/embed-model's curated list already chose; `plan` with it)
|
|
5607
|
+
* newKey ask for a key even if the server has one (the stored one was refused)
|
|
5563
5608
|
*/
|
|
5564
|
-
|
|
5565
|
-
|
|
5566
|
-
noun: "coding model",
|
|
5567
|
-
question: "Which model writes the code?",
|
|
5568
|
-
/** Retired ids dropped; the caller also filters non-coding ones in the picker. */
|
|
5569
|
-
parse: (ps, am, body) => ps.parseProviderModels(body),
|
|
5570
|
-
/** Extra models populate the row so /model can offer several from one provider. */
|
|
5571
|
-
extras: true,
|
|
5572
|
-
},
|
|
5573
|
-
agent: {
|
|
5574
|
-
noun: "agent model",
|
|
5575
|
-
question: "Which model talks?",
|
|
5576
|
-
// Nothing hidden — retired and non-chat ids are SUNK and LABELLED by agentModelsFromProvider.
|
|
5577
|
-
// Shaped like parseProviderModels' answer so the caller needs no branch.
|
|
5578
|
-
parse: (ps, am, body) => {
|
|
5579
|
-
const rows = am.agentModelsFromProvider(body);
|
|
5580
|
-
return { models: rows.map((r) => r.model), why: rows.length ? "" : "empty", total: rows.length, retired: 0 };
|
|
5581
|
-
},
|
|
5582
|
-
// The agent uses ONE model per machine; /agent-model probes the provider live every time, so
|
|
5583
|
-
// there is nothing an extras list would add.
|
|
5584
|
-
extras: false,
|
|
5585
|
-
},
|
|
5586
|
-
};
|
|
5587
|
-
|
|
5588
|
-
async function addCodingProvider(roleName = "coding") {
|
|
5589
|
-
const role = PROVIDER_ROLES[roleName] ?? PROVIDER_ROLES.coding;
|
|
5609
|
+
async function addServer({ role = "coding", dialect = "openai", url: presetUrl = "", model: presetModel = "", plan: presetPlan = null, newKey = false } = {}) {
|
|
5610
|
+
const as = await import("./add-server.mjs");
|
|
5590
5611
|
const ps = await import("./coding-provider-setup.mjs");
|
|
5591
|
-
const
|
|
5612
|
+
const sk = await import("./server-keys.mjs");
|
|
5592
5613
|
const md = await import("./model-doctor.mjs");
|
|
5593
5614
|
const { proxyFetch } = await import("./proxy-dispatcher.mjs");
|
|
5615
|
+
const R = as.serverRole(role);
|
|
5594
5616
|
|
|
5595
5617
|
const settings = await withPendingStatus("reading your model settings", fetchWorkerSettings);
|
|
5596
5618
|
if (!settings) {
|
|
5597
|
-
console.log(red(" couldn
|
|
5619
|
+
console.log(red(" couldn't reach the API to read your current settings.\n"));
|
|
5598
5620
|
return;
|
|
5599
5621
|
}
|
|
5600
5622
|
// EVERY ROW THAT IS ALREADY THERE, including the synthesized one a pre-#123 account keeps in
|
|
5601
|
-
// its legacy fields — writing an array without it would make
|
|
5602
|
-
// vanish from /model while its legacy fields sat there unread.
|
|
5623
|
+
// its legacy fields — writing an array without it would make that coder vanish from /model.
|
|
5603
5624
|
const existing = ps.existingBackendRows(settings);
|
|
5604
|
-
//
|
|
5605
|
-
//
|
|
5606
|
-
|
|
5607
|
-
|
|
5608
|
-
|
|
5609
|
-
// them on every API version, so a before/after comparison notices a loss that this client had
|
|
5610
|
-
// no way to prevent. Failure is not fatal — it only means the check below stays quiet.
|
|
5611
|
-
const beforeChoices = (
|
|
5612
|
-
await fetchAgentPayload([{ role: "user", content: "ping" }]).catch(() => null)
|
|
5613
|
-
)?.codingChoices ?? null;
|
|
5614
|
-
if (existing.length >= ps.MAX_BACKENDS) {
|
|
5615
|
-
console.log(yellow(` ${ps.MAX_BACKENDS} coding backends is the limit — remove one first.`));
|
|
5616
|
-
console.log(GUTTER + dim("web Settings → Agent lists them; unchecking one frees a slot.\n"));
|
|
5617
|
-
return;
|
|
5618
|
-
}
|
|
5619
|
-
|
|
5620
|
-
// OFFER THE OBVIOUS ANSWER. Reported after using this: "it says 'provider URL', please give
|
|
5621
|
-
// default one, everyone know that right?" — https://api.openai.com/v1 is the address this whole
|
|
5622
|
-
// category is named after. It is "" when that provider is already configured, because a default
|
|
5623
|
-
// whose only possible outcome is "already configured here" invites a pointless Enter.
|
|
5624
|
-
const dfltUrl = ps.defaultProviderUrl(existing);
|
|
5625
|
-
console.log(dim(" Any OpenAI-compatible endpoint: OpenRouter, Together, vLLM, LM Studio, …"));
|
|
5626
|
-
// THE ADDRESS GOES ABOVE, IN FULL, and the input row keeps a SHORT label. The frozen row is too
|
|
5627
|
-
// narrow to hold a URL without truncating exactly the part worth reading — the same reason the
|
|
5628
|
-
// Ollama server prompt puts its default on the line above (task #220 follow-up).
|
|
5629
|
-
//
|
|
5630
|
-
// …AND WHAT ENTER DOES IS SAID OUT LOUD. It used to cancel; it now accepts the default, and a
|
|
5631
|
-
// prompt whose Enter key quietly changed meaning is the bug fixed in 46d1524e. Nothing is saved
|
|
5632
|
-
// until the end of the flow either way, so an accidental Enter costs a question, not a provider.
|
|
5633
|
-
// ESC IS THE CANCEL, and it is named here because a prompt that does not say so has no
|
|
5634
|
-
// cancel as far as the reader is concerned — `type "no"` was a password you had to already
|
|
5635
|
-
// know, and it was the only prompt in this flow that offered anything at all. Typing "no"
|
|
5636
|
-
// still works (it is in fingers, and the tests pin it); it is simply no longer the only way
|
|
5637
|
-
// out, and no longer the one advertised.
|
|
5638
|
-
console.log(
|
|
5639
|
-
dim(dfltUrl ? ` Enter uses ${dfltUrl} · Esc cancels\n` : " Esc cancels.\n")
|
|
5640
|
-
);
|
|
5625
|
+
// What /model offers BEFORE, so a save that silently evicts a provider can be noticed (an API
|
|
5626
|
+
// older than row ids keys a backend by its KIND). Coding only — it is the list that can shrink.
|
|
5627
|
+
const beforeChoices = role === "coding"
|
|
5628
|
+
? ((await fetchAgentPayload([{ role: "user", content: "ping" }]).catch(() => null))?.codingChoices ?? null)
|
|
5629
|
+
: null;
|
|
5641
5630
|
|
|
5642
|
-
// --- 1. the
|
|
5643
|
-
let
|
|
5644
|
-
|
|
5645
|
-
|
|
5646
|
-
|
|
5647
|
-
|
|
5648
|
-
if (/^(no|n|skip|-)$/i.test(typed)) {
|
|
5649
|
-
console.log(dim(" (cancelled)\n"));
|
|
5650
|
-
return;
|
|
5651
|
-
}
|
|
5652
|
-
// Enter with nothing typed takes the default — or cancels when there is none to take.
|
|
5653
|
-
const raw = typed || dfltUrl;
|
|
5654
|
-
if (!raw) {
|
|
5655
|
-
console.log(dim(" (cancelled)\n"));
|
|
5656
|
-
return;
|
|
5657
|
-
}
|
|
5658
|
-
const r = ps.normalizeProviderUrl(raw);
|
|
5659
|
-
if (!r.ok) {
|
|
5660
|
-
for (const l of ps.describeUrlProblem(r.why, raw)) console.log(GUTTER + dim(l));
|
|
5661
|
-
continue;
|
|
5662
|
-
}
|
|
5663
|
-
// ALREADY CONFIGURED is its own answer. Saving a second row for the same URL would give the
|
|
5664
|
-
// picker two rows that are identical in everything a person can see.
|
|
5665
|
-
const clash = existing.find((b) => ps.ensureV1Root(String(b.url ?? "")) === r.url);
|
|
5666
|
-
if (clash && roleName === "agent") {
|
|
5667
|
-
// FOR THE AGENT, "already configured" IS THE ANSWER, NOT A REFUSAL (#40). The provider and
|
|
5668
|
-
// its key may well be there already; what is missing is the agent pointing at it. Sending the
|
|
5669
|
-
// user to /model — the CODING picker — left no way to finish from here.
|
|
5670
|
-
console.log(GUTTER + dim(`${r.host} is already configured — using it for the agent.`));
|
|
5671
|
-
await pointAgentAtProvider(clash, r.host);
|
|
5631
|
+
// --- 1. the server --------------------------------------------------------------------
|
|
5632
|
+
let target = null;
|
|
5633
|
+
if (presetUrl) {
|
|
5634
|
+
target = as.serverTarget(dialect, presetUrl);
|
|
5635
|
+
if (!target.ok) {
|
|
5636
|
+
console.log(red(` "${presetUrl}" is ${target.why} — nothing changed.\n`));
|
|
5672
5637
|
return;
|
|
5673
5638
|
}
|
|
5674
|
-
|
|
5675
|
-
|
|
5676
|
-
|
|
5639
|
+
} else {
|
|
5640
|
+
const dflt = as.defaultServerUrl(role, dialect, settings, existing);
|
|
5641
|
+
console.log(dim(dialect === "ollama"
|
|
5642
|
+
? " An Ollama server: this machine, a box on your network, or https://ollama.com (needs a key)."
|
|
5643
|
+
: " Any OpenAI-compatible endpoint: OpenRouter, Together, vLLM, LM Studio, …"));
|
|
5644
|
+
// The address goes ABOVE, in full, and Enter's meaning is said out loud (46d1524e).
|
|
5645
|
+
console.log(dim(dflt ? ` Enter uses ${dflt} · Esc cancels\n` : " Esc cancels.\n"));
|
|
5646
|
+
for (let attempt = 0; attempt < 3 && !target; attempt++) {
|
|
5647
|
+
const typed = await ask(" server URL: ");
|
|
5648
|
+
if (typed === null || /^(no|n|skip|-)$/i.test(typed.trim())) break;
|
|
5649
|
+
const raw = typed.trim() || dflt;
|
|
5650
|
+
if (!raw) break;
|
|
5651
|
+
const t = as.serverTarget(dialect, raw);
|
|
5652
|
+
if (!t.ok) {
|
|
5653
|
+
if (dialect === "openai") for (const l of ps.describeUrlProblem(t.why, raw)) console.log(GUTTER + dim(l));
|
|
5654
|
+
else console.log(GUTTER + dim(`"${raw}" is ${t.why} — try host:port, e.g. 127.0.0.1:11434`));
|
|
5655
|
+
continue;
|
|
5656
|
+
}
|
|
5657
|
+
// SAY WHAT WE ASSUMED: a scheme we supplied or a path we stripped is a guess, and a wrong
|
|
5658
|
+
// guess shows up later as a timeout that reads like a dead server.
|
|
5659
|
+
if (t.root !== raw && t.url !== raw) console.log(GUTTER + dim(`using ${t.root}`));
|
|
5660
|
+
target = t;
|
|
5677
5661
|
}
|
|
5678
|
-
url = r.url;
|
|
5679
|
-
host = r.host;
|
|
5680
|
-
locality = r.locality;
|
|
5681
|
-
// SAY WHAT WE ASSUMED. A scheme we supplied is a guess, and a wrong guess shows up later as
|
|
5682
|
-
// a timeout that reads like a dead server.
|
|
5683
|
-
if (r.schemeAssumed || r.url !== raw) console.log(GUTTER + dim(`using ${r.url}`));
|
|
5684
|
-
break;
|
|
5685
5662
|
}
|
|
5686
|
-
if (!
|
|
5663
|
+
if (!target) {
|
|
5687
5664
|
console.log(dim(" (nothing saved)\n"));
|
|
5688
5665
|
return;
|
|
5689
5666
|
}
|
|
5667
|
+
const host = target.host;
|
|
5690
5668
|
|
|
5691
|
-
// --- 2.
|
|
5692
|
-
//
|
|
5693
|
-
//
|
|
5694
|
-
|
|
5695
|
-
|
|
5696
|
-
|
|
5697
|
-
|
|
5698
|
-
|
|
5699
|
-
console.log(GUTTER + dim("(cancelled — nothing was saved)\n"));
|
|
5669
|
+
// --- 2. the server's key --------------------------------------------------------------
|
|
5670
|
+
// AN EXISTING SERVER IS REUSED, never stored twice (two rows for one URL would be two picker
|
|
5671
|
+
// entries identical in everything a person can see) — its keys come with it.
|
|
5672
|
+
const found = ps.providerRowForUrl(existing, target.url);
|
|
5673
|
+
// THE ROW CAP BEFORE ANY QUESTION about a new server: exceeding it is a silent 200 with no row,
|
|
5674
|
+
// and asking for a key and a model first would be the worst order to discover that in.
|
|
5675
|
+
if (!found && existing.length >= ps.MAX_BACKENDS) {
|
|
5676
|
+
console.log(yellow(` ${ps.MAX_BACKENDS} servers is the limit — remove one in /model → manage providers first.\n`));
|
|
5700
5677
|
return;
|
|
5701
5678
|
}
|
|
5702
|
-
|
|
5703
|
-
|
|
5704
|
-
|
|
5705
|
-
|
|
5706
|
-
|
|
5707
|
-
|
|
5708
|
-
|
|
5709
|
-
|
|
5710
|
-
|
|
5711
|
-
if (
|
|
5712
|
-
|
|
5713
|
-
|
|
5714
|
-
}
|
|
5679
|
+
const need = as.keyRequirement(target);
|
|
5680
|
+
let key = "";
|
|
5681
|
+
let keyName = "";
|
|
5682
|
+
let typedKey = false;
|
|
5683
|
+
if (found && !newKey) {
|
|
5684
|
+
// A KEY BELONGS TO THE SERVER: one already saved is used, whichever model it was saved for.
|
|
5685
|
+
const keys = await withPendingStatus(`reading the keys for ${host}`, () => fetchServerKeys(found.id));
|
|
5686
|
+
const folderKeyId = role === "agent" ? sessionAgentKeyId : role === "embed" ? sessionEmbedKeyId : sessionCodingKeyId;
|
|
5687
|
+
const inUse = sk.keyInUse(keys ?? [], found.id, folderKeyId);
|
|
5688
|
+
if (inUse && inUse.backendId === found.id) {
|
|
5689
|
+
key = (await fetchCodingKey(found.id)).key;
|
|
5690
|
+
if (key) console.log(GUTTER + dim(`using the key "${inUse.name}" already saved for ${host}.`));
|
|
5691
|
+
}
|
|
5692
|
+
}
|
|
5693
|
+
const askKey = async () => {
|
|
5694
|
+
const t = await askServerKey({ host, optional: need === "optional" });
|
|
5695
|
+
key = t.secret;
|
|
5696
|
+
keyName = t.name;
|
|
5697
|
+
typedKey = Boolean(t.secret);
|
|
5698
|
+
return t.secret || need !== "required";
|
|
5699
|
+
};
|
|
5700
|
+
if (!key && (need !== "none" || newKey)) {
|
|
5701
|
+
if (need === "required") console.log(GUTTER + dim(`${host} needs an API key.`));
|
|
5702
|
+
if (!(await askKey())) return;
|
|
5715
5703
|
}
|
|
5716
5704
|
|
|
5717
|
-
// ---
|
|
5718
|
-
// A REAL ROUND TRIP, not a ping (the /doctor lesson): a bad URL or key has to fail HERE, where
|
|
5719
|
-
// it is one line of explanation, rather than mid-turn as a coder that silently dropped to the
|
|
5720
|
-
// chat model. A 401 at this point is the single most useful answer this flow can give.
|
|
5721
|
-
const probe = await withPendingStatus(`asking ${host} what it has`, async () => {
|
|
5722
|
-
try {
|
|
5723
|
-
const res = await proxyFetch(ps.modelsUrlFor(url), {
|
|
5724
|
-
headers: { Authorization: `Bearer ${secret}` },
|
|
5725
|
-
signal: AbortSignal.timeout(12_000),
|
|
5726
|
-
});
|
|
5727
|
-
if (!res.ok) return { status: res.status };
|
|
5728
|
-
return { body: await res.json().catch(() => null) };
|
|
5729
|
-
} catch (err) {
|
|
5730
|
-
return { err };
|
|
5731
|
-
}
|
|
5732
|
-
});
|
|
5705
|
+
// --- 3. its models, listed with that key ----------------------------------------------
|
|
5733
5706
|
let models = [];
|
|
5734
|
-
if (
|
|
5735
|
-
const
|
|
5736
|
-
|
|
5737
|
-
|
|
5738
|
-
|
|
5739
|
-
|
|
5740
|
-
|
|
5741
|
-
|
|
5742
|
-
|
|
5743
|
-
|
|
5744
|
-
|
|
5745
|
-
|
|
5746
|
-
|
|
5747
|
-
|
|
5748
|
-
|
|
5749
|
-
|
|
5750
|
-
|
|
5707
|
+
if (!presetModel) {
|
|
5708
|
+
const list = () =>
|
|
5709
|
+
withPendingStatus(`asking ${host} what it has`, async () => {
|
|
5710
|
+
const req = as.listRequest(target, key);
|
|
5711
|
+
try {
|
|
5712
|
+
const res = await proxyFetch(req.url, { headers: req.headers, signal: AbortSignal.timeout(12_000) });
|
|
5713
|
+
return res.ok ? { body: await res.json().catch(() => null) } : { status: res.status };
|
|
5714
|
+
} catch (err) {
|
|
5715
|
+
return { err };
|
|
5716
|
+
}
|
|
5717
|
+
});
|
|
5718
|
+
let probe = await list();
|
|
5719
|
+
// THE STORED KEY WAS REFUSED — ask for the right one once, rather than saving nothing.
|
|
5720
|
+
if ((probe.status === 401 || probe.status === 403) && !typedKey && key) {
|
|
5721
|
+
console.log(yellow(` ${host} refused the stored key (HTTP ${probe.status}) — enter the right one.`));
|
|
5722
|
+
if (!(await askKey()) || !key) return;
|
|
5723
|
+
probe = await list();
|
|
5724
|
+
}
|
|
5725
|
+
if (probe.status || probe.err) {
|
|
5726
|
+
const d = ps.describeProbeFailure(probe);
|
|
5727
|
+
for (const l of d.lines) console.log(GUTTER + (d.fatal ? red(l) : yellow(l)));
|
|
5728
|
+
// FATAL = THE KEY WAS REFUSED: storing a credential the server already rejected is storing
|
|
5729
|
+
// a problem for later. Anything else is survivable — the model id can be typed.
|
|
5730
|
+
if (d.fatal) {
|
|
5731
|
+
console.log(GUTTER + dim("nothing was saved — check the key and try again.\n"));
|
|
5732
|
+
return;
|
|
5733
|
+
}
|
|
5734
|
+
console.log(GUTTER + dim("carrying on — you can type the model id yourself."));
|
|
5735
|
+
} else {
|
|
5736
|
+
const parsed = as.modelsForRole(role, target.dialect, probe.body);
|
|
5737
|
+
models = parsed.models;
|
|
5738
|
+
for (const l of ps.describeModelProblem(parsed.why, { host, total: parsed.total })) console.log(GUTTER + dim(l));
|
|
5739
|
+
const rn = ps.retiredNote(parsed.retired, host);
|
|
5740
|
+
if (rn) console.log(GUTTER + dim(rn));
|
|
5741
|
+
if (models.length) console.log(GUTTER + dim(`${host} offers ${models.length} model${models.length === 1 ? "" : "s"}`));
|
|
5751
5742
|
}
|
|
5752
|
-
|
|
5753
|
-
|
|
5754
|
-
|
|
5755
|
-
|
|
5743
|
+
}
|
|
5744
|
+
|
|
5745
|
+
// --- 4. the model ---------------------------------------------------------------------
|
|
5746
|
+
let model = presetModel;
|
|
5747
|
+
const ec = role === "embed" ? await import("./embed-model-command.mjs") : null;
|
|
5748
|
+
if (!model) {
|
|
5756
5749
|
if (models.length) {
|
|
5757
|
-
|
|
5750
|
+
const am = await import("./agent-model-command.mjs");
|
|
5751
|
+
const current = role === "agent" ? am.currentAgentModel(settings) : role === "embed" ? ec.currentEmbedModel(settings) : sessionCodingLabel;
|
|
5752
|
+
model = R.filterCoders
|
|
5753
|
+
? await chooseCodingModelFrom(ps, models, R.question, 0)
|
|
5754
|
+
: await chooseFromModelList(models, R.question, Math.max(0, models.indexOf(current)));
|
|
5755
|
+
if (model === null) {
|
|
5756
|
+
console.log(dim(" (cancelled — nothing was saved)\n"));
|
|
5757
|
+
return;
|
|
5758
|
+
}
|
|
5759
|
+
} else {
|
|
5760
|
+
model = String((await ask(" model id: ")) ?? "").trim();
|
|
5761
|
+
}
|
|
5762
|
+
if (!model) {
|
|
5763
|
+
console.log(GUTTER + dim("(cancelled — nothing was saved)\n"));
|
|
5764
|
+
return;
|
|
5758
5765
|
}
|
|
5759
5766
|
}
|
|
5760
|
-
|
|
5761
|
-
//
|
|
5762
|
-
|
|
5763
|
-
|
|
5764
|
-
|
|
5765
|
-
|
|
5766
|
-
|
|
5767
|
-
|
|
5768
|
-
|
|
5769
|
-
|
|
5770
|
-
|
|
5771
|
-
? await chooseFromModelList(models, role.question, 0)
|
|
5772
|
-
: await chooseCodingModelFrom(ps, models, role.question, 0);
|
|
5773
|
-
if (model === null) {
|
|
5774
|
-
console.log(dim(" (cancelled — nothing was saved)\n"));
|
|
5767
|
+
// THE EMBEDDER'S RECOGNITION WALL: a different model invalidates every index, so the projects
|
|
5768
|
+
// that stop working are named BEFORE anything is saved. The same model is a pure server move.
|
|
5769
|
+
let plan = presetPlan;
|
|
5770
|
+
if (role === "embed" && !presetModel) {
|
|
5771
|
+
const current = ec.currentEmbedModel(settings);
|
|
5772
|
+
if (!ec.sameEmbedModel(model, current)) {
|
|
5773
|
+
const sw = await confirmEmbedSwitch(ec, current, model);
|
|
5774
|
+
if (!sw.changed) return;
|
|
5775
|
+
plan = sw.plan;
|
|
5776
|
+
} else if (as.serverTarget(target.dialect, String(settings.ragEmbedUrl ?? "")).root === target.root && !typedKey) {
|
|
5777
|
+
console.log(dim(` already embedding with ${model} on ${host} — nothing changed.\n`));
|
|
5775
5778
|
return;
|
|
5776
5779
|
}
|
|
5777
|
-
} else {
|
|
5778
|
-
model = (await ask(" model id: ")).trim();
|
|
5779
5780
|
}
|
|
5780
|
-
|
|
5781
|
-
|
|
5782
|
-
|
|
5781
|
+
// MORE THAN ONE MODEL from a NEW server, for the coder: the default is what runs, these are the
|
|
5782
|
+
// others /model may switch to. At most MAX_MODELS_PER_SERVER in all (the API refuses more).
|
|
5783
|
+
let extras = [];
|
|
5784
|
+
if (R.extras && !found && models.length > 1) {
|
|
5785
|
+
extras = await pickExtraModels(models.filter((m) => m !== model));
|
|
5786
|
+
if (extras === null) {
|
|
5787
|
+
console.log(GUTTER + dim("(cancelled — nothing was saved)\n"));
|
|
5788
|
+
return;
|
|
5789
|
+
}
|
|
5783
5790
|
}
|
|
5784
5791
|
|
|
5785
|
-
// ---
|
|
5786
|
-
|
|
5787
|
-
|
|
5788
|
-
//
|
|
5789
|
-
|
|
5790
|
-
|
|
5791
|
-
|
|
5792
|
-
|
|
5792
|
+
// --- 5. save: the server, its key, then the role --------------------------------------
|
|
5793
|
+
let rowId = found?.id ?? "";
|
|
5794
|
+
let rows = existing;
|
|
5795
|
+
// A NEW ROW ONLY WHEN SOMETHING NEEDS ONE: the coder is picked through its row, and a key is
|
|
5796
|
+
// bound to one. A keyless Ollama on this machine or the LAN for the agent or the embedder needs
|
|
5797
|
+
// neither — the role's own setting holds its URL — and a row would put it in /model as a coder.
|
|
5798
|
+
const needRow = role === "coding" || typedKey;
|
|
5799
|
+
if (!found && needRow) {
|
|
5800
|
+
rowId = ps.mintProviderId(existing.map((b) => String(b.id ?? "")));
|
|
5801
|
+
const { row } = as.rowForSave(role, { id: rowId, kind: target.dialect === "ollama" ? "ollama" : "openai", url: target.url, model, extras });
|
|
5802
|
+
rows = [...existing, row];
|
|
5803
|
+
} else {
|
|
5804
|
+
const r = as.rowForSave(role, { existing: found.row, model });
|
|
5805
|
+
if (r.full) {
|
|
5806
|
+
console.log(yellow(` ${host} already offers ${ps.MAX_MODELS_PER_SERVER} models — remove one in /model → manage providers first.\n`));
|
|
5807
|
+
return;
|
|
5808
|
+
}
|
|
5809
|
+
if (r.changed) rows = existing.map((b) => (b === found.row ? r.row : b));
|
|
5793
5810
|
}
|
|
5794
|
-
|
|
5795
|
-
|
|
5796
|
-
|
|
5797
|
-
|
|
5798
|
-
|
|
5799
|
-
|
|
5800
|
-
|
|
5801
|
-
|
|
5802
|
-
console.log(red(" couldn\'t save that to this machine — nothing changed."));
|
|
5803
|
-
console.log(GUTTER + dim("if this machine\'s local API is old, restart it with `gu api restart`.\n"));
|
|
5804
|
-
return;
|
|
5811
|
+
if (rows !== existing) {
|
|
5812
|
+
const ok = await withPendingStatus(`saving ${host}`, () => md.saveWorkerSettings(apiBase, workerKey, { agentCodingBackends: rows }));
|
|
5813
|
+
if (!ok) {
|
|
5814
|
+
console.log(red(` couldn't save ${host} — nothing changed.`));
|
|
5815
|
+
console.log(GUTTER + dim("if this machine's local API is old, restart it with `gu api restart`.\n"));
|
|
5816
|
+
return;
|
|
5817
|
+
}
|
|
5818
|
+
if (!found && role !== "coding" && as.serverRole(role).rowModel) console.log(GUTTER + dim(`${host} is also offered in /model now.`));
|
|
5805
5819
|
}
|
|
5806
|
-
|
|
5807
|
-
addCodingKey(
|
|
5808
|
-
|
|
5809
|
-
|
|
5810
|
-
|
|
5811
|
-
|
|
5812
|
-
|
|
5813
|
-
|
|
5814
|
-
|
|
5820
|
+
if (typedKey) {
|
|
5821
|
+
const id = await withPendingStatus("saving the key", () => addCodingKey(keyName, key, rowId));
|
|
5822
|
+
if (!id) {
|
|
5823
|
+
console.log(yellow(` ${host} was saved, but its key was not — nothing else changed.`));
|
|
5824
|
+
console.log(GUTTER + dim("try again, or add the key with ctrl+k.\n"));
|
|
5825
|
+
return;
|
|
5826
|
+
}
|
|
5827
|
+
// A key typed for a server that already had keys was typed because there was none usable or
|
|
5828
|
+
// the stored one was refused: it is the server's key from now on, for every role and folder.
|
|
5829
|
+
if (found) await setServerDefaultKey(rowId, id);
|
|
5815
5830
|
}
|
|
5831
|
+
const keyLine = key ? dim(` API key ${keyName || "saved"} ····${key.slice(-4)} · ${host}'s key`) : "";
|
|
5816
5832
|
|
|
5817
|
-
|
|
5818
|
-
|
|
5819
|
-
|
|
5820
|
-
|
|
5821
|
-
|
|
5822
|
-
|
|
5823
|
-
|
|
5824
|
-
|
|
5825
|
-
// turn could only reject, seconds after configuring the thing.
|
|
5826
|
-
//
|
|
5827
|
-
// So: re-read the choices and take the id the server actually offers. Matched on the MODEL and
|
|
5828
|
-
// the BACKEND — by id when the API is new enough to report one, else by URL, which is what
|
|
5829
|
-
// identifies a row to an older API.
|
|
5830
|
-
// --- 7a. THE AGENT ROLE SAVES A MACHINE SETTING AND IS DONE (task #225 phase 4) ----------
|
|
5831
|
-
//
|
|
5832
|
-
// The rest of this function is about the CODING pick: which wire id the API offers for it, and
|
|
5833
|
-
// storing that id on the folder. None of it applies here. The agent model is not a folder
|
|
5834
|
-
// choice — it is what `gu` talks to in every folder on this machine — so it goes to
|
|
5835
|
-
// settings, which is where /agent-model has always written it.
|
|
5836
|
-
//
|
|
5837
|
-
// Written as URL + NAME rather than a row id because that is the pair app.ts reads
|
|
5838
|
-
// (buildAgentChatJobPayload), and phase 1 resolves the key by matching that URL back to the
|
|
5839
|
-
// provider row we just saved. One representation, already proven.
|
|
5840
|
-
if (roleName === "agent") {
|
|
5841
|
-
const ok = await withPendingStatus("pointing the agent model at it", () =>
|
|
5842
|
-
md.saveWorkerSettings(apiBase, workerKey, { agentModelUrl: url, agentModelName: model })
|
|
5843
|
-
);
|
|
5844
|
-
if (!ok) {
|
|
5845
|
-
// The PROVIDER is saved — say so, or this reads as the whole thing having failed and the
|
|
5846
|
-
// user re-enters a key that is already stored.
|
|
5833
|
+
if (role === "agent") {
|
|
5834
|
+
// A MACHINE SETTING, not a folder pick: the agent is what gu talks to in every folder. Saved
|
|
5835
|
+
// as URL + NAME, the pair app.ts matches back to the server row to find the key.
|
|
5836
|
+
if (await saveAgentModel(target.roleUrl("agent"), model, host)) {
|
|
5837
|
+
if (keyLine) console.log(keyLine + "\n");
|
|
5838
|
+
console.log(GUTTER + dim("the banner shows the new model next time gu starts.\n"));
|
|
5839
|
+
} else if (rows !== existing || typedKey) {
|
|
5840
|
+
// The SERVER landed — say so, or the user re-enters a key that is already stored.
|
|
5847
5841
|
console.log(yellow(` ${host} was saved, but the agent model was not pointed at it.`));
|
|
5848
5842
|
console.log(GUTTER + dim("run /agent-model to finish, or check the API is reachable.\n"));
|
|
5849
|
-
return;
|
|
5850
5843
|
}
|
|
5851
|
-
console.log(accent(` ✓ agent model → ${model}`) + dim(` · ${host}`));
|
|
5852
|
-
console.log(dim(` API key ${name || "Unknown"} ····${secret.slice(-4)} · this machine\n`));
|
|
5853
|
-
// Restart advice, because the banner and the running daemon both read this at startup: a
|
|
5854
|
-
// machine that keeps talking to the old model after a successful save looks like the save
|
|
5855
|
-
// failed, and that is the one thing this line prevents.
|
|
5856
|
-
console.log(GUTTER + dim("the banner shows the new model next time gu starts.\n"));
|
|
5857
5844
|
return;
|
|
5858
5845
|
}
|
|
5846
|
+
if (role === "embed") {
|
|
5847
|
+
// ragEmbedKeyId "" — the server's own keys decide now (its default, or this folder's pick).
|
|
5848
|
+
const fields = { ragEmbedUrl: target.roleUrl("embed"), ragEmbedModel: model, ragEmbedKeyId: "" };
|
|
5849
|
+
if (!(await saveEmbedSettings(fields))) return;
|
|
5850
|
+
if (plan) announceEmbedSaved({ chosen: model, plan, url: target.roleUrl("embed") });
|
|
5851
|
+
else {
|
|
5852
|
+
console.log(accent(` ✓ embedding server → ${host}`));
|
|
5853
|
+
console.log(dim(` ${model} did not change, so no knowledge base rebuilds — the same model just answers from ${host}.\n`));
|
|
5854
|
+
}
|
|
5855
|
+
return;
|
|
5856
|
+
}
|
|
5857
|
+
await useCoderInThisFolder({ ps, rows, beforeChoices, url: target.url, host, locality: target.locality, model, backendId: rowId, key, keyName });
|
|
5858
|
+
}
|
|
5859
5859
|
|
|
5860
|
+
/**
|
|
5861
|
+
* The coder just added becomes THIS FOLDER's pick (not the account default — per-folder is
|
|
5862
|
+
* reversible and nobody asked for account-wide). Split out of addServer because only the coder
|
|
5863
|
+
* has it, and it carries the two checks that were learned the hard way:
|
|
5864
|
+
*
|
|
5865
|
+
* · ASK THE API WHAT THE PICK IS CALLED. The wire id belongs to the API (a bare model or
|
|
5866
|
+
* `${rowId}::${model}`); guessing it here once stored a pick the next turn rejected.
|
|
5867
|
+
* · DID SAVING EVICT ANOTHER PROVIDER? An API older than row ids keys a backend by its KIND,
|
|
5868
|
+
* so a second openai row replaced the first with a 200. Said BEFORE the success line.
|
|
5869
|
+
*/
|
|
5870
|
+
async function useCoderInThisFolder({ ps, rows, beforeChoices, url, host, locality, model, backendId, key, keyName }) {
|
|
5860
5871
|
const cwd = resolve(process.cwd());
|
|
5861
5872
|
const after = await withPendingStatus("checking it is offered", () =>
|
|
5862
5873
|
fetchAgentPayload([{ role: "user", content: "ping" }]).catch(() => null)
|
|
@@ -5866,28 +5877,13 @@ async function addCodingProvider(roleName = "coding") {
|
|
|
5866
5877
|
(c) => c.model === model && (c.backendId ? c.backendId === backendId : sameHost(c.url, url))
|
|
5867
5878
|
);
|
|
5868
5879
|
if (!offered) {
|
|
5869
|
-
// SAVED BUT NOT OFFERED. Pinning the folder to a guess here is what produced the reported
|
|
5870
|
-
// failure; say what happened instead, and leave the folder on a coder that works.
|
|
5871
5880
|
console.log(yellow(` ${host} was saved, but this API is not offering ${model} yet.`));
|
|
5872
5881
|
console.log(GUTTER + dim("run /model — if it is missing there too, the API may be older than"));
|
|
5873
5882
|
console.log(GUTTER + dim("this feature; restart it with `gu api restart`.\n"));
|
|
5874
5883
|
return;
|
|
5875
5884
|
}
|
|
5876
|
-
//
|
|
5877
|
-
//
|
|
5878
|
-
// The check above asks only whether the model just added is offered — and it is, which is
|
|
5879
|
-
// exactly why the loss was silent: "whenever i add or change an openai-compatible provider, it
|
|
5880
|
-
// replace the old one in this list". An API older than row ids keys a backend by its KIND, so a
|
|
5881
|
-
// second openai row collides with the first and the first disappears. The write answers 200,
|
|
5882
|
-
// the new provider works, and nothing says the old one is gone.
|
|
5883
|
-
//
|
|
5884
|
-
// Printed BEFORE the success line, because it is the more important half of what just
|
|
5885
|
-
// happened — and the folder's pick still goes through, since the provider that was added is
|
|
5886
|
-
// genuinely usable and refusing to select it would help nobody.
|
|
5887
|
-
// Two questions, and they catch different failures. providerUrlsLost asks whether a row WE
|
|
5888
|
-
// SENT failed to land (an old API colliding two rows on their kind). providersNoLongerOffered
|
|
5889
|
-
// asks whether anything that used to be offered has gone — including a provider this client
|
|
5890
|
-
// never saw, which is the shape the reported bug actually took.
|
|
5885
|
+
// Two questions, and they catch different failures: a row WE SENT failed to land, or something
|
|
5886
|
+
// that used to be offered has gone (including a provider this client never saw).
|
|
5891
5887
|
const lost = Array.from(
|
|
5892
5888
|
new Set([
|
|
5893
5889
|
...ps.providerUrlsLost(rows, after?.codingChoices),
|
|
@@ -5898,32 +5894,27 @@ async function addCodingProvider(roleName = "coding") {
|
|
|
5898
5894
|
console.log(yellow(` ⚠ this API dropped ${lost.length === 1 ? "a provider" : `${lost.length} providers`} when saving:`));
|
|
5899
5895
|
for (const u of lost) console.log(GUTTER + dim(` ${u}`));
|
|
5900
5896
|
if (ps.apiKeysBackendsByKind(after?.codingChoices)) {
|
|
5901
|
-
// SAY WHY, not just what. "Restart your API" on its own is an instruction without a
|
|
5902
|
-
// reason, and this one has now been given three times without being acted on.
|
|
5903
5897
|
console.log(GUTTER + dim("this API identifies a backend by its KIND, so it holds ONE"));
|
|
5904
5898
|
console.log(GUTTER + dim("OpenAI-compatible provider at a time. A newer one keeps several."));
|
|
5905
5899
|
}
|
|
5906
5900
|
console.log(GUTTER + dim("restart it with `gu api restart`, then add that provider again.\n"));
|
|
5907
5901
|
}
|
|
5908
|
-
// THE FOLDER\'S CHOICE, NOT THE ACCOUNT DEFAULT. Adding a provider must not re-point every
|
|
5909
|
-
// other folder at it — per-folder is reversible and account-wide is not, and nobody asked for
|
|
5910
|
-
// the second thing. agentCodingDefaultBackendId is deliberately untouched.
|
|
5911
5902
|
sessionCodingModel = offered.id;
|
|
5912
5903
|
sessionCodingLabel = model;
|
|
5913
5904
|
sessionCodingUrl = url;
|
|
5914
|
-
|
|
5915
|
-
|
|
5916
|
-
|
|
5905
|
+
// The folder follows the SERVER's key (task #245) — its default, which a first key already is.
|
|
5906
|
+
sessionCodingKeyId = "";
|
|
5907
|
+
sessionApiKeyTail = key ? key.slice(-4) : "";
|
|
5908
|
+
keyChoicePending = false;
|
|
5917
5909
|
await saveSessionCoding(cwd);
|
|
5918
5910
|
await saveSessionCodingKeyId(cwd);
|
|
5919
|
-
// The key question for this coder is
|
|
5920
|
-
//
|
|
5911
|
+
// The key question for this coder is answered — leaving the flag set would warn about a key
|
|
5912
|
+
// that was just entered, which reads as the save having failed.
|
|
5921
5913
|
codingKeyMissingKnown = false;
|
|
5922
5914
|
console.log(accent(` ✓ coding model → ${model}`) + dim(` · ${host}`));
|
|
5923
|
-
console.log(dim(` API key ${
|
|
5924
|
-
// A HOSTED
|
|
5925
|
-
//
|
|
5926
|
-
// every turn and produces a bill with no explanation.
|
|
5915
|
+
if (key) console.log(dim(` API key ${keyName || "saved"} ····${key.slice(-4)} · ${host}'s key\n`));
|
|
5916
|
+
// A HOSTED SERVER NEEDS ITS WINDOW SET: with nothing set gu assumes 24,000 prompt tokens and
|
|
5917
|
+
// compacts there — on a large hosted model that throws history away every turn.
|
|
5927
5918
|
if (ps.shouldMentionContextWindow(locality)) {
|
|
5928
5919
|
console.log(GUTTER + dim("tip: /context-window — hosted servers do not report their size,"));
|
|
5929
5920
|
console.log(GUTTER + dim(" so gu assumes 24,000 prompt tokens and compacts there.\n"));
|
|
@@ -6288,7 +6279,7 @@ async function fetchWorkerSettings() {
|
|
|
6288
6279
|
* Only a model change rebuilds indexes — moving the SAME embedder to another server, or changing
|
|
6289
6280
|
* only its key, leaves every vector valid, so those paths save without the plan wall. The worker
|
|
6290
6281
|
* already speaks both dialects: gonext_agent_chat._embed() POSTs {base}/v1/embeddings first, then
|
|
6291
|
-
* Ollama /api/embed and /api/embeddings, and sends the
|
|
6282
|
+
* Ollama /api/embed and /api/embeddings, and sends the embed server's stored key (role=embed) as a Bearer when
|
|
6292
6283
|
* one exists — so pointing at an OpenAI-compatible provider here needs no worker-side change.
|
|
6293
6284
|
*
|
|
6294
6285
|
* The decisions live in embed-model-command.mjs; this is the terminal half only.
|
|
@@ -6307,50 +6298,6 @@ async function saveEmbedSettings(fields) {
|
|
|
6307
6298
|
return saved;
|
|
6308
6299
|
}
|
|
6309
6300
|
|
|
6310
|
-
// WHERE TO EMBED, ASKED ONCE, SHARED. The model sub-flow and the ollama-server action both need
|
|
6311
|
-
// "which Ollama serves the embeddings"; it used to live inline, which meant a second copy could
|
|
6312
|
-
// never exist. Enter keeps the current URL; Ctrl+C/Escape returns null and the caller just stops.
|
|
6313
|
-
// Anything that is not an Ollama-shaped host is rejected with the reason, then asked AGAIN —
|
|
6314
|
-
// ollama-host-snippets' why line IS the retry message.
|
|
6315
|
-
async function pickEmbedServerUrl({ currentUrl }) {
|
|
6316
|
-
const ohs = await import("./ollama-host-snippets.mjs");
|
|
6317
|
-
for (;;) {
|
|
6318
|
-
const urlIn = await ask(` Ollama server URL for embedding [${currentUrl}]: `);
|
|
6319
|
-
if (urlIn === null) return null;
|
|
6320
|
-
const typed = urlIn.trim();
|
|
6321
|
-
if (!typed) return currentUrl;
|
|
6322
|
-
const embedUrl = /^https?:\/\//i.test(typed) ? typed : `http://${typed}`;
|
|
6323
|
-
const r = ohs.checkOllamaHostInput(embedUrl);
|
|
6324
|
-
if (r.ok) return embedUrl;
|
|
6325
|
-
console.log(GUTTER + dim(` "${typed}" is ${r.why} — try host:port, e.g. 127.0.0.1:11434`));
|
|
6326
|
-
}
|
|
6327
|
-
}
|
|
6328
|
-
|
|
6329
|
-
// THE KEY, ASKED ONCE, SHARED. ollama.com cannot proceed without one; the OpenAI-compatible
|
|
6330
|
-
// provider row asks the same way (Enter skips it — a local vLLM answers anonymously). All embed
|
|
6331
|
-
// keys land under the fixed backendId "embed-ollama" because that is the id
|
|
6332
|
-
// gonext_agent_chat._rag_resolve_embed_api_key() fetches, whatever the server is; ragEmbedKeyId
|
|
6333
|
-
// is just the human label the settings panel shows. Returns { cancelled, keyId } — a cancelled
|
|
6334
|
-
// prompt writes NOTHING, and a failed store keeps the key out of the settings so the user is not
|
|
6335
|
-
// told they authenticated when they did not.
|
|
6336
|
-
async function askEmbedKey(url, { optional = false } = {}) {
|
|
6337
|
-
console.log(GUTTER + dim('A name just tells keys apart — "embed", "work".'));
|
|
6338
|
-
const name = (await askKeyName()).trim();
|
|
6339
|
-
console.log(GUTTER + dim(SECRET_KEY_HINT));
|
|
6340
|
-
const key = (await askSecret(` API key for ${url}${name ? ` (${name})` : ""}: `)).trim();
|
|
6341
|
-
if (!key) {
|
|
6342
|
-
if (optional) console.log(GUTTER + dim("(no key — the provider will be called anonymously)"));
|
|
6343
|
-
return { cancelled: !optional, keyId: "", key: "" };
|
|
6344
|
-
}
|
|
6345
|
-
const id = await withPendingStatus("saving the key", () => addCodingKey(name || "embed", key, "embed-ollama"));
|
|
6346
|
-
if (!id) {
|
|
6347
|
-
console.log(red(" couldn't save the embed key — searching stays unauthenticated.\n"));
|
|
6348
|
-
return { cancelled: true, keyId: "", key: "" };
|
|
6349
|
-
}
|
|
6350
|
-
console.log(GUTTER + dim(`embed key "${name || id}" saved.\n`));
|
|
6351
|
-
return { cancelled: false, keyId: id, key };
|
|
6352
|
-
}
|
|
6353
|
-
|
|
6354
6301
|
// THE RECOGNITION STEP, ONE WAY. Switching embedders invalidates the indexes; naming the projects
|
|
6355
6302
|
// that stop working is the decision the user is actually making, so the plan is read BEFORE the
|
|
6356
6303
|
// "switch / keep" picker — never after, never implied. Returns { changed:false } when the user
|
|
@@ -6438,147 +6385,33 @@ async function chooseEmbedModel() {
|
|
|
6438
6385
|
return;
|
|
6439
6386
|
}
|
|
6440
6387
|
|
|
6388
|
+
// EVERY ARM IS THE ONE ADD-SERVER SCREEN (task #245) — the same URL → key → models → save as
|
|
6389
|
+
// /model and /agent-model. The curated arm has already chosen the model (and passed the
|
|
6390
|
+
// recognition wall), so it only asks WHERE; the other two list what the server has.
|
|
6441
6391
|
if (action === "model") {
|
|
6442
|
-
// WHICH + WHERE, one pass: the recognition wall first (it is the expensive decision), then
|
|
6443
|
-
// the server prompt, then — only on ollama.com — the key. Same addition coding and agent
|
|
6444
|
-
// models already have (#127): the key is named, stored in the shared account store
|
|
6445
|
-
// (settings.agentCodingApiKeys) tagged "embed-ollama", and its id recorded in ragEmbedKeyId so
|
|
6446
|
-
// the embedder can name what it sends. Cancelled url/key prompts abort cleanly: the key, if
|
|
6447
|
-
// typed, is already in the store and the settings write is simply skipped.
|
|
6448
6392
|
const { changed, chosen, plan } = await pickEmbedModel(ec, current);
|
|
6449
6393
|
if (!changed) return;
|
|
6450
|
-
|
|
6451
|
-
if (embedUrl === null) {
|
|
6452
|
-
console.log(dim(" (cancelled)\n"));
|
|
6453
|
-
return;
|
|
6454
|
-
}
|
|
6455
|
-
let embedKeyId = settings.ragEmbedKeyId ?? "";
|
|
6456
|
-
if (isOllamaCloudUrl(embedUrl)) {
|
|
6457
|
-
const k = await askEmbedKey(embedUrl);
|
|
6458
|
-
if (k.cancelled) return;
|
|
6459
|
-
embedKeyId = k.keyId;
|
|
6460
|
-
}
|
|
6461
|
-
// A FAILED WRITE MUST NOT LOOK LIKE A SUCCESS (#169): the next turn would keep using the old
|
|
6462
|
-
// embedder while the user is told the index was rebuilt for a change that never happened.
|
|
6463
|
-
if (!(await saveEmbedSettings({ ragEmbedModel: chosen, ragEmbedUrl: embedUrl, ragEmbedKeyId: embedKeyId }))) return;
|
|
6464
|
-
announceEmbedSaved({ chosen, plan, url: embedUrl });
|
|
6394
|
+
await addServer({ role: "embed", dialect: "ollama", model: chosen, plan });
|
|
6465
6395
|
return;
|
|
6466
6396
|
}
|
|
6397
|
+
if (action === "ollama") return addServer({ role: "embed", dialect: "ollama" });
|
|
6398
|
+
if (action === "provider") return addServer({ role: "embed", dialect: "openai" });
|
|
6467
6399
|
|
|
6468
|
-
if (action === "
|
|
6469
|
-
// SAME
|
|
6470
|
-
//
|
|
6471
|
-
//
|
|
6472
|
-
const embedUrl = await pickEmbedServerUrl({ currentUrl });
|
|
6473
|
-
if (embedUrl === null) {
|
|
6474
|
-
console.log(dim(" (cancelled)\n"));
|
|
6475
|
-
return;
|
|
6476
|
-
}
|
|
6477
|
-
let embedKeyId = "";
|
|
6478
|
-
if (isOllamaCloudUrl(embedUrl)) {
|
|
6479
|
-
const k = await askEmbedKey(embedUrl);
|
|
6480
|
-
if (k.cancelled) return;
|
|
6481
|
-
embedKeyId = k.keyId;
|
|
6482
|
-
}
|
|
6483
|
-
// Same box, no new key: keep the no-op honest instead of writing noise over the settings.
|
|
6484
|
-
if (embedUrl === currentUrl && !embedKeyId) {
|
|
6485
|
-
console.log(dim(` already embedding on ${host || currentUrl} — nothing changed.\n`));
|
|
6486
|
-
return;
|
|
6487
|
-
}
|
|
6488
|
-
if (!(await saveEmbedSettings({ ragEmbedUrl: embedUrl, ragEmbedKeyId: embedKeyId }))) return;
|
|
6489
|
-
console.log(accent(` ✓ embedding server → ${hostOf(embedUrl)}`));
|
|
6490
|
-
console.log(dim(` ${current} did not change, so no knowledge base rebuilds — the same model just answers from another box.\n`));
|
|
6491
|
-
return;
|
|
6492
|
-
}
|
|
6493
|
-
|
|
6494
|
-
if (action === "provider") {
|
|
6495
|
-
// ANOTHER OPENAI-COMPATIBLE PROVIDER. _embed() tries {base}/v1/embeddings first, so any
|
|
6496
|
-
// /v1-shaped server works — vLLM, OpenRouter, LM Studio, a hosted gateway. The key comes
|
|
6497
|
-
// BEFORE the list: most providers 401 an anonymous /v1/models, and a keyless local server
|
|
6498
|
-
// just answers, so Enter skips the key. The model id then comes from the provider's own
|
|
6499
|
-
// /v1/models when it answers, typed EXACTLY as the provider calls it when it does not — the
|
|
6500
|
-
// worker POSTs the string verbatim, so both are supported.
|
|
6400
|
+
if (action === "key") {
|
|
6401
|
+
// THE SAME KEY MANAGER as /model's ctrl+k and /agent-model (task #245): this server's keys
|
|
6402
|
+
// only — switch (this folder's embedder, or the server's default), add, delete. A server
|
|
6403
|
+
// with no row yet gets one here, with its first key, exactly as the model arms add it.
|
|
6501
6404
|
const ps = await import("./coding-provider-setup.mjs");
|
|
6502
|
-
|
|
6503
|
-
|
|
6504
|
-
|
|
6505
|
-
|
|
6506
|
-
|
|
6507
|
-
return;
|
|
6508
|
-
}
|
|
6509
|
-
const typed = raw.trim();
|
|
6510
|
-
base = /^https?:\/\//i.test(typed) ? typed : `https://${typed}`;
|
|
6511
|
-
try {
|
|
6512
|
-
new URL(base);
|
|
6513
|
-
break;
|
|
6514
|
-
} catch {
|
|
6515
|
-
console.log(yellow(" that is not a URL — try https://host or https://host/v1"));
|
|
6516
|
-
}
|
|
6517
|
-
}
|
|
6518
|
-
base = ps.ensureV1Root(base);
|
|
6519
|
-
const providerHost = hostOf(base);
|
|
6520
|
-
const k = await askEmbedKey(base, { optional: true });
|
|
6521
|
-
const embedKeyId = k.keyId;
|
|
6522
|
-
const { proxyFetch } = await import("./proxy-dispatcher.mjs");
|
|
6523
|
-
let chosen = "";
|
|
6524
|
-
let listed = [];
|
|
6525
|
-
try {
|
|
6526
|
-
const am = await import("./agent-model-command.mjs");
|
|
6527
|
-
const r = await withPendingStatus(`asking ${providerHost} for its models`, () =>
|
|
6528
|
-
proxyFetch(`${base}/models`, { headers: am.agentListHeaders(k.key || ""), signal: AbortSignal.timeout(12000) })
|
|
6529
|
-
);
|
|
6530
|
-
if (r.ok) listed = am.agentModelsFromBody("openai", await r.json().catch(() => null));
|
|
6531
|
-
} catch { /* an unreachable list is not a dead end — typing is fully supported, below */ }
|
|
6532
|
-
if (listed.length) {
|
|
6533
|
-
// Embedders first, the rest after, each once: the provider's catalog is mostly chat models,
|
|
6534
|
-
// and burying "text-embedding-3-large" under 200 of them is a menu nobody can use.
|
|
6535
|
-
const am = await import("./agent-model-command.mjs");
|
|
6536
|
-
const ordered = [...listed].sort((a, b) =>
|
|
6537
|
-
Number(am.isLikelyEmbedder(b)) - Number(am.isLikelyEmbedder(a)) || a.model.localeCompare(b.model));
|
|
6538
|
-
const labels = ordered.map((r) =>
|
|
6539
|
-
`${r.model}${r.note ? dim(` · ${r.note}`) : ""}` + (ec.sameEmbedModel(r.model, current) ? accent(" (current)") : ""));
|
|
6540
|
-
const li = await pickFromList(labels, Math.max(0, ordered.findIndex((r) => ec.sameEmbedModel(r.model, current))));
|
|
6541
|
-
if (li < 0) {
|
|
6542
|
-
console.log(dim(" (cancelled)\n"));
|
|
6543
|
-
return;
|
|
6544
|
-
}
|
|
6545
|
-
chosen = ordered[li].model;
|
|
6405
|
+
const found = ps.providerRowForUrl(ps.existingBackendRows(settings), currentUrl);
|
|
6406
|
+
if (!found) {
|
|
6407
|
+
// No server row yet: the add-server screen, URL and model already known, a key asked for.
|
|
6408
|
+
await addServer({ role: "embed", dialect: /\/v1\/?$/i.test(currentUrl) ? "openai" : "ollama", url: currentUrl, model: current, newKey: true });
|
|
6409
|
+
return;
|
|
6546
6410
|
} else {
|
|
6547
|
-
|
|
6548
|
-
const raw = await ask(` embedding model id [${current}]: `);
|
|
6549
|
-
if (raw === null) {
|
|
6550
|
-
console.log(dim(" (cancelled)\n"));
|
|
6551
|
-
return;
|
|
6552
|
-
}
|
|
6553
|
-
chosen = raw.trim() || current;
|
|
6554
|
-
}
|
|
6555
|
-
// A MODEL CHANGE THROUGH THE PROVIDER ROW IS STILL A REINDEX — same wall, same wording. Same
|
|
6556
|
-
// model = a pure server/key move, and no knowledge base needs a rebuild.
|
|
6557
|
-
let plan = null;
|
|
6558
|
-
if (!ec.sameEmbedModel(chosen, current)) {
|
|
6559
|
-
const sw = await confirmEmbedSwitch(ec, current, chosen);
|
|
6560
|
-
if (!sw.changed) return;
|
|
6561
|
-
plan = sw.plan;
|
|
6411
|
+
await manageServerKeys({ role: "embed", backendId: found.id, host: host || currentUrl, model: current });
|
|
6562
6412
|
}
|
|
6563
|
-
|
|
6564
|
-
if (
|
|
6565
|
-
if (!(await saveEmbedSettings(fields))) return;
|
|
6566
|
-
if (plan) announceEmbedSaved({ chosen, plan, url: base });
|
|
6567
|
-
else {
|
|
6568
|
-
console.log(accent(` ✓ embedding provider → ${providerHost}`));
|
|
6569
|
-
console.log(dim(` ${chosen} did not change, so no knowledge base rebuilds — the same model just answers from ${providerHost}.\n`));
|
|
6570
|
-
}
|
|
6571
|
-
return;
|
|
6572
|
-
}
|
|
6573
|
-
|
|
6574
|
-
if (action === "key") {
|
|
6575
|
-
// A NEW KEY FOR THE SERVER ALREADY CHOSEN. The model and the URL stay; only the credential
|
|
6576
|
-
// rotates — same three steps as everywhere else (name → secret → store under "embed-ollama").
|
|
6577
|
-
const k = await askEmbedKey(host || currentUrl);
|
|
6578
|
-
if (k.cancelled) return;
|
|
6579
|
-
if (!(await saveEmbedSettings({ ragEmbedKeyId: k.keyId }))) return;
|
|
6580
|
-
console.log(accent(` ✓ embedding key → "${k.keyId}"`));
|
|
6581
|
-
console.log(dim(` ${current} still embeds on ${host || currentUrl} — the new key signs the next request.\n`));
|
|
6413
|
+
// The pre-#245 account-wide pick would shadow what was just chosen; the server decides now.
|
|
6414
|
+
if (String(settings.ragEmbedKeyId ?? "").trim()) await saveEmbedSettings({ ragEmbedKeyId: "" });
|
|
6582
6415
|
}
|
|
6583
6416
|
}
|
|
6584
6417
|
|
|
@@ -6613,83 +6446,47 @@ async function askKeyName(prompt = " key name: ") {
|
|
|
6613
6446
|
return "";
|
|
6614
6447
|
}
|
|
6615
6448
|
|
|
6616
|
-
/**
|
|
6617
|
-
|
|
6618
|
-
|
|
6619
|
-
|
|
6449
|
+
/**
|
|
6450
|
+
* ASK FOR A SERVER'S KEY — name, then the secret, then the shape check — ONE PROMPT for every
|
|
6451
|
+
* flow that adds a key (task #245): /model's add-provider and ctrl+k, /agent-model's Ollama cloud
|
|
6452
|
+
* and provider rows, /embed-model's three arms. It only ASKS; the caller saves (a new server's
|
|
6453
|
+
* row must exist before its key can be bound to it).
|
|
6454
|
+
*
|
|
6455
|
+
* Returns { name, secret }; secret "" = nothing typed — a cancel, or with `optional` a deliberate
|
|
6456
|
+
* "no key" (a local vLLM answers anonymously). `host` names the server in the prompt and decides
|
|
6457
|
+
* the shape check's locality: a local server accepts any string, so "abc" is not questioned there.
|
|
6458
|
+
*/
|
|
6459
|
+
async function askServerKey({ host = "", nameHint = '"work", "personal"', optional = false } = {}) {
|
|
6460
|
+
const ps = await import("./coding-provider-setup.mjs");
|
|
6461
|
+
console.log("\n" + GUTTER + dim(`A name just tells keys apart — ${nameHint}.`));
|
|
6462
|
+
const name = (await askKeyName()).trim();
|
|
6620
6463
|
console.log(GUTTER + dim(SECRET_KEY_HINT));
|
|
6621
|
-
const secret = (await askSecret(` API key for ${host}${name ? ` (${name})` : ""}: `)).trim();
|
|
6464
|
+
const secret = (await askSecret(` API key${host ? ` for ${host}` : ""}${name ? ` (${name})` : ""}: `)).trim();
|
|
6622
6465
|
if (!secret) {
|
|
6623
|
-
console.log(GUTTER + dim("(cancelled — nothing was saved)\n"));
|
|
6624
|
-
return "";
|
|
6466
|
+
console.log(GUTTER + dim(optional ? "(no key — the server will be called anonymously)" : "(cancelled — nothing was saved)\n"));
|
|
6467
|
+
return { name, secret: "" };
|
|
6625
6468
|
}
|
|
6626
|
-
|
|
6627
|
-
|
|
6628
|
-
|
|
6629
|
-
|
|
6469
|
+
// DOES THAT LOOK LIKE A KEY? A shape check only — the server decides validity — but "abc" and a
|
|
6470
|
+
// README placeholder are obvious on sight and would otherwise die as a 401 several steps in.
|
|
6471
|
+
const shape = ps.describeApiKeyShape(secret, { host, locality: host ? ps.hostLocality(host) : "" });
|
|
6472
|
+
if (shape.level) {
|
|
6473
|
+
console.log(GUTTER + (shape.level === "warn" ? yellow(shape.note) : dim(shape.note)));
|
|
6474
|
+
if (shape.level === "warn" && !(await askYesNo("Use that key anyway?", false))) {
|
|
6475
|
+
console.log(GUTTER + dim("(cancelled — nothing was saved)\n"));
|
|
6476
|
+
return { name, secret: "" };
|
|
6477
|
+
}
|
|
6630
6478
|
}
|
|
6631
|
-
|
|
6632
|
-
// cloud key should become the chosen one) — pin it to this folder now so the next coder
|
|
6633
|
-
// pick doesn't fall back to an older key, and the bar tail matches what will be sent.
|
|
6634
|
-
sessionCodingKeyId = id;
|
|
6635
|
-
await saveSessionCodingKeyId(resolve(process.cwd()));
|
|
6636
|
-
sessionApiKeyTail = secret.slice(-4);
|
|
6637
|
-
keyChoicePending = false;
|
|
6638
|
-
codingKeyMissingKnown = false;
|
|
6639
|
-
return secret;
|
|
6640
|
-
}
|
|
6641
|
-
|
|
6642
|
-
/** #40: the key was the problem — enter it, then list again. */
|
|
6643
|
-
async function enterAgentProviderKey(row, host) {
|
|
6644
|
-
if (!row) return;
|
|
6645
|
-
if (!(await askKeyForRow(row, host))) return;
|
|
6646
|
-
await chooseAgentModel();
|
|
6479
|
+
return { name, secret };
|
|
6647
6480
|
}
|
|
6648
6481
|
|
|
6649
6482
|
/**
|
|
6650
|
-
* #40:
|
|
6651
|
-
*
|
|
6483
|
+
* #40: the key was the problem — enter it and list again. The same screen as adding a server
|
|
6484
|
+
* (addServer), with the server already known and a fresh key asked for.
|
|
6652
6485
|
*/
|
|
6653
|
-
async function
|
|
6654
|
-
|
|
6655
|
-
const
|
|
6656
|
-
|
|
6657
|
-
const url = ps.ensureV1Root(String(row.url ?? ""));
|
|
6658
|
-
let key = (await withPendingStatus(`reading the key for ${host}`, () => fetchCodingKey(String(row.id ?? "")))).key;
|
|
6659
|
-
if (!key) {
|
|
6660
|
-
console.log(GUTTER + dim(`no API key is stored for ${host} yet.`));
|
|
6661
|
-
key = await askKeyForRow(row, host);
|
|
6662
|
-
if (!key) return;
|
|
6663
|
-
}
|
|
6664
|
-
const list = async (k) =>
|
|
6665
|
-
withPendingStatus(`asking ${host} for its models`, async () => {
|
|
6666
|
-
try {
|
|
6667
|
-
const res = await proxyFetch(ps.modelsUrlFor(url), { headers: am.agentListHeaders(k), signal: AbortSignal.timeout(12_000) });
|
|
6668
|
-
return res.ok ? { body: await res.json().catch(() => null) } : { status: res.status };
|
|
6669
|
-
} catch (err) {
|
|
6670
|
-
return { err };
|
|
6671
|
-
}
|
|
6672
|
-
});
|
|
6673
|
-
let probe = await list(key);
|
|
6674
|
-
if (probe.status === 401 || probe.status === 403) {
|
|
6675
|
-
console.log(yellow(` ${host} rejected the stored key (HTTP ${probe.status}) — enter the right one.`));
|
|
6676
|
-
key = await askKeyForRow(row, host);
|
|
6677
|
-
if (!key) return;
|
|
6678
|
-
probe = await list(key);
|
|
6679
|
-
}
|
|
6680
|
-
let models = [];
|
|
6681
|
-
if (probe.body) {
|
|
6682
|
-
models = am.agentModelsFromProvider(probe.body).map((m) => m.model);
|
|
6683
|
-
} else {
|
|
6684
|
-
for (const l of ps.describeProbeFailure(probe).lines) console.log(GUTTER + yellow(l));
|
|
6685
|
-
console.log(GUTTER + dim("carrying on — you can type the model id yourself."));
|
|
6686
|
-
}
|
|
6687
|
-
const model = models.length ? await chooseFromModelList(models, "Which model talks?", 0) : (await ask(" model id: ")).trim();
|
|
6688
|
-
if (!model) {
|
|
6689
|
-
console.log(dim(" (cancelled — nothing was saved)\n"));
|
|
6690
|
-
return;
|
|
6691
|
-
}
|
|
6692
|
-
await saveAgentModel(url, model, host);
|
|
6486
|
+
async function enterAgentProviderKey(row, host) {
|
|
6487
|
+
if (!row) return;
|
|
6488
|
+
const dialect = String(row.kind ?? "") === "ollama" ? "ollama" : "openai";
|
|
6489
|
+
await addServer({ role: "agent", dialect, url: String(row.url ?? host), newKey: true });
|
|
6693
6490
|
}
|
|
6694
6491
|
|
|
6695
6492
|
/**
|
|
@@ -6703,7 +6500,7 @@ async function chooseAgentProvider() {
|
|
|
6703
6500
|
return;
|
|
6704
6501
|
}
|
|
6705
6502
|
const choices = am.agentProviderChoices(settings);
|
|
6706
|
-
if (choices.length === 1) return
|
|
6503
|
+
if (choices.length === 1) return addServer({ role: "agent", dialect: "openai" });
|
|
6707
6504
|
console.log(dim(` Which OpenAI-compatible provider should the agent use? (${PICK_HINT})`));
|
|
6708
6505
|
const idx = await pickFromList(choices.map((c) => c.label), 0);
|
|
6709
6506
|
if (idx < 0) {
|
|
@@ -6711,133 +6508,9 @@ async function chooseAgentProvider() {
|
|
|
6711
6508
|
return;
|
|
6712
6509
|
}
|
|
6713
6510
|
const pick = choices[idx];
|
|
6714
|
-
if (pick.action === "add") return
|
|
6715
|
-
|
|
6716
|
-
}
|
|
6717
|
-
|
|
6718
|
-
/**
|
|
6719
|
-
* #41: the agent model on an OLLAMA server — this machine's, a LAN box, or the Ollama cloud.
|
|
6720
|
-
*
|
|
6721
|
-
* Local and LAN Ollama take no key: the agent URL and model are saved, and the API sends its
|
|
6722
|
-
* placeholder, which Ollama ignores. The CLOUD (https://ollama.com) needs one, and the API finds an
|
|
6723
|
-
* agent's key through the provider row whose URL matches — so a cloud server reuses the row that is
|
|
6724
|
-
* already there (a kimi-k3 coder on ollama.com has one), or gets one, with the key bound to it.
|
|
6725
|
-
*/
|
|
6726
|
-
async function addOllamaAgent() {
|
|
6727
|
-
const am = await import("./agent-model-command.mjs");
|
|
6728
|
-
const ps = await import("./coding-provider-setup.mjs");
|
|
6729
|
-
const md = await import("./model-doctor.mjs");
|
|
6730
|
-
const { proxyFetch } = await import("./proxy-dispatcher.mjs");
|
|
6731
|
-
const settings = await withPendingStatus("reading your model settings", fetchWorkerSettings);
|
|
6732
|
-
if (!settings) {
|
|
6733
|
-
console.log(red(" couldn't reach the API to read your current settings.\n"));
|
|
6734
|
-
return;
|
|
6735
|
-
}
|
|
6736
|
-
const dflt = am.ollamaAgentDefault(settings);
|
|
6737
|
-
console.log(dim(" An Ollama server: this machine, a box on your network, or https://ollama.com (needs a key)."));
|
|
6738
|
-
console.log(dim(` Enter uses ${dflt} · Esc cancels\n`));
|
|
6739
|
-
|
|
6740
|
-
let target = null;
|
|
6741
|
-
for (let attempt = 0; attempt < 3 && !target; attempt++) {
|
|
6742
|
-
const typed = (await ask(" Ollama server: ")).trim();
|
|
6743
|
-
const r = am.ollamaAgentUrl(typed || dflt);
|
|
6744
|
-
if (!r.ok) {
|
|
6745
|
-
console.log(GUTTER + dim(`"${typed}" is ${r.why} — try host:port, e.g. 127.0.0.1:11434`));
|
|
6746
|
-
continue;
|
|
6747
|
-
}
|
|
6748
|
-
target = r;
|
|
6749
|
-
}
|
|
6750
|
-
if (!target) {
|
|
6751
|
-
console.log(dim(" (nothing saved)\n"));
|
|
6752
|
-
return;
|
|
6753
|
-
}
|
|
6754
|
-
|
|
6755
|
-
// THE CLOUD NEEDS A KEY, and the key has to belong to a provider row with this URL.
|
|
6756
|
-
const existing = ps.existingBackendRows(settings);
|
|
6757
|
-
let row = target.cloud
|
|
6758
|
-
? existing.find((b) => ps.ensureV1Root(String(b.url ?? "")).toLowerCase() === target.agentUrl.toLowerCase()) ?? null
|
|
6759
|
-
: null;
|
|
6760
|
-
let key = "";
|
|
6761
|
-
let newRow = null;
|
|
6762
|
-
let hasBoundKey = false;
|
|
6763
|
-
if (target.cloud) {
|
|
6764
|
-
// A KEY SAVED FOR THIS PROVIDER — not any key the account holds. ollama.com's model list answers
|
|
6765
|
-
// without a key, so listing cannot catch a borrowed one; only asking can.
|
|
6766
|
-
if (row) {
|
|
6767
|
-
const keys = await withPendingStatus(`reading the keys for ${target.host}`, fetchCodingKeys);
|
|
6768
|
-
hasBoundKey = Boolean(am.boundKeyFor(keys, row.id));
|
|
6769
|
-
}
|
|
6770
|
-
if (!hasBoundKey) {
|
|
6771
|
-
if (!row) {
|
|
6772
|
-
if (existing.length >= ps.MAX_BACKENDS) {
|
|
6773
|
-
console.log(yellow(` ${ps.MAX_BACKENDS} providers is the limit — remove one in web Settings → Agent first.\n`));
|
|
6774
|
-
return;
|
|
6775
|
-
}
|
|
6776
|
-
newRow = { id: ps.mintProviderId(existing.map((b) => String(b.id ?? ""))), kind: "ollama", enabled: true, url: target.agentUrl, defaultModel: "", models: [] };
|
|
6777
|
-
}
|
|
6778
|
-
console.log(GUTTER + dim(`${target.host} is the Ollama cloud — it needs an API key.`));
|
|
6779
|
-
console.log(GUTTER + dim(SECRET_KEY_HINT));
|
|
6780
|
-
key = (await askSecret(` API key for ${target.host}: `)).trim();
|
|
6781
|
-
if (!key) {
|
|
6782
|
-
console.log(GUTTER + dim("(cancelled — nothing was saved)\n"));
|
|
6783
|
-
return;
|
|
6784
|
-
}
|
|
6785
|
-
}
|
|
6786
|
-
}
|
|
6787
|
-
|
|
6788
|
-
// ITS MODELS, LIVE — with the key when it is the cloud.
|
|
6789
|
-
const probe = await withPendingStatus(`asking ${target.host} for its models`, async () => {
|
|
6790
|
-
try {
|
|
6791
|
-
const res = await proxyFetch(target.tagsUrl, { headers: am.agentListHeaders(key), signal: AbortSignal.timeout(12_000) });
|
|
6792
|
-
return res.ok ? { body: await res.json().catch(() => null) } : { status: res.status };
|
|
6793
|
-
} catch (err) {
|
|
6794
|
-
return { err: err?.name === "TimeoutError" ? "no response in 12s" : (err?.message ?? String(err)) };
|
|
6795
|
-
}
|
|
6796
|
-
});
|
|
6797
|
-
if (!probe.body) {
|
|
6798
|
-
const why = probe.status ? `HTTP ${probe.status}` : probe.err;
|
|
6799
|
-
console.log(red(` couldn't list the models on ${target.host} — ${why}.`));
|
|
6800
|
-
if (probe.status === 401 || probe.status === 403) console.log(GUTTER + dim("the key was rejected — nothing was saved.\n"));
|
|
6801
|
-
else console.log(GUTTER + dim("check the server is running and reachable (and /proxy if this machine needs one) — nothing was saved.\n"));
|
|
6802
|
-
return;
|
|
6803
|
-
}
|
|
6804
|
-
const models = am.parseOllamaTags(probe.body);
|
|
6805
|
-
if (!models.length) {
|
|
6806
|
-
console.log(yellow(` ${target.host} answered but lists no models — pull one there first (ollama pull …).\n`));
|
|
6807
|
-
return;
|
|
6808
|
-
}
|
|
6809
|
-
const rows = am.agentModelRows(models, am.currentAgentModel(settings));
|
|
6810
|
-
console.log(dim(` ${models.length} on ${target.root} (${PICK_HINT}):`));
|
|
6811
|
-
const idx = await pickFromList(
|
|
6812
|
-
rows.map((r) => r.model + (r.embedder ? dim(" · embedding model, not for chat") : "") + (r.current ? accent(" (current)") : "")),
|
|
6813
|
-
Math.max(0, rows.findIndex((r) => r.current))
|
|
6814
|
-
);
|
|
6815
|
-
if (idx < 0) {
|
|
6816
|
-
console.log(dim(" (cancelled — nothing was saved)\n"));
|
|
6817
|
-
return;
|
|
6818
|
-
}
|
|
6819
|
-
const model = rows[idx].model;
|
|
6820
|
-
|
|
6821
|
-
// SAVE: the cloud's row and key first (the agent's key is found through them), then the agent.
|
|
6822
|
-
if (newRow) {
|
|
6823
|
-
const ok = await withPendingStatus("saving the Ollama cloud provider", () =>
|
|
6824
|
-
md.saveWorkerSettings(apiBase, workerKey, { agentCodingBackends: [...existing, { ...newRow, defaultModel: model }] })
|
|
6825
|
-
);
|
|
6826
|
-
if (!ok) {
|
|
6827
|
-
console.log(red(" couldn't save the provider — nothing changed.\n"));
|
|
6828
|
-
return;
|
|
6829
|
-
}
|
|
6830
|
-
row = newRow;
|
|
6831
|
-
console.log(GUTTER + dim(`${target.host} is also offered in /model now.`));
|
|
6832
|
-
}
|
|
6833
|
-
if (target.cloud && key && !hasBoundKey) {
|
|
6834
|
-
const id = await withPendingStatus("saving the key", () => addCodingKey("Ollama cloud", key, String(row?.id ?? "")));
|
|
6835
|
-
if (!id) {
|
|
6836
|
-
console.log(red(" couldn't save the key — the agent was not changed.\n"));
|
|
6837
|
-
return;
|
|
6838
|
-
}
|
|
6839
|
-
}
|
|
6840
|
-
await saveAgentModel(target.agentUrl, model, target.host);
|
|
6511
|
+
if (pick.action === "add") return addServer({ role: "agent", dialect: "openai" });
|
|
6512
|
+
// A server already set up: the same screen, URL known — its key is reused, its models listed.
|
|
6513
|
+
await addServer({ role: "agent", dialect: String(pick.row.kind ?? "") === "ollama" ? "ollama" : "openai", url: String(pick.row.url ?? "") });
|
|
6841
6514
|
}
|
|
6842
6515
|
|
|
6843
6516
|
/**
|
|
@@ -6930,8 +6603,8 @@ async function chooseAgentModel() {
|
|
|
6930
6603
|
);
|
|
6931
6604
|
const action = go >= 0 ? options[go].action : "leave";
|
|
6932
6605
|
if (action === "key") await enterAgentProviderKey(source.row, listHost);
|
|
6933
|
-
else if (action === "ollama") await
|
|
6934
|
-
else if (action === "provider") await
|
|
6606
|
+
else if (action === "ollama") await addServer({ role: "agent", dialect: "ollama" });
|
|
6607
|
+
else if (action === "provider") await addServer({ role: "agent", dialect: "openai" });
|
|
6935
6608
|
else console.log("");
|
|
6936
6609
|
return;
|
|
6937
6610
|
}
|
|
@@ -6947,9 +6620,15 @@ async function chooseAgentModel() {
|
|
|
6947
6620
|
console.log(dim(" (cancelled)\n"));
|
|
6948
6621
|
return;
|
|
6949
6622
|
}
|
|
6950
|
-
if (firstAction === "ollama") return
|
|
6623
|
+
if (firstAction === "ollama") return addServer({ role: "agent", dialect: "ollama" });
|
|
6951
6624
|
if (firstAction === "provider") return chooseAgentProvider();
|
|
6952
|
-
if (firstAction === "key")
|
|
6625
|
+
if (firstAction === "key") {
|
|
6626
|
+
// Task #245: one key manager for every role — this server's keys, this folder's AGENT pick.
|
|
6627
|
+
const rowId = String(source.row?.id ?? "").trim() || String(source.row?.kind ?? "").trim();
|
|
6628
|
+
if (!rowId) return enterAgentProviderKey(source.row, listHost);
|
|
6629
|
+
await manageServerKeys({ role: "agent", backendId: rowId, host: listHost, model: am.currentAgentModel(settings) });
|
|
6630
|
+
return;
|
|
6631
|
+
}
|
|
6953
6632
|
|
|
6954
6633
|
const rows = am.agentModelRows(models, current);
|
|
6955
6634
|
console.log(dim(` Agent model — the one that TALKS (currently ${current || "unset"})`));
|
|
@@ -7721,6 +7400,55 @@ async function fetchCodingKeys() {
|
|
|
7721
7400
|
}
|
|
7722
7401
|
}
|
|
7723
7402
|
|
|
7403
|
+
/**
|
|
7404
|
+
* ONE SERVER'S KEYS, masked (task #245) — then the keys tied to no server, which carry no
|
|
7405
|
+
* backendId. Each says whether it is the server's `first` key and its `default`. null = an API
|
|
7406
|
+
* that predates the filter; the caller falls back to fetchCodingKeys.
|
|
7407
|
+
*/
|
|
7408
|
+
async function fetchServerKeys(backendId) {
|
|
7409
|
+
try {
|
|
7410
|
+
const r = await fetch(`${apiBase}/api/worker/coding-keys?backendId=${encodeURIComponent(backendId)}`, {
|
|
7411
|
+
headers: { "X-Worker-Key": workerKey },
|
|
7412
|
+
signal: AbortSignal.timeout(API_CALL_TIMEOUT_MS),
|
|
7413
|
+
});
|
|
7414
|
+
if (!r.ok) return null;
|
|
7415
|
+
const j = await r.json().catch(() => ({}));
|
|
7416
|
+
return Array.isArray(j?.keys) ? j.keys : null;
|
|
7417
|
+
} catch {
|
|
7418
|
+
return null;
|
|
7419
|
+
}
|
|
7420
|
+
}
|
|
7421
|
+
|
|
7422
|
+
/** "Make this the server's default key" (task #245). Returns true when the API took it. */
|
|
7423
|
+
async function setServerDefaultKey(backendId, keyId) {
|
|
7424
|
+
try {
|
|
7425
|
+
const r = await fetch(`${apiBase}/api/worker/server-default-key`, {
|
|
7426
|
+
method: "PUT",
|
|
7427
|
+
headers: { "Content-Type": "application/json", "X-Worker-Key": workerKey },
|
|
7428
|
+
body: JSON.stringify({ backendId, keyId }),
|
|
7429
|
+
signal: AbortSignal.timeout(API_CALL_TIMEOUT_MS),
|
|
7430
|
+
});
|
|
7431
|
+
return r.ok;
|
|
7432
|
+
} catch {
|
|
7433
|
+
return false;
|
|
7434
|
+
}
|
|
7435
|
+
}
|
|
7436
|
+
|
|
7437
|
+
/** What deleting a key would change — wasFirst / wasDefault / folders — WITHOUT deleting it. */
|
|
7438
|
+
async function previewDeleteKey(id) {
|
|
7439
|
+
try {
|
|
7440
|
+
const r = await fetch(`${apiBase}/api/worker/coding-keys/${encodeURIComponent(id)}?dryRun=1`, {
|
|
7441
|
+
method: "DELETE",
|
|
7442
|
+
headers: { "X-Worker-Key": workerKey },
|
|
7443
|
+
signal: AbortSignal.timeout(API_CALL_TIMEOUT_MS),
|
|
7444
|
+
});
|
|
7445
|
+
if (!r.ok) return null;
|
|
7446
|
+
return await r.json().catch(() => null);
|
|
7447
|
+
} catch {
|
|
7448
|
+
return null;
|
|
7449
|
+
}
|
|
7450
|
+
}
|
|
7451
|
+
|
|
7724
7452
|
/**
|
|
7725
7453
|
* Add a named key. Returns its id, or "" on failure.
|
|
7726
7454
|
*
|
|
@@ -7783,6 +7511,9 @@ async function fetchCodingChoice(folderHash) {
|
|
|
7783
7511
|
codingModel: str(c.codingModel),
|
|
7784
7512
|
codingLabel: str(c.codingLabel),
|
|
7785
7513
|
codingUrl: str(c.codingUrl),
|
|
7514
|
+
// Task #245: this folder's own agent / embed keys ("" from an API that predates them).
|
|
7515
|
+
agentKeyId: str(c.agentKeyId),
|
|
7516
|
+
embedKeyId: str(c.embedKeyId),
|
|
7786
7517
|
};
|
|
7787
7518
|
}
|
|
7788
7519
|
|
|
@@ -7809,32 +7540,8 @@ function folderHashFor(cwd) {
|
|
|
7809
7540
|
* larger promise than the question implied.
|
|
7810
7541
|
*/
|
|
7811
7542
|
async function promptNewCodingKey(backendId = "", provider = "") {
|
|
7812
|
-
|
|
7813
|
-
|
|
7814
|
-
console.log(GUTTER + dim(SECRET_KEY_HINT));
|
|
7815
|
-
const secret = (await askSecret(` API key${name ? ` for ${name}` : ""}: `)).trim();
|
|
7816
|
-
if (!secret) {
|
|
7817
|
-
console.log(GUTTER + dim("(cancelled — nothing was added)\n"));
|
|
7818
|
-
return "";
|
|
7819
|
-
}
|
|
7820
|
-
// The same shape check as the add-provider flow. The provider LABEL is its host, so the
|
|
7821
|
-
// locality can be derived from it — which matters: a local server accepts any string, and
|
|
7822
|
-
// warning a vLLM user that "abc" is not a key would be telling them their working setup is
|
|
7823
|
-
// wrong. An unknown label falls through as hosted, where the checks belong.
|
|
7824
|
-
{
|
|
7825
|
-
const ps = await import("./coding-provider-setup.mjs");
|
|
7826
|
-
const shape = ps.describeApiKeyShape(secret, {
|
|
7827
|
-
host: provider,
|
|
7828
|
-
locality: provider ? ps.hostLocality(provider) : "",
|
|
7829
|
-
});
|
|
7830
|
-
if (shape.level) {
|
|
7831
|
-
console.log(GUTTER + (shape.level === "warn" ? yellow(shape.note) : dim(shape.note)));
|
|
7832
|
-
if (shape.level === "warn" && !(await askYesNo("Use that key anyway?", false))) {
|
|
7833
|
-
console.log(GUTTER + dim("(cancelled — nothing was added)\n"));
|
|
7834
|
-
return "";
|
|
7835
|
-
}
|
|
7836
|
-
}
|
|
7837
|
-
}
|
|
7543
|
+
const { name, secret } = await askServerKey({ host: provider });
|
|
7544
|
+
if (!secret) return "";
|
|
7838
7545
|
const id = await addCodingKey(name, secret, backendId);
|
|
7839
7546
|
console.log(
|
|
7840
7547
|
id
|
|
@@ -7879,7 +7586,8 @@ async function replaceCodingKey() {
|
|
|
7879
7586
|
const ps = await import("./coding-provider-setup.mjs");
|
|
7880
7587
|
const keys = allKeys ? ps.keysForBackend(allKeys, ks.backendId) : null;
|
|
7881
7588
|
if (keys && keys.length >= 1) {
|
|
7882
|
-
|
|
7589
|
+
// ONE SERVER'S KEYS (task #245): the same picker /agent-model and /embed-model open.
|
|
7590
|
+
await manageServerKeys({ role: "coding", backendId: ks.backendId, host: ks.provider || ks.backendId || "this server", model: ks.model });
|
|
7883
7591
|
return;
|
|
7884
7592
|
}
|
|
7885
7593
|
|
|
@@ -7900,13 +7608,10 @@ async function replaceCodingKey() {
|
|
|
7900
7608
|
// the strength of a question that was asked about one provider.
|
|
7901
7609
|
const id = await promptNewCodingKey(ks.backendId, ks.provider);
|
|
7902
7610
|
if (id) {
|
|
7903
|
-
//
|
|
7904
|
-
//
|
|
7905
|
-
//
|
|
7906
|
-
|
|
7907
|
-
// ctrl+k is opened by hand. Pin now so the new key is chosen from the start.
|
|
7908
|
-
sessionCodingKeyId = id;
|
|
7909
|
-
await saveSessionCodingKeyId(resolve(process.cwd()));
|
|
7611
|
+
// THE SERVER'S FIRST KEY IS ITS DEFAULT (task #245) — the API stamped it — so this folder
|
|
7612
|
+
// FOLLOWS it rather than pinning a copy: a later "make X the default" then reaches this
|
|
7613
|
+
// folder too, which is what "the first choice is the default for every later project" means.
|
|
7614
|
+
await saveFolderKeyPick("coding", "");
|
|
7910
7615
|
codingKeyMissingKnown = false;
|
|
7911
7616
|
keyChoicePending = false;
|
|
7912
7617
|
const kd = await fetchCodingKey(ks.backendId || "");
|
|
@@ -7943,127 +7648,196 @@ async function replaceCodingKey() {
|
|
|
7943
7648
|
}
|
|
7944
7649
|
|
|
7945
7650
|
/**
|
|
7946
|
-
*
|
|
7947
|
-
*
|
|
7948
|
-
*
|
|
7949
|
-
*
|
|
7950
|
-
*
|
|
7651
|
+
* THE KEYS OF ONE SERVER — switch, add, make default, delete — ONE FLOW FOR ALL THREE ROLES
|
|
7652
|
+
* (task #245). /model's ctrl+k, /agent-model and /embed-model all land here, so a key behaves the
|
|
7653
|
+
* same whichever model you reached it from.
|
|
7654
|
+
*
|
|
7655
|
+
* · LISTS THIS SERVER'S KEYS ONLY ("keys of current url only"), then any key tied to no server
|
|
7656
|
+
* yet — picking one of those ties it to this server, since a key belongs to one URL.
|
|
7657
|
+
* · A PICK asks where it applies: just this folder's <role>, or the server's default for every
|
|
7658
|
+
* folder without its own pick. The first key chosen for a server is already its default.
|
|
7659
|
+
* · DELETE previews first (the API's dry run) and says what goes with it — above all when it is
|
|
7660
|
+
* the FIRST key entered for the server, the one its model list was loaded with.
|
|
7661
|
+
*
|
|
7662
|
+
* `role` is "coding" | "agent" | "embed"; `backendId` the server's row id; `host` its name.
|
|
7663
|
+
* Returns the id now in use for this folder's role, or "" when nothing changed.
|
|
7951
7664
|
*/
|
|
7952
|
-
async function
|
|
7953
|
-
const
|
|
7954
|
-
const
|
|
7955
|
-
|
|
7956
|
-
|
|
7665
|
+
async function manageServerKeys({ role = "coding", backendId, host, model = "" }) {
|
|
7666
|
+
const sk = await import("./server-keys.mjs");
|
|
7667
|
+
const { label: roleLabel } = sk.roleOf(role);
|
|
7668
|
+
const folderKeyId = role === "agent" ? sessionAgentKeyId : role === "embed" ? sessionEmbedKeyId : sessionCodingKeyId;
|
|
7669
|
+
// No server id = an API older than provider rows (#223): it cannot scope or default a key, so
|
|
7670
|
+
// the account's keys are listed and a pick is this folder's own.
|
|
7671
|
+
const keys = await withPendingStatus(`reading the keys for ${host}`, () =>
|
|
7672
|
+
backendId ? fetchServerKeys(backendId) : fetchCodingKeys()
|
|
7957
7673
|
);
|
|
7674
|
+
if (!keys) {
|
|
7675
|
+
console.log(red(` couldn't read the keys for ${host} — is this machine's API up to date? (\`gu api restart\`)\n`));
|
|
7676
|
+
paintBar();
|
|
7677
|
+
return "";
|
|
7678
|
+
}
|
|
7679
|
+
const inUse = sk.keyInUse(keys, backendId, folderKeyId);
|
|
7680
|
+
const rows = keys.map((k) => {
|
|
7681
|
+
const tags = sk.keyTags(k, { folderKeyId, inUseId: inUse?.id ?? "" });
|
|
7682
|
+
return `${k.name}${dim(` ····${k.tail}`)}${tags.length ? accent(` (${tags.join(" · ")})`) : ""}`;
|
|
7683
|
+
});
|
|
7958
7684
|
const ADD = rows.length;
|
|
7959
7685
|
rows.push(dim("add a new key…"));
|
|
7960
|
-
console.log(
|
|
7961
|
-
"\n" + GUTTER +
|
|
7962
|
-
(keys.length > 1
|
|
7963
|
-
? dim(`Which key should ${folderLabel()} use for `) + accent(ks.model) + dim("?\n")
|
|
7964
|
-
: dim(`${folderLabel()} uses this key for `) + accent(ks.model) + dim(" — or add another.\n"))
|
|
7965
|
-
);
|
|
7686
|
+
console.log("\n" + GUTTER + dim(`Keys for ${host} — ${folderLabel()}'s ${roleLabel}${model ? ` (${model})` : ""}\n`));
|
|
7966
7687
|
console.log(GUTTER + dim("Enter chooses · ctrl+d deletes the highlighted key\n"));
|
|
7967
|
-
const
|
|
7968
|
-
const picked = await pickFromList(rows, at, { allowDelete: true });
|
|
7688
|
+
const picked = await pickFromList(rows, Math.max(0, keys.findIndex((k) => k.id === inUse?.id)), { allowDelete: true });
|
|
7969
7689
|
const idx = picked?.index ?? -1;
|
|
7970
7690
|
if (idx < 0) {
|
|
7971
|
-
console.log(GUTTER + dim("(cancelled —
|
|
7691
|
+
console.log(GUTTER + dim("(cancelled — nothing changed)\n"));
|
|
7972
7692
|
paintBar();
|
|
7973
|
-
return;
|
|
7693
|
+
return "";
|
|
7974
7694
|
}
|
|
7975
7695
|
|
|
7976
7696
|
if (picked.action === "delete") {
|
|
7977
|
-
// The "add a new key…" row is not a key. Deleting it is meaningless, and treating the index
|
|
7978
|
-
// as one would delete whichever key happens to sit last.
|
|
7979
7697
|
if (idx === ADD) {
|
|
7980
7698
|
console.log(GUTTER + dim("(that row is not a key — nothing deleted)\n"));
|
|
7981
7699
|
paintBar();
|
|
7982
|
-
return;
|
|
7700
|
+
return "";
|
|
7983
7701
|
}
|
|
7984
|
-
await
|
|
7985
|
-
|
|
7702
|
+
await deleteServerKeyFlow({
|
|
7703
|
+
role, key: keys[idx], host, folderKeyId,
|
|
7704
|
+
lastOfServer: keys.filter((k) => k.backendId === backendId).length === 1 && keys[idx].backendId === backendId,
|
|
7705
|
+
});
|
|
7706
|
+
return "";
|
|
7986
7707
|
}
|
|
7987
7708
|
|
|
7988
7709
|
if (idx === ADD) {
|
|
7989
|
-
|
|
7990
|
-
|
|
7991
|
-
|
|
7992
|
-
|
|
7993
|
-
// through the picker, which is the whole reason someone opened it.
|
|
7994
|
-
if (id) {
|
|
7995
|
-
sessionCodingKeyId = id;
|
|
7996
|
-
await saveSessionCodingKeyId(resolve(process.cwd()));
|
|
7997
|
-
codingKeyMissingKnown = false;
|
|
7998
|
-
keyChoicePending = false;
|
|
7710
|
+
const id = await promptNewCodingKey(backendId, host);
|
|
7711
|
+
if (!id) {
|
|
7712
|
+
paintBar();
|
|
7713
|
+
return "";
|
|
7999
7714
|
}
|
|
8000
|
-
|
|
8001
|
-
|
|
7715
|
+
// A key added here is one you mean to use. The API made it the server's default when it was
|
|
7716
|
+
// the server's first; otherwise the question is the same as for a picked key.
|
|
7717
|
+
const fresh = await fetchServerKeys(backendId);
|
|
7718
|
+
const added = fresh?.find((k) => k.id === id);
|
|
7719
|
+
if (added?.default) {
|
|
7720
|
+
console.log(accent(` ✓ ${added.name} is ${host}'s default key\n`));
|
|
7721
|
+
paintBar();
|
|
7722
|
+
return id;
|
|
7723
|
+
}
|
|
7724
|
+
return await applyKeyPick({ role, backendId, host, key: added ?? { id, name: "the new key", tail: "" } });
|
|
7725
|
+
}
|
|
7726
|
+
|
|
7727
|
+
const key = keys[idx];
|
|
7728
|
+
// A KEY TIED TO NO SERVER is tied to this one before it is used: it belongs to one URL now.
|
|
7729
|
+
if (!key.backendId && backendId) {
|
|
7730
|
+
const ok = await withPendingStatus(`tying ${key.name} to ${host}`, () => bindKeyToServer(key.id, backendId));
|
|
7731
|
+
if (!ok) {
|
|
7732
|
+
console.log(red(` couldn't tie ${key.name} to ${host} — nothing changed.\n`));
|
|
7733
|
+
paintBar();
|
|
7734
|
+
return "";
|
|
7735
|
+
}
|
|
7736
|
+
}
|
|
7737
|
+
return await applyKeyPick({ role, backendId, host, key });
|
|
7738
|
+
}
|
|
7739
|
+
|
|
7740
|
+
/**
|
|
7741
|
+
* Where a picked key applies (task #245): just this folder's role, or the server's default.
|
|
7742
|
+
* Picking the server's default itself in a folder that had its own pick CLEARS the pick — the
|
|
7743
|
+
* folder goes back to following the default, which is what choosing the default means.
|
|
7744
|
+
*/
|
|
7745
|
+
async function applyKeyPick({ role, backendId, host, key }) {
|
|
7746
|
+
const sk = await import("./server-keys.mjs");
|
|
7747
|
+
const { label: roleLabel } = sk.roleOf(role);
|
|
7748
|
+
let scope = "folder";
|
|
7749
|
+
if (!backendId) {
|
|
7750
|
+
await saveFolderKeyPick(role, key.id);
|
|
7751
|
+
console.log(accent(` ✓ ${folderLabel()}'s ${roleLabel} → ${key.name}`) + dim(` ····${key.tail}\n`));
|
|
7752
|
+
} else if (key.default) {
|
|
7753
|
+
await saveFolderKeyPick(role, "");
|
|
7754
|
+
console.log(accent(` ✓ ${folderLabel()}'s ${roleLabel} → ${key.name}`) + dim(` ····${key.tail} · ${host}'s default\n`));
|
|
7755
|
+
} else {
|
|
7756
|
+
const choices = sk.scopeChoices({ host, role });
|
|
7757
|
+
console.log("\n" + GUTTER + dim(`Use ${key.name} for…`));
|
|
7758
|
+
const at = await pickFromList(choices.map((c) => c.label), 0);
|
|
7759
|
+
if (at < 0) {
|
|
7760
|
+
console.log(GUTTER + dim("(cancelled — nothing changed)\n"));
|
|
7761
|
+
paintBar();
|
|
7762
|
+
return "";
|
|
7763
|
+
}
|
|
7764
|
+
scope = choices[at].scope;
|
|
7765
|
+
if (scope === "default") {
|
|
7766
|
+
const ok = await withPendingStatus(`making ${key.name} the default for ${host}`, () => setServerDefaultKey(backendId, key.id));
|
|
7767
|
+
if (!ok) {
|
|
7768
|
+
console.log(red(" couldn't change the default — nothing changed.\n"));
|
|
7769
|
+
paintBar();
|
|
7770
|
+
return "";
|
|
7771
|
+
}
|
|
7772
|
+
// This folder follows the default now, rather than pinning a copy of it.
|
|
7773
|
+
await saveFolderKeyPick(role, "");
|
|
7774
|
+
console.log(accent(` ✓ ${key.name} is now ${host}'s default`) + dim(` ····${key.tail} · every folder without its own pick\n`));
|
|
7775
|
+
} else {
|
|
7776
|
+
await saveFolderKeyPick(role, key.id);
|
|
7777
|
+
console.log(accent(` ✓ ${folderLabel()}'s ${roleLabel} → ${key.name}`) + dim(` ····${key.tail} · this folder only\n`));
|
|
7778
|
+
}
|
|
7779
|
+
}
|
|
7780
|
+
if (role === "coding") {
|
|
7781
|
+
keyChoicePending = false;
|
|
7782
|
+
codingKeyMissingKnown = false;
|
|
7783
|
+
sessionApiKeyTail = key.tail || "";
|
|
8002
7784
|
}
|
|
8003
|
-
sessionCodingKeyId = keys[idx].id;
|
|
8004
|
-
await saveSessionCodingKeyId(resolve(process.cwd()));
|
|
8005
|
-
keyChoicePending = false; // chosen — the bar's mark comes off
|
|
8006
|
-
codingKeyMissingKnown = false;
|
|
8007
|
-
// The picked row already carries the correct masked tail. Do NOT refetch via
|
|
8008
|
-
// fetchCodingKey(backendId): that returns the key the provider would DEFAULT to, not the key
|
|
8009
|
-
// just chosen by id — and for an UNBOUND key backendId is "", so it answers the account
|
|
8010
|
-
// default. Either way the bar kept showing the old/default key's tail no matter what you
|
|
8011
|
-
// picked (reported: "choosing between multiple keys doesn't change the tail").
|
|
8012
|
-
sessionApiKeyTail = keys[idx].tail || "";
|
|
8013
|
-
console.log(
|
|
8014
|
-
accent(` ✓ ${folderLabel()} → ${keys[idx].name}`) + dim(` ····${keys[idx].tail}\n`)
|
|
8015
|
-
);
|
|
8016
7785
|
paintBar();
|
|
7786
|
+
return key.id;
|
|
7787
|
+
}
|
|
7788
|
+
|
|
7789
|
+
/** Tie a key that belongs to no server to this one (PATCH, the binding only — never the secret). */
|
|
7790
|
+
async function bindKeyToServer(id, backendId) {
|
|
7791
|
+
try {
|
|
7792
|
+
const r = await fetch(`${apiBase}/api/worker/coding-keys/${encodeURIComponent(id)}`, {
|
|
7793
|
+
method: "PATCH",
|
|
7794
|
+
headers: { "Content-Type": "application/json", "X-Worker-Key": workerKey },
|
|
7795
|
+
body: JSON.stringify({ backendId }),
|
|
7796
|
+
signal: AbortSignal.timeout(API_CALL_TIMEOUT_MS),
|
|
7797
|
+
});
|
|
7798
|
+
return r.ok;
|
|
7799
|
+
} catch {
|
|
7800
|
+
return false;
|
|
7801
|
+
}
|
|
8017
7802
|
}
|
|
8018
7803
|
|
|
8019
7804
|
/**
|
|
8020
|
-
* Confirm and delete one key (task #222).
|
|
8021
|
-
*
|
|
8022
|
-
* ASKED, NOT ASSUMED. The secret is not recoverable from anything the API keeps — there is no
|
|
8023
|
-
* undo and no copy — so this is one of the few actions in the terminal that earns a yes/no. The
|
|
8024
|
-
* question names the key AND its tail, because "delete accounting?" is not enough when two keys
|
|
8025
|
-
* can share a name and only the last four characters tell them apart.
|
|
7805
|
+
* Confirm and delete one key (task #222; the warnings are #245's).
|
|
8026
7806
|
*
|
|
8027
|
-
*
|
|
8028
|
-
*
|
|
8029
|
-
*
|
|
7807
|
+
* ASKED, NOT ASSUMED — the secret is not recoverable. What goes with it is read from the API's
|
|
7808
|
+
* dry run and said BEFORE the question: the FIRST key entered for the server (its model list was
|
|
7809
|
+
* loaded with it), the server's default, this folder's pick, other folders' picks.
|
|
8030
7810
|
*/
|
|
8031
|
-
async function
|
|
8032
|
-
const
|
|
8033
|
-
const
|
|
8034
|
-
const isLast = keys.length === 1;
|
|
7811
|
+
async function deleteServerKeyFlow({ role, key, host, folderKeyId, lastOfServer = false }) {
|
|
7812
|
+
const sk = await import("./server-keys.mjs");
|
|
7813
|
+
const preview = await withPendingStatus("checking what uses this key", () => previewDeleteKey(key.id));
|
|
8035
7814
|
console.log("");
|
|
8036
|
-
|
|
8037
|
-
|
|
8038
|
-
|
|
8039
|
-
|
|
8040
|
-
|
|
8041
|
-
|
|
8042
|
-
|
|
8043
|
-
}
|
|
8044
|
-
const yes = await askYesNo(
|
|
8045
|
-
`Delete the API key "${victim.name}" (····${victim.tail})? This cannot be undone.`,
|
|
8046
|
-
false
|
|
8047
|
-
);
|
|
7815
|
+
for (const line of sk.deleteWarnings(preview, {
|
|
7816
|
+
host,
|
|
7817
|
+
isFolderPick: key.id === folderKeyId,
|
|
7818
|
+
roleLabel: sk.roleOf(role).label,
|
|
7819
|
+
lastOfServer,
|
|
7820
|
+
})) {
|
|
7821
|
+
console.log(GUTTER + orange(` ${line}`));
|
|
7822
|
+
}
|
|
7823
|
+
const yes = await askYesNo(`Delete the API key "${key.name}" (····${key.tail})? This cannot be undone.`, false);
|
|
8048
7824
|
if (!yes) {
|
|
8049
7825
|
console.log(GUTTER + dim("(cancelled — nothing was deleted)\n"));
|
|
8050
7826
|
paintBar();
|
|
8051
7827
|
return;
|
|
8052
7828
|
}
|
|
8053
|
-
const out = await deleteCodingKey(
|
|
7829
|
+
const out = await deleteCodingKey(key.id);
|
|
8054
7830
|
if (!out.ok) {
|
|
8055
7831
|
console.log(red(" couldn't delete the key — try again, or remove it in web Settings → Agent\n"));
|
|
8056
7832
|
paintBar();
|
|
8057
7833
|
return;
|
|
8058
7834
|
}
|
|
8059
|
-
// A folder pointing at a key
|
|
8060
|
-
|
|
8061
|
-
|
|
8062
|
-
|
|
8063
|
-
await saveSessionCodingKeyId(resolve(process.cwd()));
|
|
7835
|
+
// A folder pointing at a deleted key would keep sending a dead id; put it back on the default.
|
|
7836
|
+
for (const r of ["coding", "agent", "embed"]) {
|
|
7837
|
+
const mine = r === "agent" ? sessionAgentKeyId : r === "embed" ? sessionEmbedKeyId : sessionCodingKeyId;
|
|
7838
|
+
if (mine === key.id) await saveFolderKeyPick(r, "");
|
|
8064
7839
|
}
|
|
8065
|
-
console.log(accent(` ✓ deleted ${out.name ||
|
|
8066
|
-
dim(` ····${victim.tail} · ${out.remaining} key(s) left\n`));
|
|
7840
|
+
console.log(accent(` ✓ deleted ${out.name || key.name}`) + dim(` ····${key.tail} · ${out.remaining} key(s) left\n`));
|
|
8067
7841
|
paintBar();
|
|
8068
7842
|
}
|
|
8069
7843
|
|
|
@@ -8641,6 +8415,10 @@ let sessionCodingModel = "";
|
|
|
8641
8415
|
* a folder keeps meaning until someone picks.
|
|
8642
8416
|
*/
|
|
8643
8417
|
let sessionCodingKeyId = "";
|
|
8418
|
+
// THIS FOLDER'S OWN KEY for the agent / the embedder (task #245) — per role, like the coder's.
|
|
8419
|
+
// "" = the server's default key, which is what a folder means until someone picks otherwise.
|
|
8420
|
+
let sessionAgentKeyId = "";
|
|
8421
|
+
let sessionEmbedKeyId = "";
|
|
8644
8422
|
// Last 4 characters of the current API key, for display in the bottom bar
|
|
8645
8423
|
let sessionApiKeyTail = "";
|
|
8646
8424
|
// The friendly model name behind sessionCodingModel, for display only: the token footer
|
|
@@ -8936,24 +8714,17 @@ async function runAgentTurn(history, { attachJobId = "" } = {}) {
|
|
|
8936
8714
|
// Deploy target chosen via /server — host/user only (no secret), for key-auth deploys.
|
|
8937
8715
|
...(selectedServer ? { deployServer: selectedServer } : {}),
|
|
8938
8716
|
...(sessionCodingModel ? { codingModelOverride: sessionCodingModel } : {}),
|
|
8939
|
-
//
|
|
8940
|
-
//
|
|
8941
|
-
// model i choose to use … key belongs to model".
|
|
8942
|
-
//
|
|
8943
|
-
// Task #222 put a chosen key on each folder, and that is what produced the reported
|
|
8944
|
-
// failure: switching between two OpenAI-compatible providers inside one folder changed the
|
|
8945
|
-
// model and KEPT the key, because an unbound key is eligible everywhere. Each provider was
|
|
8946
|
-
// then handed the other's credential and answered 401 —
|
|
8947
|
-
// api.openai.com Incorrect API key provided: sk-0Ug2d***Vgic
|
|
8948
|
-
// api.moonshot.ai Invalid Authentication
|
|
8717
|
+
// THIS FOLDER'S OWN KEY PICKS, PER ROLE (task #245) — ids, never secrets, recorded on the
|
|
8718
|
+
// job so two folders' concurrent turns each resolve their own.
|
|
8949
8719
|
//
|
|
8950
|
-
//
|
|
8951
|
-
//
|
|
8952
|
-
//
|
|
8953
|
-
// the API
|
|
8954
|
-
//
|
|
8955
|
-
|
|
8956
|
-
|
|
8720
|
+
// They were dropped once (#222 → the "folder names no key" rule) because an UNBOUND key
|
|
8721
|
+
// picked in a folder followed the folder from provider to provider, and each provider was
|
|
8722
|
+
// handed the other's credential (api.openai.com and api.moonshot.ai both 401'd). That is
|
|
8723
|
+
// now impossible at the API: a pick counts only when it is one of the turn's SERVER'S OWN
|
|
8724
|
+
// keys (resolveCodingApiKey), and anything else falls back to that server's default key.
|
|
8725
|
+
...(sessionCodingKeyId ? { codingKeyId: sessionCodingKeyId } : {}),
|
|
8726
|
+
...(sessionAgentKeyId ? { agentKeyId: sessionAgentKeyId } : {}),
|
|
8727
|
+
...(sessionEmbedKeyId ? { embedKeyId: sessionEmbedKeyId } : {}),
|
|
8957
8728
|
}),
|
|
8958
8729
|
});
|
|
8959
8730
|
const data = res ? await res.json().catch(() => ({})) : {};
|
|
@@ -10328,10 +10099,14 @@ async function main() {
|
|
|
10328
10099
|
sessionCodingUrl = dbChoice.codingUrl;
|
|
10329
10100
|
}
|
|
10330
10101
|
if (dbChoice.codingKeyId) sessionCodingKeyId = dbChoice.codingKeyId;
|
|
10102
|
+
sessionAgentKeyId = dbChoice.agentKeyId;
|
|
10103
|
+
sessionEmbedKeyId = dbChoice.embedKeyId;
|
|
10331
10104
|
// Refresh the local cache so the file matches the database.
|
|
10332
10105
|
try {
|
|
10333
10106
|
await updateSessionFile(resolve(process.cwd()), (raw) => {
|
|
10334
10107
|
raw.codingKeyId = dbChoice.codingKeyId;
|
|
10108
|
+
raw.agentKeyId = dbChoice.agentKeyId;
|
|
10109
|
+
raw.embedKeyId = dbChoice.embedKeyId;
|
|
10335
10110
|
raw.codingModel = dbChoice.codingModel;
|
|
10336
10111
|
raw.codingLabel = dbChoice.codingLabel;
|
|
10337
10112
|
raw.codingUrl = dbChoice.codingUrl;
|
|
@@ -10349,6 +10124,8 @@ async function main() {
|
|
|
10349
10124
|
sessionCodingUrl = c.url;
|
|
10350
10125
|
}
|
|
10351
10126
|
sessionCodingKeyId = await loadSessionCodingKeyId(resolve(process.cwd())); // task #222
|
|
10127
|
+
sessionAgentKeyId = await loadSessionFolderKeyId(resolve(process.cwd()), "agent"); // task #245
|
|
10128
|
+
sessionEmbedKeyId = await loadSessionFolderKeyId(resolve(process.cwd()), "embed");
|
|
10352
10129
|
}
|
|
10353
10130
|
}
|
|
10354
10131
|
// Fetch the API key tail after loading the session, if a key is configured
|