@tiens.nguyen/gu-cli 1.0.721 → 1.0.723
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 +213 -0
- package/agent-model-command.mjs +3 -1
- package/coding-provider-setup.mjs +35 -0
- package/gonext_agent_chat.py +15 -13
- package/gu-repl.mjs +749 -865
- package/ollama-setup.mjs +3 -0
- package/package.json +3 -2
- package/server-keys.mjs +97 -0
- package/settings-transfer.mjs +156 -13
- package/terminal-layout.mjs +40 -0
- package/ollama-host-snippets.mjs +0 -36
package/gu-repl.mjs
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
* Commands: /exit /model /reset /help Ctrl-C stops following the current run.
|
|
25
25
|
*/
|
|
26
26
|
import { readFile, mkdir, writeFile, unlink, rename } from "node:fs/promises";
|
|
27
|
-
import { existsSync, statSync, readFileSync, writeFileSync } from "node:fs";
|
|
27
|
+
import { existsSync, statSync, readFileSync, writeFileSync, readdirSync, openSync, readSync, closeSync } from "node:fs";
|
|
28
28
|
import { spawn } from "node:child_process";
|
|
29
29
|
// FIRST, before anything that could itself fail on an old runtime: npm only WARNS about
|
|
30
30
|
// `engines`, so an unsupported Node installs fine and then breaks somewhere unrelated.
|
|
@@ -73,6 +73,10 @@ import {
|
|
|
73
73
|
answerBlock,
|
|
74
74
|
updateRestartNote,
|
|
75
75
|
describeInstallFailure,
|
|
76
|
+
CLI_UPDATE_SHELL_COMMAND,
|
|
77
|
+
isFreshPublishMiss,
|
|
78
|
+
freshPublishNote,
|
|
79
|
+
lazyModuleNames,
|
|
76
80
|
inputZoneRows,
|
|
77
81
|
INPUT_HEAD_ROWS,
|
|
78
82
|
RAG_STORAGE,
|
|
@@ -494,11 +498,11 @@ const fmtTokens = (n) => {
|
|
|
494
498
|
*/
|
|
495
499
|
const PICK_HINT = "↑/↓ then Enter · Esc to cancel";
|
|
496
500
|
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 =
|
|
501
|
+
// How many EXTRA models one server offers alongside its default. Task #245 made it a rule, not a
|
|
502
|
+
// readability cap: a server URL offers AT MOST 3 models, default included (MAX_MODELS_PER_SERVER
|
|
503
|
+
// in coding-provider-setup.mjs and api/src/store/settingsTypes.ts — the API refuses a row that
|
|
504
|
+
// grows past it). A server saved with more before the rule keeps them: see extraModelRoom.
|
|
505
|
+
const MAX_ALT_CODERS = 2;
|
|
502
506
|
const BULLET = "●"; // a neutral completed action (informational step)
|
|
503
507
|
const CHECK = "✓"; // a step that succeeded
|
|
504
508
|
const CROSS = "✗"; // a step that FAILED
|
|
@@ -795,8 +799,8 @@ const COMMANDS = [
|
|
|
795
799
|
{ name: "/proxy", usage: "/proxy [show|set|on|off|clear|test]", desc: "route MODEL calls through an HTTP proxy (local calls always go direct)" },
|
|
796
800
|
{ name: "/s3", usage: "/s3 [test]", desc: "set up the S3 bucket the RAG index lives in, and check the AWS keys work" },
|
|
797
801
|
{ name: "/context-window", usage: "/context-window [tokens|auto]", desc: "tell gu how big the coding model's context is — hosted models cannot be probed" },
|
|
798
|
-
{ name: "/save-settings", usage: "/save-settings [path]", desc: "write this account's
|
|
799
|
-
{ name: "/load-settings", usage: "/load-settings [path]", desc: "read that file back on another machine — asks before it replaces anything" },
|
|
802
|
+
{ name: "/save-settings", usage: "/save-settings [name|path]", desc: "write this account's providers, models and key NAMES to a file in your home folder (no secrets) — asks for a name, Enter keeps gu-settings.json" },
|
|
803
|
+
{ name: "/load-settings", usage: "/load-settings [name|path]", desc: "read that file back on another machine — picks from the settings files in your home folder, asks before it replaces anything" },
|
|
800
804
|
{ name: "/doctor", desc: "check every configured model ANSWERS — especially the embedder, which a turn only reaches after several steps" },
|
|
801
805
|
{ name: "/tokens", desc: "break down the last prompt sent to the coding model — how much of it was RAG" },
|
|
802
806
|
{ name: "/help", desc: "list these commands" },
|
|
@@ -1962,6 +1966,9 @@ function announceCliUpdate() {
|
|
|
1962
1966
|
* The picker names the consequence rather than the version, because the version is already on
|
|
1963
1967
|
* the button and the consequence is what the answer turns on.
|
|
1964
1968
|
*/
|
|
1969
|
+
/** How long to wait before retrying an install that hit a just-published version (ETARGET). */
|
|
1970
|
+
const UPDATE_RETRY_WAIT_MS = 15_000;
|
|
1971
|
+
|
|
1965
1972
|
async function runUpdate() {
|
|
1966
1973
|
const { cli, api } = availableUpdates;
|
|
1967
1974
|
const rows = [];
|
|
@@ -2013,8 +2020,17 @@ async function runUpdate() {
|
|
|
2013
2020
|
// stderr is captured rather than discarded so the reason can be shown. npm puts the
|
|
2014
2021
|
// useful line (EPERM, EACCES, ETARGET) there, and "it failed" without it leaves the
|
|
2015
2022
|
// user with nothing to act on — sudo and "close gu first" are different fixes.
|
|
2016
|
-
const
|
|
2017
|
-
|
|
2023
|
+
const install = () => spawnSync(CLI_UPDATE_SHELL_COMMAND,
|
|
2024
|
+
{ stdio: ["ignore", "ignore", "pipe"], shell: true, encoding: "utf8" });
|
|
2025
|
+
let r = install();
|
|
2026
|
+
// JUST PUBLISHED: the update check sees a new version the moment it lands, npm's cache and
|
|
2027
|
+
// CDN a few minutes later — ETARGET in between. Wait once and retry before calling it a
|
|
2028
|
+
// failure; if npm still has not caught up, say THAT, not a raw npm error.
|
|
2029
|
+
if (isFreshPublishMiss(r)) {
|
|
2030
|
+
await new Promise((res) => setTimeout(res, UPDATE_RETRY_WAIT_MS));
|
|
2031
|
+
r = install();
|
|
2032
|
+
if (isFreshPublishMiss(r)) throw new Error(freshPublishNote(cli));
|
|
2033
|
+
}
|
|
2018
2034
|
const failed = describeInstallFailure(r);
|
|
2019
2035
|
if (failed) throw new Error(failed);
|
|
2020
2036
|
});
|
|
@@ -4542,6 +4558,46 @@ async function saveSessionCodingKeyId(cwd) {
|
|
|
4542
4558
|
await persistCodingChoiceToServer(cwd);
|
|
4543
4559
|
}
|
|
4544
4560
|
|
|
4561
|
+
/** This folder's own key for a role (task #245), from the session-file cache. */
|
|
4562
|
+
async function loadSessionFolderKeyId(cwd, role) {
|
|
4563
|
+
const { roleOf } = await import("./server-keys.mjs");
|
|
4564
|
+
try {
|
|
4565
|
+
const raw = JSON.parse(await readFile(sessionFilePath(cwd), "utf8"));
|
|
4566
|
+
const v = raw?.[roleOf(role).field];
|
|
4567
|
+
return typeof v === "string" ? v.trim() : "";
|
|
4568
|
+
} catch {
|
|
4569
|
+
return "";
|
|
4570
|
+
}
|
|
4571
|
+
}
|
|
4572
|
+
|
|
4573
|
+
/**
|
|
4574
|
+
* THIS FOLDER'S OWN KEY FOR ONE ROLE (task #245) — the one place a folder's pick is written, for
|
|
4575
|
+
* all three roles. The session file is the offline cache; the database is the record, and its
|
|
4576
|
+
* write carries ONLY this role's field, which the API merges — so picking the agent's key can
|
|
4577
|
+
* never wipe the coder's. "" = back to the server's default.
|
|
4578
|
+
*/
|
|
4579
|
+
async function saveFolderKeyPick(role, id) {
|
|
4580
|
+
const { roleOf } = await import("./server-keys.mjs");
|
|
4581
|
+
const field = roleOf(role).field;
|
|
4582
|
+
if (role === "agent") sessionAgentKeyId = id;
|
|
4583
|
+
else if (role === "embed") sessionEmbedKeyId = id;
|
|
4584
|
+
else sessionCodingKeyId = id;
|
|
4585
|
+
const cwd = resolve(process.cwd());
|
|
4586
|
+
try {
|
|
4587
|
+
await updateSessionFile(cwd, (raw) => {
|
|
4588
|
+
raw[field] = id;
|
|
4589
|
+
raw.updatedAt = new Date().toISOString();
|
|
4590
|
+
});
|
|
4591
|
+
} catch (err) {
|
|
4592
|
+
console.error(dim(`(key choice save failed: ${err.message})`));
|
|
4593
|
+
}
|
|
4594
|
+
try {
|
|
4595
|
+
await saveCodingChoice(folderHashFor(cwd), { [field]: id });
|
|
4596
|
+
} catch (err) {
|
|
4597
|
+
console.error(dim(`(key choice could not reach the database: ${err.message})`));
|
|
4598
|
+
}
|
|
4599
|
+
}
|
|
4600
|
+
|
|
4545
4601
|
async function loadSessionCodingKeyId(cwd) {
|
|
4546
4602
|
try {
|
|
4547
4603
|
const raw = JSON.parse(await readFile(sessionFilePath(cwd), "utf8"));
|
|
@@ -5053,7 +5109,7 @@ async function reconfigureOllamaServer() {
|
|
|
5053
5109
|
const ticks = await pickManyFromList(
|
|
5054
5110
|
rest.map((m) => `${m.model}${am.formatSize(m.sizeBytes) ? dim(` ${am.formatSize(m.sizeBytes)}`) : ""}`),
|
|
5055
5111
|
rest.map((m, i) => (already.has(m.model) ? i : -1)).filter((i) => i >= 0),
|
|
5056
|
-
{ max: MAX_ALT_CODERS }
|
|
5112
|
+
{ max: Math.max(MAX_ALT_CODERS, rest.filter((m) => already.has(m.model)).length) }
|
|
5057
5113
|
);
|
|
5058
5114
|
// null is Escape and cancels the command; an EMPTY list is a legitimate "offer none".
|
|
5059
5115
|
if (ticks === null) {
|
|
@@ -5119,12 +5175,14 @@ async function reconfigureOllamaServer() {
|
|
|
5119
5175
|
const keys = await fetchCodingKeys();
|
|
5120
5176
|
const ps = await import("./coding-provider-setup.mjs");
|
|
5121
5177
|
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)) {
|
|
5178
|
+
const found = ps.providerRowForUrl(ps.existingBackendRows(fresh ?? {}), u);
|
|
5179
|
+
if (found && !am.boundKeyFor(keys, found.id)) {
|
|
5126
5180
|
console.log(yellow(` ${hostOf(u)} is the Ollama cloud — it needs its own API key (the model list worked without one).`));
|
|
5127
|
-
|
|
5181
|
+
// The shared key prompt (task #245); the server's first key becomes its default.
|
|
5182
|
+
const { name, secret } = await askServerKey({ host: hostOf(u) });
|
|
5183
|
+
if (secret && !(await withPendingStatus("saving the key", () => addCodingKey(name, secret, found.id)))) {
|
|
5184
|
+
console.log(red(" couldn't save the key — add it with ctrl+k.\n"));
|
|
5185
|
+
}
|
|
5128
5186
|
}
|
|
5129
5187
|
}
|
|
5130
5188
|
}
|
|
@@ -5234,7 +5292,8 @@ async function pickExtraModels(rest, preChecked = []) {
|
|
|
5234
5292
|
const ticks = await pickManyFromList(
|
|
5235
5293
|
rest,
|
|
5236
5294
|
rest.map((m, i) => (already.has(m) ? i : -1)).filter((i) => i >= 0),
|
|
5237
|
-
|
|
5295
|
+
// A server saved with more than the cap before #245 keeps them; it just cannot grow.
|
|
5296
|
+
{ max: Math.max(MAX_ALT_CODERS, rest.filter((m) => already.has(m)).length) }
|
|
5238
5297
|
);
|
|
5239
5298
|
// null is Escape and cancels; an EMPTY list is a legitimate "offer none".
|
|
5240
5299
|
if (ticks === null) return null;
|
|
@@ -5323,7 +5382,7 @@ async function manageCodingProviders() {
|
|
|
5323
5382
|
const providers = ps.manageableProviders(rows);
|
|
5324
5383
|
// NOTHING TO MANAGE IS NOT A MENU. Straight to the add flow, exactly as before.
|
|
5325
5384
|
if (providers.length === 0) {
|
|
5326
|
-
await
|
|
5385
|
+
await addServer({ role: "coding", dialect: "openai" });
|
|
5327
5386
|
return;
|
|
5328
5387
|
}
|
|
5329
5388
|
|
|
@@ -5340,7 +5399,7 @@ async function manageCodingProviders() {
|
|
|
5340
5399
|
return;
|
|
5341
5400
|
}
|
|
5342
5401
|
if (idx === ADD) {
|
|
5343
|
-
await
|
|
5402
|
+
await addServer({ role: "coding", dialect: "openai" });
|
|
5344
5403
|
return;
|
|
5345
5404
|
}
|
|
5346
5405
|
|
|
@@ -5539,324 +5598,292 @@ async function removeCodingProvider(ps, rows, p) {
|
|
|
5539
5598
|
}
|
|
5540
5599
|
|
|
5541
5600
|
/**
|
|
5542
|
-
*
|
|
5543
|
-
*
|
|
5544
|
-
*
|
|
5545
|
-
*
|
|
5546
|
-
*
|
|
5547
|
-
*
|
|
5548
|
-
*
|
|
5549
|
-
*
|
|
5550
|
-
*
|
|
5551
|
-
*
|
|
5552
|
-
*
|
|
5553
|
-
*
|
|
5554
|
-
*
|
|
5555
|
-
*
|
|
5556
|
-
*
|
|
5557
|
-
*
|
|
5558
|
-
*
|
|
5559
|
-
*
|
|
5560
|
-
*
|
|
5561
|
-
*
|
|
5562
|
-
*
|
|
5601
|
+
* ADD A MODEL SERVER — ONE SCREEN FOR THE CODER, THE AGENT AND THE EMBEDDER (task #245).
|
|
5602
|
+
*
|
|
5603
|
+
* Replaces three screens that asked the same things three ways (/model's add-provider,
|
|
5604
|
+
* /agent-model's Ollama server, /embed-model's Ollama and provider arms). Same questions, same
|
|
5605
|
+
* order, every role:
|
|
5606
|
+
*
|
|
5607
|
+
* 1. the server's URL — forgiving about what is pasted, and says what it used;
|
|
5608
|
+
* 2. its key — REUSED when the server already has one (a key belongs to the server, not the
|
|
5609
|
+
* model), asked only when the server needs one (an Ollama on this machine or the LAN never
|
|
5610
|
+
* does; a local vLLM may skip it). The FIRST key entered is what lists the models;
|
|
5611
|
+
* 3. its models, listed WITH that key — a real round trip, so a wrong URL or key fails HERE
|
|
5612
|
+
* rather than mid-turn. A refused key saves nothing;
|
|
5613
|
+
* 4. the model — coders filtered, embedders first, the agent sees everything (add-server.mjs);
|
|
5614
|
+
* 5. save the server row, then its key (the API makes a server's first key its default), then
|
|
5615
|
+
* the role's own setting.
|
|
5616
|
+
*
|
|
5617
|
+
* What differs per role or per server kind is decided in add-server.mjs, not here.
|
|
5618
|
+
*
|
|
5619
|
+
* role "coding" | "agent" | "embed"
|
|
5620
|
+
* dialect "openai" (an OpenAI-compatible /v1 server) | "ollama" (an Ollama: /api/tags)
|
|
5621
|
+
* url skip the URL question (a server picked from a list, or the embedder's current one)
|
|
5622
|
+
* model skip listing and picking (/embed-model's curated list already chose; `plan` with it)
|
|
5623
|
+
* newKey ask for a key even if the server has one (the stored one was refused)
|
|
5563
5624
|
*/
|
|
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;
|
|
5625
|
+
async function addServer({ role = "coding", dialect = "openai", url: presetUrl = "", model: presetModel = "", plan: presetPlan = null, newKey = false } = {}) {
|
|
5626
|
+
const as = await import("./add-server.mjs");
|
|
5590
5627
|
const ps = await import("./coding-provider-setup.mjs");
|
|
5591
|
-
const
|
|
5628
|
+
const sk = await import("./server-keys.mjs");
|
|
5592
5629
|
const md = await import("./model-doctor.mjs");
|
|
5593
5630
|
const { proxyFetch } = await import("./proxy-dispatcher.mjs");
|
|
5631
|
+
const R = as.serverRole(role);
|
|
5594
5632
|
|
|
5595
5633
|
const settings = await withPendingStatus("reading your model settings", fetchWorkerSettings);
|
|
5596
5634
|
if (!settings) {
|
|
5597
|
-
console.log(red(" couldn
|
|
5635
|
+
console.log(red(" couldn't reach the API to read your current settings.\n"));
|
|
5598
5636
|
return;
|
|
5599
5637
|
}
|
|
5600
5638
|
// 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.
|
|
5639
|
+
// its legacy fields — writing an array without it would make that coder vanish from /model.
|
|
5603
5640
|
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
|
-
);
|
|
5641
|
+
// What /model offers BEFORE, so a save that silently evicts a provider can be noticed (an API
|
|
5642
|
+
// older than row ids keys a backend by its KIND). Coding only — it is the list that can shrink.
|
|
5643
|
+
const beforeChoices = role === "coding"
|
|
5644
|
+
? ((await fetchAgentPayload([{ role: "user", content: "ping" }]).catch(() => null))?.codingChoices ?? null)
|
|
5645
|
+
: null;
|
|
5641
5646
|
|
|
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);
|
|
5647
|
+
// --- 1. the server --------------------------------------------------------------------
|
|
5648
|
+
let target = null;
|
|
5649
|
+
if (presetUrl) {
|
|
5650
|
+
target = as.serverTarget(dialect, presetUrl);
|
|
5651
|
+
if (!target.ok) {
|
|
5652
|
+
console.log(red(` "${presetUrl}" is ${target.why} — nothing changed.\n`));
|
|
5672
5653
|
return;
|
|
5673
5654
|
}
|
|
5674
|
-
|
|
5675
|
-
|
|
5676
|
-
|
|
5655
|
+
} else {
|
|
5656
|
+
const dflt = as.defaultServerUrl(role, dialect, settings, existing);
|
|
5657
|
+
console.log(dim(dialect === "ollama"
|
|
5658
|
+
? " An Ollama server: this machine, a box on your network, or https://ollama.com (needs a key)."
|
|
5659
|
+
: " Any OpenAI-compatible endpoint: OpenRouter, Together, vLLM, LM Studio, …"));
|
|
5660
|
+
// The address goes ABOVE, in full, and Enter's meaning is said out loud (46d1524e).
|
|
5661
|
+
console.log(dim(dflt ? ` Enter uses ${dflt} · Esc cancels\n` : " Esc cancels.\n"));
|
|
5662
|
+
for (let attempt = 0; attempt < 3 && !target; attempt++) {
|
|
5663
|
+
const typed = await ask(" server URL: ");
|
|
5664
|
+
if (typed === null || as.isCancelWord(typed)) break;
|
|
5665
|
+
const raw = typed.trim() || dflt;
|
|
5666
|
+
if (!raw) break;
|
|
5667
|
+
const t = as.serverTarget(dialect, raw);
|
|
5668
|
+
if (!t.ok) {
|
|
5669
|
+
if (dialect === "openai") for (const l of ps.describeUrlProblem(t.why, raw)) console.log(GUTTER + dim(l));
|
|
5670
|
+
else console.log(GUTTER + dim(`"${raw}" is ${t.why} — try host:port, e.g. 127.0.0.1:11434`));
|
|
5671
|
+
continue;
|
|
5672
|
+
}
|
|
5673
|
+
// SAY WHAT WE ASSUMED: a scheme we supplied or a path we stripped is a guess, and a wrong
|
|
5674
|
+
// guess shows up later as a timeout that reads like a dead server.
|
|
5675
|
+
if (t.root !== raw && t.url !== raw) console.log(GUTTER + dim(`using ${t.root}`));
|
|
5676
|
+
target = t;
|
|
5677
5677
|
}
|
|
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
5678
|
}
|
|
5686
|
-
if (!
|
|
5679
|
+
if (!target) {
|
|
5687
5680
|
console.log(dim(" (nothing saved)\n"));
|
|
5688
5681
|
return;
|
|
5689
5682
|
}
|
|
5683
|
+
const host = target.host;
|
|
5690
5684
|
|
|
5691
|
-
// --- 2.
|
|
5692
|
-
//
|
|
5693
|
-
//
|
|
5694
|
-
|
|
5695
|
-
|
|
5696
|
-
|
|
5697
|
-
|
|
5698
|
-
|
|
5699
|
-
console.log(GUTTER + dim("(cancelled — nothing was saved)\n"));
|
|
5685
|
+
// --- 2. the server's key --------------------------------------------------------------
|
|
5686
|
+
// AN EXISTING SERVER IS REUSED, never stored twice (two rows for one URL would be two picker
|
|
5687
|
+
// entries identical in everything a person can see) — its keys come with it.
|
|
5688
|
+
const found = ps.providerRowForUrl(existing, target.url);
|
|
5689
|
+
// THE ROW CAP BEFORE ANY QUESTION about a new server: exceeding it is a silent 200 with no row,
|
|
5690
|
+
// and asking for a key and a model first would be the worst order to discover that in.
|
|
5691
|
+
if (!found && existing.length >= ps.MAX_BACKENDS) {
|
|
5692
|
+
console.log(yellow(` ${ps.MAX_BACKENDS} servers is the limit — remove one in /model → manage providers first.\n`));
|
|
5700
5693
|
return;
|
|
5701
5694
|
}
|
|
5702
|
-
|
|
5703
|
-
|
|
5704
|
-
|
|
5705
|
-
|
|
5706
|
-
|
|
5707
|
-
|
|
5708
|
-
|
|
5709
|
-
|
|
5710
|
-
|
|
5711
|
-
if (
|
|
5712
|
-
|
|
5713
|
-
|
|
5714
|
-
}
|
|
5695
|
+
const need = as.keyRequirement(target);
|
|
5696
|
+
let key = "";
|
|
5697
|
+
let keyName = "";
|
|
5698
|
+
let typedKey = false;
|
|
5699
|
+
if (found && !newKey) {
|
|
5700
|
+
// A KEY BELONGS TO THE SERVER: one already saved is used, whichever model it was saved for.
|
|
5701
|
+
const keys = await withPendingStatus(`reading the keys for ${host}`, () => fetchServerKeys(found.id));
|
|
5702
|
+
const folderKeyId = role === "agent" ? sessionAgentKeyId : role === "embed" ? sessionEmbedKeyId : sessionCodingKeyId;
|
|
5703
|
+
const inUse = sk.keyInUse(keys ?? [], found.id, folderKeyId);
|
|
5704
|
+
if (inUse && inUse.backendId === found.id) {
|
|
5705
|
+
key = (await fetchCodingKey(found.id)).key;
|
|
5706
|
+
if (key) console.log(GUTTER + dim(`using the key "${inUse.name}" already saved for ${host}.`));
|
|
5707
|
+
}
|
|
5708
|
+
}
|
|
5709
|
+
const askKey = async () => {
|
|
5710
|
+
const t = await askServerKey({ host, optional: need === "optional" });
|
|
5711
|
+
key = t.secret;
|
|
5712
|
+
keyName = t.name;
|
|
5713
|
+
typedKey = Boolean(t.secret);
|
|
5714
|
+
return t.secret || need !== "required";
|
|
5715
|
+
};
|
|
5716
|
+
if (!key && (need !== "none" || newKey)) {
|
|
5717
|
+
if (need === "required") console.log(GUTTER + dim(`${host} needs an API key.`));
|
|
5718
|
+
if (!(await askKey())) return;
|
|
5715
5719
|
}
|
|
5716
5720
|
|
|
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
|
-
});
|
|
5721
|
+
// --- 3. its models, listed with that key ----------------------------------------------
|
|
5733
5722
|
let models = [];
|
|
5734
|
-
if (
|
|
5735
|
-
const
|
|
5736
|
-
|
|
5737
|
-
|
|
5738
|
-
|
|
5739
|
-
|
|
5740
|
-
|
|
5741
|
-
|
|
5742
|
-
|
|
5743
|
-
|
|
5744
|
-
|
|
5745
|
-
|
|
5746
|
-
|
|
5747
|
-
|
|
5748
|
-
|
|
5749
|
-
|
|
5750
|
-
|
|
5723
|
+
if (!presetModel) {
|
|
5724
|
+
const list = () =>
|
|
5725
|
+
withPendingStatus(`asking ${host} what it has`, async () => {
|
|
5726
|
+
const req = as.listRequest(target, key);
|
|
5727
|
+
try {
|
|
5728
|
+
const res = await proxyFetch(req.url, { headers: req.headers, signal: AbortSignal.timeout(12_000) });
|
|
5729
|
+
return res.ok ? { body: await res.json().catch(() => null) } : { status: res.status };
|
|
5730
|
+
} catch (err) {
|
|
5731
|
+
return { err };
|
|
5732
|
+
}
|
|
5733
|
+
});
|
|
5734
|
+
let probe = await list();
|
|
5735
|
+
// THE STORED KEY WAS REFUSED — ask for the right one once, rather than saving nothing.
|
|
5736
|
+
if ((probe.status === 401 || probe.status === 403) && !typedKey && key) {
|
|
5737
|
+
console.log(yellow(` ${host} refused the stored key (HTTP ${probe.status}) — enter the right one.`));
|
|
5738
|
+
if (!(await askKey()) || !key) return;
|
|
5739
|
+
probe = await list();
|
|
5740
|
+
}
|
|
5741
|
+
if (probe.status || probe.err) {
|
|
5742
|
+
const d = ps.describeProbeFailure(probe);
|
|
5743
|
+
for (const l of d.lines) console.log(GUTTER + (d.fatal ? red(l) : yellow(l)));
|
|
5744
|
+
// FATAL = THE KEY WAS REFUSED: storing a credential the server already rejected is storing
|
|
5745
|
+
// a problem for later. Anything else is survivable — the model id can be typed.
|
|
5746
|
+
if (d.fatal) {
|
|
5747
|
+
console.log(GUTTER + dim("nothing was saved — check the key and try again.\n"));
|
|
5748
|
+
return;
|
|
5749
|
+
}
|
|
5750
|
+
console.log(GUTTER + dim("carrying on — you can type the model id yourself."));
|
|
5751
|
+
} else {
|
|
5752
|
+
const parsed = as.modelsForRole(role, target.dialect, probe.body);
|
|
5753
|
+
models = parsed.models;
|
|
5754
|
+
for (const l of ps.describeModelProblem(parsed.why, { host, total: parsed.total })) console.log(GUTTER + dim(l));
|
|
5755
|
+
const rn = ps.retiredNote(parsed.retired, host);
|
|
5756
|
+
if (rn) console.log(GUTTER + dim(rn));
|
|
5757
|
+
if (models.length) console.log(GUTTER + dim(`${host} offers ${models.length} model${models.length === 1 ? "" : "s"}`));
|
|
5751
5758
|
}
|
|
5752
|
-
|
|
5753
|
-
|
|
5754
|
-
|
|
5755
|
-
|
|
5759
|
+
}
|
|
5760
|
+
|
|
5761
|
+
// --- 4. the model ---------------------------------------------------------------------
|
|
5762
|
+
let model = presetModel;
|
|
5763
|
+
const ec = role === "embed" ? await import("./embed-model-command.mjs") : null;
|
|
5764
|
+
if (!model) {
|
|
5756
5765
|
if (models.length) {
|
|
5757
|
-
|
|
5766
|
+
const am = await import("./agent-model-command.mjs");
|
|
5767
|
+
const current = role === "agent" ? am.currentAgentModel(settings) : role === "embed" ? ec.currentEmbedModel(settings) : sessionCodingLabel;
|
|
5768
|
+
model = R.filterCoders
|
|
5769
|
+
? await chooseCodingModelFrom(ps, models, R.question, 0)
|
|
5770
|
+
: await chooseFromModelList(models, R.question, Math.max(0, models.indexOf(current)));
|
|
5771
|
+
if (model === null) {
|
|
5772
|
+
console.log(dim(" (cancelled — nothing was saved)\n"));
|
|
5773
|
+
return;
|
|
5774
|
+
}
|
|
5775
|
+
} else {
|
|
5776
|
+
model = String((await ask(" model id: ")) ?? "").trim();
|
|
5777
|
+
}
|
|
5778
|
+
if (!model) {
|
|
5779
|
+
console.log(GUTTER + dim("(cancelled — nothing was saved)\n"));
|
|
5780
|
+
return;
|
|
5758
5781
|
}
|
|
5759
5782
|
}
|
|
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"));
|
|
5783
|
+
// THE EMBEDDER'S RECOGNITION WALL: a different model invalidates every index, so the projects
|
|
5784
|
+
// that stop working are named BEFORE anything is saved. The same model is a pure server move.
|
|
5785
|
+
let plan = presetPlan;
|
|
5786
|
+
if (role === "embed" && !presetModel) {
|
|
5787
|
+
const current = ec.currentEmbedModel(settings);
|
|
5788
|
+
if (!ec.sameEmbedModel(model, current)) {
|
|
5789
|
+
const sw = await confirmEmbedSwitch(ec, current, model);
|
|
5790
|
+
if (!sw.changed) return;
|
|
5791
|
+
plan = sw.plan;
|
|
5792
|
+
} else if (as.serverTarget(target.dialect, String(settings.ragEmbedUrl ?? "")).root === target.root && !typedKey) {
|
|
5793
|
+
console.log(dim(` already embedding with ${model} on ${host} — nothing changed.\n`));
|
|
5775
5794
|
return;
|
|
5776
5795
|
}
|
|
5777
|
-
} else {
|
|
5778
|
-
model = (await ask(" model id: ")).trim();
|
|
5779
5796
|
}
|
|
5780
|
-
|
|
5781
|
-
|
|
5782
|
-
|
|
5797
|
+
// MORE THAN ONE MODEL from a NEW server, for the coder: the default is what runs, these are the
|
|
5798
|
+
// others /model may switch to. At most MAX_MODELS_PER_SERVER in all (the API refuses more).
|
|
5799
|
+
let extras = [];
|
|
5800
|
+
if (R.extras && !found && models.length > 1) {
|
|
5801
|
+
extras = await pickExtraModels(models.filter((m) => m !== model));
|
|
5802
|
+
if (extras === null) {
|
|
5803
|
+
console.log(GUTTER + dim("(cancelled — nothing was saved)\n"));
|
|
5804
|
+
return;
|
|
5805
|
+
}
|
|
5783
5806
|
}
|
|
5784
5807
|
|
|
5785
|
-
// ---
|
|
5786
|
-
|
|
5787
|
-
|
|
5788
|
-
//
|
|
5789
|
-
|
|
5790
|
-
|
|
5791
|
-
|
|
5792
|
-
|
|
5808
|
+
// --- 5. save: the server, its key, then the role --------------------------------------
|
|
5809
|
+
let rowId = found?.id ?? "";
|
|
5810
|
+
let rows = existing;
|
|
5811
|
+
// A NEW ROW ONLY WHEN SOMETHING NEEDS ONE: the coder is picked through its row, and a key is
|
|
5812
|
+
// bound to one. A keyless Ollama on this machine or the LAN for the agent or the embedder needs
|
|
5813
|
+
// neither — the role's own setting holds its URL — and a row would put it in /model as a coder.
|
|
5814
|
+
const needRow = role === "coding" || typedKey;
|
|
5815
|
+
if (!found && needRow) {
|
|
5816
|
+
rowId = ps.mintProviderId(existing.map((b) => String(b.id ?? "")));
|
|
5817
|
+
const { row } = as.rowForSave(role, { id: rowId, kind: target.dialect === "ollama" ? "ollama" : "openai", url: target.url, model, extras });
|
|
5818
|
+
rows = [...existing, row];
|
|
5819
|
+
} else {
|
|
5820
|
+
const r = as.rowForSave(role, { existing: found.row, model });
|
|
5821
|
+
if (r.full) {
|
|
5822
|
+
console.log(yellow(` ${host} already offers ${ps.MAX_MODELS_PER_SERVER} models — remove one in /model → manage providers first.\n`));
|
|
5823
|
+
return;
|
|
5824
|
+
}
|
|
5825
|
+
if (r.changed) rows = existing.map((b) => (b === found.row ? r.row : b));
|
|
5793
5826
|
}
|
|
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;
|
|
5827
|
+
if (rows !== existing) {
|
|
5828
|
+
const ok = await withPendingStatus(`saving ${host}`, () => md.saveWorkerSettings(apiBase, workerKey, { agentCodingBackends: rows }));
|
|
5829
|
+
if (!ok) {
|
|
5830
|
+
console.log(red(` couldn't save ${host} — nothing changed.`));
|
|
5831
|
+
console.log(GUTTER + dim("if this machine's local API is old, restart it with `gu api restart`.\n"));
|
|
5832
|
+
return;
|
|
5833
|
+
}
|
|
5834
|
+
if (!found && role !== "coding" && as.serverRole(role).rowModel) console.log(GUTTER + dim(`${host} is also offered in /model now.`));
|
|
5805
5835
|
}
|
|
5806
|
-
|
|
5807
|
-
addCodingKey(
|
|
5808
|
-
|
|
5809
|
-
|
|
5810
|
-
|
|
5811
|
-
|
|
5812
|
-
|
|
5813
|
-
|
|
5814
|
-
|
|
5836
|
+
if (typedKey) {
|
|
5837
|
+
const id = await withPendingStatus("saving the key", () => addCodingKey(keyName, key, rowId));
|
|
5838
|
+
if (!id) {
|
|
5839
|
+
console.log(yellow(` ${host} was saved, but its key was not — nothing else changed.`));
|
|
5840
|
+
console.log(GUTTER + dim("try again, or add the key with ctrl+k.\n"));
|
|
5841
|
+
return;
|
|
5842
|
+
}
|
|
5843
|
+
// A key typed for a server that already had keys was typed because there was none usable or
|
|
5844
|
+
// the stored one was refused: it is the server's key from now on, for every role and folder.
|
|
5845
|
+
if (found) await setServerDefaultKey(rowId, id);
|
|
5815
5846
|
}
|
|
5847
|
+
const keyLine = key ? dim(` API key ${keyName || "saved"} ····${key.slice(-4)} · ${host}'s key`) : "";
|
|
5816
5848
|
|
|
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.
|
|
5849
|
+
if (role === "agent") {
|
|
5850
|
+
// A MACHINE SETTING, not a folder pick: the agent is what gu talks to in every folder. Saved
|
|
5851
|
+
// as URL + NAME, the pair app.ts matches back to the server row to find the key.
|
|
5852
|
+
if (await saveAgentModel(target.roleUrl("agent"), model, host)) {
|
|
5853
|
+
if (keyLine) console.log(keyLine + "\n");
|
|
5854
|
+
console.log(GUTTER + dim("the banner shows the new model next time gu starts.\n"));
|
|
5855
|
+
} else if (rows !== existing || typedKey) {
|
|
5856
|
+
// The SERVER landed — say so, or the user re-enters a key that is already stored.
|
|
5847
5857
|
console.log(yellow(` ${host} was saved, but the agent model was not pointed at it.`));
|
|
5848
5858
|
console.log(GUTTER + dim("run /agent-model to finish, or check the API is reachable.\n"));
|
|
5849
|
-
return;
|
|
5850
5859
|
}
|
|
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
5860
|
return;
|
|
5858
5861
|
}
|
|
5862
|
+
if (role === "embed") {
|
|
5863
|
+
// ragEmbedKeyId "" — the server's own keys decide now (its default, or this folder's pick).
|
|
5864
|
+
const fields = { ragEmbedUrl: target.roleUrl("embed"), ragEmbedModel: model, ragEmbedKeyId: "" };
|
|
5865
|
+
if (!(await saveEmbedSettings(fields))) return;
|
|
5866
|
+
if (plan) announceEmbedSaved({ chosen: model, plan, url: target.roleUrl("embed") });
|
|
5867
|
+
else {
|
|
5868
|
+
console.log(accent(` ✓ embedding server → ${host}`));
|
|
5869
|
+
console.log(dim(` ${model} did not change, so no knowledge base rebuilds — the same model just answers from ${host}.\n`));
|
|
5870
|
+
}
|
|
5871
|
+
return;
|
|
5872
|
+
}
|
|
5873
|
+
await useCoderInThisFolder({ ps, rows, beforeChoices, url: target.url, host, locality: target.locality, model, backendId: rowId, key, keyName });
|
|
5874
|
+
}
|
|
5859
5875
|
|
|
5876
|
+
/**
|
|
5877
|
+
* The coder just added becomes THIS FOLDER's pick (not the account default — per-folder is
|
|
5878
|
+
* reversible and nobody asked for account-wide). Split out of addServer because only the coder
|
|
5879
|
+
* has it, and it carries the two checks that were learned the hard way:
|
|
5880
|
+
*
|
|
5881
|
+
* · ASK THE API WHAT THE PICK IS CALLED. The wire id belongs to the API (a bare model or
|
|
5882
|
+
* `${rowId}::${model}`); guessing it here once stored a pick the next turn rejected.
|
|
5883
|
+
* · DID SAVING EVICT ANOTHER PROVIDER? An API older than row ids keys a backend by its KIND,
|
|
5884
|
+
* so a second openai row replaced the first with a 200. Said BEFORE the success line.
|
|
5885
|
+
*/
|
|
5886
|
+
async function useCoderInThisFolder({ ps, rows, beforeChoices, url, host, locality, model, backendId, key, keyName }) {
|
|
5860
5887
|
const cwd = resolve(process.cwd());
|
|
5861
5888
|
const after = await withPendingStatus("checking it is offered", () =>
|
|
5862
5889
|
fetchAgentPayload([{ role: "user", content: "ping" }]).catch(() => null)
|
|
@@ -5866,28 +5893,13 @@ async function addCodingProvider(roleName = "coding") {
|
|
|
5866
5893
|
(c) => c.model === model && (c.backendId ? c.backendId === backendId : sameHost(c.url, url))
|
|
5867
5894
|
);
|
|
5868
5895
|
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
5896
|
console.log(yellow(` ${host} was saved, but this API is not offering ${model} yet.`));
|
|
5872
5897
|
console.log(GUTTER + dim("run /model — if it is missing there too, the API may be older than"));
|
|
5873
5898
|
console.log(GUTTER + dim("this feature; restart it with `gu api restart`.\n"));
|
|
5874
5899
|
return;
|
|
5875
5900
|
}
|
|
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.
|
|
5901
|
+
// Two questions, and they catch different failures: a row WE SENT failed to land, or something
|
|
5902
|
+
// that used to be offered has gone (including a provider this client never saw).
|
|
5891
5903
|
const lost = Array.from(
|
|
5892
5904
|
new Set([
|
|
5893
5905
|
...ps.providerUrlsLost(rows, after?.codingChoices),
|
|
@@ -5898,32 +5910,27 @@ async function addCodingProvider(roleName = "coding") {
|
|
|
5898
5910
|
console.log(yellow(` ⚠ this API dropped ${lost.length === 1 ? "a provider" : `${lost.length} providers`} when saving:`));
|
|
5899
5911
|
for (const u of lost) console.log(GUTTER + dim(` ${u}`));
|
|
5900
5912
|
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
5913
|
console.log(GUTTER + dim("this API identifies a backend by its KIND, so it holds ONE"));
|
|
5904
5914
|
console.log(GUTTER + dim("OpenAI-compatible provider at a time. A newer one keeps several."));
|
|
5905
5915
|
}
|
|
5906
5916
|
console.log(GUTTER + dim("restart it with `gu api restart`, then add that provider again.\n"));
|
|
5907
5917
|
}
|
|
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
5918
|
sessionCodingModel = offered.id;
|
|
5912
5919
|
sessionCodingLabel = model;
|
|
5913
5920
|
sessionCodingUrl = url;
|
|
5914
|
-
|
|
5915
|
-
|
|
5916
|
-
|
|
5921
|
+
// The folder follows the SERVER's key (task #245) — its default, which a first key already is.
|
|
5922
|
+
sessionCodingKeyId = "";
|
|
5923
|
+
sessionApiKeyTail = key ? key.slice(-4) : "";
|
|
5924
|
+
keyChoicePending = false;
|
|
5917
5925
|
await saveSessionCoding(cwd);
|
|
5918
5926
|
await saveSessionCodingKeyId(cwd);
|
|
5919
|
-
// The key question for this coder is
|
|
5920
|
-
//
|
|
5927
|
+
// The key question for this coder is answered — leaving the flag set would warn about a key
|
|
5928
|
+
// that was just entered, which reads as the save having failed.
|
|
5921
5929
|
codingKeyMissingKnown = false;
|
|
5922
5930
|
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.
|
|
5931
|
+
if (key) console.log(dim(` API key ${keyName || "saved"} ····${key.slice(-4)} · ${host}'s key\n`));
|
|
5932
|
+
// A HOSTED SERVER NEEDS ITS WINDOW SET: with nothing set gu assumes 24,000 prompt tokens and
|
|
5933
|
+
// compacts there — on a large hosted model that throws history away every turn.
|
|
5927
5934
|
if (ps.shouldMentionContextWindow(locality)) {
|
|
5928
5935
|
console.log(GUTTER + dim("tip: /context-window — hosted servers do not report their size,"));
|
|
5929
5936
|
console.log(GUTTER + dim(" so gu assumes 24,000 prompt tokens and compacts there.\n"));
|
|
@@ -6142,20 +6149,31 @@ async function chooseModel() {
|
|
|
6142
6149
|
* NOT THE CURRENT CHOICE. Which model this account codes with stays as it is on the machine being
|
|
6143
6150
|
* loaded into: a file that moves your models should not quietly move your coder too.
|
|
6144
6151
|
*/
|
|
6145
|
-
|
|
6146
|
-
|
|
6147
|
-
|
|
6148
|
-
function settingsPathFrom(line, word) {
|
|
6149
|
-
const typed = line.slice(word.length).trim().replace(/^['"]|['"]$/g, "");
|
|
6150
|
-
if (!typed) return SETTINGS_FILE_DEFAULT;
|
|
6151
|
-
const expanded = typed.startsWith("~/") ? join(homedir(), typed.slice(2)) : typed;
|
|
6152
|
-
return resolve(expanded);
|
|
6152
|
+
/** What followed the command word, or "" — the name/path rules are settingsFilePath's. */
|
|
6153
|
+
function settingsArg(line, word) {
|
|
6154
|
+
return line.slice(word.length).trim();
|
|
6153
6155
|
}
|
|
6154
6156
|
|
|
6155
6157
|
async function runSaveSettingsCommand(line) {
|
|
6156
6158
|
const st = await import("./settings-transfer.mjs");
|
|
6157
|
-
const
|
|
6159
|
+
const home = homedir();
|
|
6158
6160
|
console.log("");
|
|
6161
|
+
// THE FILE NAME, IN THE DEFAULT FOLDER. The home directory is where it goes; all anyone should
|
|
6162
|
+
// have to type is a name — and Enter keeps gu-settings.json, as before.
|
|
6163
|
+
let typed = settingsArg(line, "/save-settings");
|
|
6164
|
+
if (!typed) {
|
|
6165
|
+
const answer = await ask(` file name in ${home} [${st.DEFAULT_SETTINGS_NAME}]: `);
|
|
6166
|
+
if (answer === null) {
|
|
6167
|
+
console.log(GUTTER + dim(" (cancelled — nothing was written)\n"));
|
|
6168
|
+
return;
|
|
6169
|
+
}
|
|
6170
|
+
typed = answer.trim();
|
|
6171
|
+
}
|
|
6172
|
+
let file = st.settingsFilePath(typed, home);
|
|
6173
|
+
if (!file) {
|
|
6174
|
+
console.log(GUTTER + dim(" (cancelled — nothing was written)\n"));
|
|
6175
|
+
return;
|
|
6176
|
+
}
|
|
6159
6177
|
const settings = await withPendingStatus("reading your model settings", fetchWorkerSettings);
|
|
6160
6178
|
if (!settings) {
|
|
6161
6179
|
console.log(GUTTER + red(` couldn't reach the API at ${apiBase} — nothing was written.\n`));
|
|
@@ -6163,18 +6181,23 @@ async function runSaveSettingsCommand(line) {
|
|
|
6163
6181
|
}
|
|
6164
6182
|
const keys = (await fetchCodingKeys()) ?? [];
|
|
6165
6183
|
const doc = st.buildTransfer({ settings, keys, host: hostname() });
|
|
6166
|
-
if (!doc.codingBackends.length && !doc.agentModel.url && !doc.agentModel.name) {
|
|
6167
|
-
console.log(GUTTER + dim(" this account has no coding providers
|
|
6184
|
+
if (!doc.codingBackends.length && !doc.agentModel.url && !doc.agentModel.name && !doc.embedModel.url && !doc.embedModel.name) {
|
|
6185
|
+
console.log(GUTTER + dim(" this account has no coding providers, agent model or embedding model set — nothing to save.\n"));
|
|
6168
6186
|
return;
|
|
6169
6187
|
}
|
|
6170
|
-
// ASK BEFORE OVERWRITING. The default path is one everybody
|
|
6171
|
-
// save would otherwise eat the first machine's file
|
|
6172
|
-
|
|
6173
|
-
|
|
6174
|
-
|
|
6188
|
+
// ASK BEFORE OVERWRITING — and let a NEW NAME be the answer. The default path is one everybody
|
|
6189
|
+
// shares, so the second machine to save would otherwise eat the first machine's file; "no" alone
|
|
6190
|
+
// left nowhere to go but typing the whole command again with a path.
|
|
6191
|
+
while (existsSync(file)) {
|
|
6192
|
+
const answer = await ask(` ${file} exists — overwrite it? [y/N], or type a new name: `);
|
|
6193
|
+
const a = String(answer ?? "").trim();
|
|
6194
|
+
if (/^(y|yes)$/i.test(a)) break;
|
|
6195
|
+
const next = a && !/^(n|no)$/i.test(a) ? st.settingsFilePath(a, home) : "";
|
|
6196
|
+
if (!next) {
|
|
6175
6197
|
console.log(GUTTER + dim(" left it alone.\n"));
|
|
6176
6198
|
return;
|
|
6177
6199
|
}
|
|
6200
|
+
file = next;
|
|
6178
6201
|
}
|
|
6179
6202
|
try {
|
|
6180
6203
|
// 0600: it names your providers and your key names. Not secret, but nobody else's business.
|
|
@@ -6183,19 +6206,36 @@ async function runSaveSettingsCommand(line) {
|
|
|
6183
6206
|
console.log(GUTTER + red(` could not write ${file}: ${e instanceof Error ? e.message : e}\n`));
|
|
6184
6207
|
return;
|
|
6185
6208
|
}
|
|
6186
|
-
const models = doc.codingBackends.reduce((n, b) => n + b.models.length, 0);
|
|
6187
6209
|
console.log(GUTTER + accent(` saved to ${file}`));
|
|
6188
|
-
|
|
6189
|
-
|
|
6190
|
-
dim(` ${doc.codingBackends.length} provider${doc.codingBackends.length === 1 ? "" : "s"}, ${models} model${models === 1 ? "" : "s"}, ${doc.keys.length} key name${doc.keys.length === 1 ? "" : "s"} — no secrets.`),
|
|
6191
|
-
);
|
|
6210
|
+
// Counted by the tested rule — the default model included (it once read "0 models").
|
|
6211
|
+
console.log(GUTTER + dim(` ${st.transferSummary(doc)}`));
|
|
6192
6212
|
console.log(GUTTER + dim(" load it on the other machine with /load-settings " + file + "\n"));
|
|
6193
6213
|
}
|
|
6194
6214
|
|
|
6195
6215
|
async function runLoadSettingsCommand(line) {
|
|
6196
6216
|
const st = await import("./settings-transfer.mjs");
|
|
6197
|
-
const
|
|
6217
|
+
const home = homedir();
|
|
6198
6218
|
console.log("");
|
|
6219
|
+
// A NAME IN THE DEFAULT FOLDER, like /save-settings. With none given and more than one settings
|
|
6220
|
+
// file there, pick from them rather than silently taking gu-settings.json.
|
|
6221
|
+
let typed = settingsArg(line, "/load-settings");
|
|
6222
|
+
if (!typed) {
|
|
6223
|
+
const found = st.settingsFilesIn(listJsonFiles(home));
|
|
6224
|
+
if (found.length > 1) {
|
|
6225
|
+
console.log(dim(` Settings files in ${home} (${PICK_HINT}):`));
|
|
6226
|
+
const i = await pickFromList(found, 0);
|
|
6227
|
+
if (i < 0) {
|
|
6228
|
+
console.log(GUTTER + dim(" (cancelled — nothing changed)\n"));
|
|
6229
|
+
return;
|
|
6230
|
+
}
|
|
6231
|
+
typed = found[i];
|
|
6232
|
+
}
|
|
6233
|
+
}
|
|
6234
|
+
const file = st.settingsFilePath(typed, home);
|
|
6235
|
+
if (!file) {
|
|
6236
|
+
console.log(GUTTER + dim(" (cancelled — nothing changed)\n"));
|
|
6237
|
+
return;
|
|
6238
|
+
}
|
|
6199
6239
|
let text;
|
|
6200
6240
|
try {
|
|
6201
6241
|
text = readFileSync(file, "utf8");
|
|
@@ -6240,16 +6280,16 @@ async function runLoadSettingsCommand(line) {
|
|
|
6240
6280
|
console.log(GUTTER + red(" the API refused the settings — nothing was changed.\n"));
|
|
6241
6281
|
return;
|
|
6242
6282
|
}
|
|
6243
|
-
console.log(GUTTER + accent(` applied: ${plan.adds} provider${plan.adds === 1 ? "" : "s"}.`));
|
|
6283
|
+
console.log(GUTTER + accent(` applied: ${plan.adds} provider${plan.adds === 1 ? "" : "s"}${patch.ragEmbedUrl || patch.ragEmbedModel ? ", the embedding model" : ""}.`));
|
|
6244
6284
|
|
|
6245
6285
|
// THE KEYS, AFTER the settings — each needs the row it belongs to, and the rows only exist
|
|
6246
6286
|
// once the API has minted their ids.
|
|
6247
6287
|
const fresh = (await fetchWorkerSettings()) ?? {};
|
|
6248
|
-
const
|
|
6249
|
-
const
|
|
6250
|
-
|
|
6251
|
-
|
|
6252
|
-
|
|
6288
|
+
const ps = await import("./coding-provider-setup.mjs");
|
|
6289
|
+
const rows = ps.existingBackendRows(fresh);
|
|
6290
|
+
// One server however it is spelled (task #245) — a raw compare missed "…/v1" and saved the key
|
|
6291
|
+
// tied to NO server, where it can never be used.
|
|
6292
|
+
const rowIdForUrl = (url) => ps.providerRowForUrl(rows, url)?.id ?? "";
|
|
6253
6293
|
for (const k of plan.missingKeys) {
|
|
6254
6294
|
const where = k.backendUrl ? ` for ${k.backendUrl}` : "";
|
|
6255
6295
|
const typed = k.key || (await ask(` key "${k.name}"${k.tail ? ` (…${k.tail})` : ""}${where} — paste it, or Enter to skip: `)).trim();
|
|
@@ -6257,12 +6297,42 @@ async function runLoadSettingsCommand(line) {
|
|
|
6257
6297
|
console.log(GUTTER + dim(` skipped ${k.name} — add it later with ctrl+k.`));
|
|
6258
6298
|
continue;
|
|
6259
6299
|
}
|
|
6260
|
-
const
|
|
6300
|
+
const rowId = k.backendUrl ? rowIdForUrl(k.backendUrl) : "";
|
|
6301
|
+
const id = await addCodingKey(k.name, typed, rowId);
|
|
6261
6302
|
console.log(GUTTER + (id ? accent(` added ${k.name}.`) : red(` could not add ${k.name}.`)));
|
|
6303
|
+
// The key that was its server's DEFAULT on the saving machine is the default here too.
|
|
6304
|
+
if (id && rowId && k.default) await setServerDefaultKey(rowId, id);
|
|
6262
6305
|
}
|
|
6263
6306
|
console.log(GUTTER + dim(" pick a coding model with /model.\n"));
|
|
6264
6307
|
}
|
|
6265
6308
|
|
|
6309
|
+
/** The .json files directly in a folder, with enough of each to recognise a settings file. */
|
|
6310
|
+
function listJsonFiles(dir) {
|
|
6311
|
+
try {
|
|
6312
|
+
return readdirSync(dir)
|
|
6313
|
+
.filter((n) => /\.json$/i.test(n))
|
|
6314
|
+
.slice(0, 200)
|
|
6315
|
+
.map((name) => {
|
|
6316
|
+
try {
|
|
6317
|
+
const p = join(dir, name);
|
|
6318
|
+
const st = statSync(p);
|
|
6319
|
+
// The marker is near the top; 4 KB is plenty and keeps a huge JSON from being read whole.
|
|
6320
|
+
if (!st.isFile() || st.size > 5_000_000) return null;
|
|
6321
|
+
const fd = openSync(p, "r");
|
|
6322
|
+
const buf = Buffer.alloc(Math.min(4096, st.size));
|
|
6323
|
+
readSync(fd, buf, 0, buf.length, 0);
|
|
6324
|
+
closeSync(fd);
|
|
6325
|
+
return { name, text: buf.toString("utf8"), mtimeMs: st.mtimeMs };
|
|
6326
|
+
} catch {
|
|
6327
|
+
return null;
|
|
6328
|
+
}
|
|
6329
|
+
})
|
|
6330
|
+
.filter(Boolean);
|
|
6331
|
+
} catch {
|
|
6332
|
+
return [];
|
|
6333
|
+
}
|
|
6334
|
+
}
|
|
6335
|
+
|
|
6266
6336
|
async function fetchWorkerSettings() {
|
|
6267
6337
|
try {
|
|
6268
6338
|
const r = await fetch(`${apiBase}/api/worker/settings`, {
|
|
@@ -6288,7 +6358,7 @@ async function fetchWorkerSettings() {
|
|
|
6288
6358
|
* Only a model change rebuilds indexes — moving the SAME embedder to another server, or changing
|
|
6289
6359
|
* only its key, leaves every vector valid, so those paths save without the plan wall. The worker
|
|
6290
6360
|
* already speaks both dialects: gonext_agent_chat._embed() POSTs {base}/v1/embeddings first, then
|
|
6291
|
-
* Ollama /api/embed and /api/embeddings, and sends the
|
|
6361
|
+
* Ollama /api/embed and /api/embeddings, and sends the embed server's stored key (role=embed) as a Bearer when
|
|
6292
6362
|
* one exists — so pointing at an OpenAI-compatible provider here needs no worker-side change.
|
|
6293
6363
|
*
|
|
6294
6364
|
* The decisions live in embed-model-command.mjs; this is the terminal half only.
|
|
@@ -6307,50 +6377,6 @@ async function saveEmbedSettings(fields) {
|
|
|
6307
6377
|
return saved;
|
|
6308
6378
|
}
|
|
6309
6379
|
|
|
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
6380
|
// THE RECOGNITION STEP, ONE WAY. Switching embedders invalidates the indexes; naming the projects
|
|
6355
6381
|
// that stop working is the decision the user is actually making, so the plan is read BEFORE the
|
|
6356
6382
|
// "switch / keep" picker — never after, never implied. Returns { changed:false } when the user
|
|
@@ -6438,147 +6464,33 @@ async function chooseEmbedModel() {
|
|
|
6438
6464
|
return;
|
|
6439
6465
|
}
|
|
6440
6466
|
|
|
6467
|
+
// EVERY ARM IS THE ONE ADD-SERVER SCREEN (task #245) — the same URL → key → models → save as
|
|
6468
|
+
// /model and /agent-model. The curated arm has already chosen the model (and passed the
|
|
6469
|
+
// recognition wall), so it only asks WHERE; the other two list what the server has.
|
|
6441
6470
|
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
6471
|
const { changed, chosen, plan } = await pickEmbedModel(ec, current);
|
|
6449
6472
|
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 });
|
|
6473
|
+
await addServer({ role: "embed", dialect: "ollama", model: chosen, plan });
|
|
6465
6474
|
return;
|
|
6466
6475
|
}
|
|
6476
|
+
if (action === "ollama") return addServer({ role: "embed", dialect: "ollama" });
|
|
6477
|
+
if (action === "provider") return addServer({ role: "embed", dialect: "openai" });
|
|
6467
6478
|
|
|
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.
|
|
6479
|
+
if (action === "key") {
|
|
6480
|
+
// THE SAME KEY MANAGER as /model's ctrl+k and /agent-model (task #245): this server's keys
|
|
6481
|
+
// only — switch (this folder's embedder, or the server's default), add, delete. A server
|
|
6482
|
+
// with no row yet gets one here, with its first key, exactly as the model arms add it.
|
|
6501
6483
|
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;
|
|
6484
|
+
const found = ps.providerRowForUrl(ps.existingBackendRows(settings), currentUrl);
|
|
6485
|
+
if (!found) {
|
|
6486
|
+
// No server row yet: the add-server screen, URL and model already known, a key asked for.
|
|
6487
|
+
await addServer({ role: "embed", dialect: /\/v1\/?$/i.test(currentUrl) ? "openai" : "ollama", url: currentUrl, model: current, newKey: true });
|
|
6488
|
+
return;
|
|
6546
6489
|
} 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;
|
|
6490
|
+
await manageServerKeys({ role: "embed", backendId: found.id, host: host || currentUrl, model: current });
|
|
6554
6491
|
}
|
|
6555
|
-
//
|
|
6556
|
-
|
|
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;
|
|
6562
|
-
}
|
|
6563
|
-
const fields = { ragEmbedUrl: base, ragEmbedKeyId: embedKeyId };
|
|
6564
|
-
if (chosen) fields.ragEmbedModel = chosen;
|
|
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`));
|
|
6492
|
+
// The pre-#245 account-wide pick would shadow what was just chosen; the server decides now.
|
|
6493
|
+
if (String(settings.ragEmbedKeyId ?? "").trim()) await saveEmbedSettings({ ragEmbedKeyId: "" });
|
|
6582
6494
|
}
|
|
6583
6495
|
}
|
|
6584
6496
|
|
|
@@ -6613,83 +6525,47 @@ async function askKeyName(prompt = " key name: ") {
|
|
|
6613
6525
|
return "";
|
|
6614
6526
|
}
|
|
6615
6527
|
|
|
6616
|
-
/**
|
|
6617
|
-
|
|
6618
|
-
|
|
6619
|
-
|
|
6528
|
+
/**
|
|
6529
|
+
* ASK FOR A SERVER'S KEY — name, then the secret, then the shape check — ONE PROMPT for every
|
|
6530
|
+
* flow that adds a key (task #245): /model's add-provider and ctrl+k, /agent-model's Ollama cloud
|
|
6531
|
+
* and provider rows, /embed-model's three arms. It only ASKS; the caller saves (a new server's
|
|
6532
|
+
* row must exist before its key can be bound to it).
|
|
6533
|
+
*
|
|
6534
|
+
* Returns { name, secret }; secret "" = nothing typed — a cancel, or with `optional` a deliberate
|
|
6535
|
+
* "no key" (a local vLLM answers anonymously). `host` names the server in the prompt and decides
|
|
6536
|
+
* the shape check's locality: a local server accepts any string, so "abc" is not questioned there.
|
|
6537
|
+
*/
|
|
6538
|
+
async function askServerKey({ host = "", nameHint = '"work", "personal"', optional = false } = {}) {
|
|
6539
|
+
const ps = await import("./coding-provider-setup.mjs");
|
|
6540
|
+
console.log("\n" + GUTTER + dim(`A name just tells keys apart — ${nameHint}.`));
|
|
6541
|
+
const name = (await askKeyName()).trim();
|
|
6620
6542
|
console.log(GUTTER + dim(SECRET_KEY_HINT));
|
|
6621
|
-
const secret = (await askSecret(` API key for ${host}${name ? ` (${name})` : ""}: `)).trim();
|
|
6543
|
+
const secret = (await askSecret(` API key${host ? ` for ${host}` : ""}${name ? ` (${name})` : ""}: `)).trim();
|
|
6622
6544
|
if (!secret) {
|
|
6623
|
-
console.log(GUTTER + dim("(cancelled — nothing was saved)\n"));
|
|
6624
|
-
return "";
|
|
6545
|
+
console.log(GUTTER + dim(optional ? "(no key — the server will be called anonymously)" : "(cancelled — nothing was saved)\n"));
|
|
6546
|
+
return { name, secret: "" };
|
|
6625
6547
|
}
|
|
6626
|
-
|
|
6627
|
-
|
|
6628
|
-
|
|
6629
|
-
|
|
6548
|
+
// DOES THAT LOOK LIKE A KEY? A shape check only — the server decides validity — but "abc" and a
|
|
6549
|
+
// README placeholder are obvious on sight and would otherwise die as a 401 several steps in.
|
|
6550
|
+
const shape = ps.describeApiKeyShape(secret, { host, locality: host ? ps.hostLocality(host) : "" });
|
|
6551
|
+
if (shape.level) {
|
|
6552
|
+
console.log(GUTTER + (shape.level === "warn" ? yellow(shape.note) : dim(shape.note)));
|
|
6553
|
+
if (shape.level === "warn" && !(await askYesNo("Use that key anyway?", false))) {
|
|
6554
|
+
console.log(GUTTER + dim("(cancelled — nothing was saved)\n"));
|
|
6555
|
+
return { name, secret: "" };
|
|
6556
|
+
}
|
|
6630
6557
|
}
|
|
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();
|
|
6558
|
+
return { name, secret };
|
|
6647
6559
|
}
|
|
6648
6560
|
|
|
6649
6561
|
/**
|
|
6650
|
-
* #40:
|
|
6651
|
-
*
|
|
6562
|
+
* #40: the key was the problem — enter it and list again. The same screen as adding a server
|
|
6563
|
+
* (addServer), with the server already known and a fresh key asked for.
|
|
6652
6564
|
*/
|
|
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);
|
|
6565
|
+
async function enterAgentProviderKey(row, host) {
|
|
6566
|
+
if (!row) return;
|
|
6567
|
+
const dialect = String(row.kind ?? "") === "ollama" ? "ollama" : "openai";
|
|
6568
|
+
await addServer({ role: "agent", dialect, url: String(row.url ?? host), newKey: true });
|
|
6693
6569
|
}
|
|
6694
6570
|
|
|
6695
6571
|
/**
|
|
@@ -6703,7 +6579,7 @@ async function chooseAgentProvider() {
|
|
|
6703
6579
|
return;
|
|
6704
6580
|
}
|
|
6705
6581
|
const choices = am.agentProviderChoices(settings);
|
|
6706
|
-
if (choices.length === 1) return
|
|
6582
|
+
if (choices.length === 1) return addServer({ role: "agent", dialect: "openai" });
|
|
6707
6583
|
console.log(dim(` Which OpenAI-compatible provider should the agent use? (${PICK_HINT})`));
|
|
6708
6584
|
const idx = await pickFromList(choices.map((c) => c.label), 0);
|
|
6709
6585
|
if (idx < 0) {
|
|
@@ -6711,133 +6587,9 @@ async function chooseAgentProvider() {
|
|
|
6711
6587
|
return;
|
|
6712
6588
|
}
|
|
6713
6589
|
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);
|
|
6590
|
+
if (pick.action === "add") return addServer({ role: "agent", dialect: "openai" });
|
|
6591
|
+
// A server already set up: the same screen, URL known — its key is reused, its models listed.
|
|
6592
|
+
await addServer({ role: "agent", dialect: String(pick.row.kind ?? "") === "ollama" ? "ollama" : "openai", url: String(pick.row.url ?? "") });
|
|
6841
6593
|
}
|
|
6842
6594
|
|
|
6843
6595
|
/**
|
|
@@ -6930,8 +6682,8 @@ async function chooseAgentModel() {
|
|
|
6930
6682
|
);
|
|
6931
6683
|
const action = go >= 0 ? options[go].action : "leave";
|
|
6932
6684
|
if (action === "key") await enterAgentProviderKey(source.row, listHost);
|
|
6933
|
-
else if (action === "ollama") await
|
|
6934
|
-
else if (action === "provider") await
|
|
6685
|
+
else if (action === "ollama") await addServer({ role: "agent", dialect: "ollama" });
|
|
6686
|
+
else if (action === "provider") await addServer({ role: "agent", dialect: "openai" });
|
|
6935
6687
|
else console.log("");
|
|
6936
6688
|
return;
|
|
6937
6689
|
}
|
|
@@ -6947,9 +6699,15 @@ async function chooseAgentModel() {
|
|
|
6947
6699
|
console.log(dim(" (cancelled)\n"));
|
|
6948
6700
|
return;
|
|
6949
6701
|
}
|
|
6950
|
-
if (firstAction === "ollama") return
|
|
6702
|
+
if (firstAction === "ollama") return addServer({ role: "agent", dialect: "ollama" });
|
|
6951
6703
|
if (firstAction === "provider") return chooseAgentProvider();
|
|
6952
|
-
if (firstAction === "key")
|
|
6704
|
+
if (firstAction === "key") {
|
|
6705
|
+
// Task #245: one key manager for every role — this server's keys, this folder's AGENT pick.
|
|
6706
|
+
const rowId = String(source.row?.id ?? "").trim() || String(source.row?.kind ?? "").trim();
|
|
6707
|
+
if (!rowId) return enterAgentProviderKey(source.row, listHost);
|
|
6708
|
+
await manageServerKeys({ role: "agent", backendId: rowId, host: listHost, model: am.currentAgentModel(settings) });
|
|
6709
|
+
return;
|
|
6710
|
+
}
|
|
6953
6711
|
|
|
6954
6712
|
const rows = am.agentModelRows(models, current);
|
|
6955
6713
|
console.log(dim(` Agent model — the one that TALKS (currently ${current || "unset"})`));
|
|
@@ -7721,6 +7479,55 @@ async function fetchCodingKeys() {
|
|
|
7721
7479
|
}
|
|
7722
7480
|
}
|
|
7723
7481
|
|
|
7482
|
+
/**
|
|
7483
|
+
* ONE SERVER'S KEYS, masked (task #245) — then the keys tied to no server, which carry no
|
|
7484
|
+
* backendId. Each says whether it is the server's `first` key and its `default`. null = an API
|
|
7485
|
+
* that predates the filter; the caller falls back to fetchCodingKeys.
|
|
7486
|
+
*/
|
|
7487
|
+
async function fetchServerKeys(backendId) {
|
|
7488
|
+
try {
|
|
7489
|
+
const r = await fetch(`${apiBase}/api/worker/coding-keys?backendId=${encodeURIComponent(backendId)}`, {
|
|
7490
|
+
headers: { "X-Worker-Key": workerKey },
|
|
7491
|
+
signal: AbortSignal.timeout(API_CALL_TIMEOUT_MS),
|
|
7492
|
+
});
|
|
7493
|
+
if (!r.ok) return null;
|
|
7494
|
+
const j = await r.json().catch(() => ({}));
|
|
7495
|
+
return Array.isArray(j?.keys) ? j.keys : null;
|
|
7496
|
+
} catch {
|
|
7497
|
+
return null;
|
|
7498
|
+
}
|
|
7499
|
+
}
|
|
7500
|
+
|
|
7501
|
+
/** "Make this the server's default key" (task #245). Returns true when the API took it. */
|
|
7502
|
+
async function setServerDefaultKey(backendId, keyId) {
|
|
7503
|
+
try {
|
|
7504
|
+
const r = await fetch(`${apiBase}/api/worker/server-default-key`, {
|
|
7505
|
+
method: "PUT",
|
|
7506
|
+
headers: { "Content-Type": "application/json", "X-Worker-Key": workerKey },
|
|
7507
|
+
body: JSON.stringify({ backendId, keyId }),
|
|
7508
|
+
signal: AbortSignal.timeout(API_CALL_TIMEOUT_MS),
|
|
7509
|
+
});
|
|
7510
|
+
return r.ok;
|
|
7511
|
+
} catch {
|
|
7512
|
+
return false;
|
|
7513
|
+
}
|
|
7514
|
+
}
|
|
7515
|
+
|
|
7516
|
+
/** What deleting a key would change — wasFirst / wasDefault / folders — WITHOUT deleting it. */
|
|
7517
|
+
async function previewDeleteKey(id) {
|
|
7518
|
+
try {
|
|
7519
|
+
const r = await fetch(`${apiBase}/api/worker/coding-keys/${encodeURIComponent(id)}?dryRun=1`, {
|
|
7520
|
+
method: "DELETE",
|
|
7521
|
+
headers: { "X-Worker-Key": workerKey },
|
|
7522
|
+
signal: AbortSignal.timeout(API_CALL_TIMEOUT_MS),
|
|
7523
|
+
});
|
|
7524
|
+
if (!r.ok) return null;
|
|
7525
|
+
return await r.json().catch(() => null);
|
|
7526
|
+
} catch {
|
|
7527
|
+
return null;
|
|
7528
|
+
}
|
|
7529
|
+
}
|
|
7530
|
+
|
|
7724
7531
|
/**
|
|
7725
7532
|
* Add a named key. Returns its id, or "" on failure.
|
|
7726
7533
|
*
|
|
@@ -7783,6 +7590,9 @@ async function fetchCodingChoice(folderHash) {
|
|
|
7783
7590
|
codingModel: str(c.codingModel),
|
|
7784
7591
|
codingLabel: str(c.codingLabel),
|
|
7785
7592
|
codingUrl: str(c.codingUrl),
|
|
7593
|
+
// Task #245: this folder's own agent / embed keys ("" from an API that predates them).
|
|
7594
|
+
agentKeyId: str(c.agentKeyId),
|
|
7595
|
+
embedKeyId: str(c.embedKeyId),
|
|
7786
7596
|
};
|
|
7787
7597
|
}
|
|
7788
7598
|
|
|
@@ -7809,32 +7619,8 @@ function folderHashFor(cwd) {
|
|
|
7809
7619
|
* larger promise than the question implied.
|
|
7810
7620
|
*/
|
|
7811
7621
|
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
|
-
}
|
|
7622
|
+
const { name, secret } = await askServerKey({ host: provider });
|
|
7623
|
+
if (!secret) return "";
|
|
7838
7624
|
const id = await addCodingKey(name, secret, backendId);
|
|
7839
7625
|
console.log(
|
|
7840
7626
|
id
|
|
@@ -7879,7 +7665,8 @@ async function replaceCodingKey() {
|
|
|
7879
7665
|
const ps = await import("./coding-provider-setup.mjs");
|
|
7880
7666
|
const keys = allKeys ? ps.keysForBackend(allKeys, ks.backendId) : null;
|
|
7881
7667
|
if (keys && keys.length >= 1) {
|
|
7882
|
-
|
|
7668
|
+
// ONE SERVER'S KEYS (task #245): the same picker /agent-model and /embed-model open.
|
|
7669
|
+
await manageServerKeys({ role: "coding", backendId: ks.backendId, host: ks.provider || ks.backendId || "this server", model: ks.model });
|
|
7883
7670
|
return;
|
|
7884
7671
|
}
|
|
7885
7672
|
|
|
@@ -7900,13 +7687,10 @@ async function replaceCodingKey() {
|
|
|
7900
7687
|
// the strength of a question that was asked about one provider.
|
|
7901
7688
|
const id = await promptNewCodingKey(ks.backendId, ks.provider);
|
|
7902
7689
|
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()));
|
|
7690
|
+
// THE SERVER'S FIRST KEY IS ITS DEFAULT (task #245) — the API stamped it — so this folder
|
|
7691
|
+
// FOLLOWS it rather than pinning a copy: a later "make X the default" then reaches this
|
|
7692
|
+
// folder too, which is what "the first choice is the default for every later project" means.
|
|
7693
|
+
await saveFolderKeyPick("coding", "");
|
|
7910
7694
|
codingKeyMissingKnown = false;
|
|
7911
7695
|
keyChoicePending = false;
|
|
7912
7696
|
const kd = await fetchCodingKey(ks.backendId || "");
|
|
@@ -7943,127 +7727,196 @@ async function replaceCodingKey() {
|
|
|
7943
7727
|
}
|
|
7944
7728
|
|
|
7945
7729
|
/**
|
|
7946
|
-
*
|
|
7947
|
-
*
|
|
7948
|
-
*
|
|
7949
|
-
*
|
|
7950
|
-
*
|
|
7730
|
+
* THE KEYS OF ONE SERVER — switch, add, make default, delete — ONE FLOW FOR ALL THREE ROLES
|
|
7731
|
+
* (task #245). /model's ctrl+k, /agent-model and /embed-model all land here, so a key behaves the
|
|
7732
|
+
* same whichever model you reached it from.
|
|
7733
|
+
*
|
|
7734
|
+
* · LISTS THIS SERVER'S KEYS ONLY ("keys of current url only"), then any key tied to no server
|
|
7735
|
+
* yet — picking one of those ties it to this server, since a key belongs to one URL.
|
|
7736
|
+
* · A PICK asks where it applies: just this folder's <role>, or the server's default for every
|
|
7737
|
+
* folder without its own pick. The first key chosen for a server is already its default.
|
|
7738
|
+
* · DELETE previews first (the API's dry run) and says what goes with it — above all when it is
|
|
7739
|
+
* the FIRST key entered for the server, the one its model list was loaded with.
|
|
7740
|
+
*
|
|
7741
|
+
* `role` is "coding" | "agent" | "embed"; `backendId` the server's row id; `host` its name.
|
|
7742
|
+
* Returns the id now in use for this folder's role, or "" when nothing changed.
|
|
7951
7743
|
*/
|
|
7952
|
-
async function
|
|
7953
|
-
const
|
|
7954
|
-
const
|
|
7955
|
-
|
|
7956
|
-
|
|
7744
|
+
async function manageServerKeys({ role = "coding", backendId, host, model = "" }) {
|
|
7745
|
+
const sk = await import("./server-keys.mjs");
|
|
7746
|
+
const { label: roleLabel } = sk.roleOf(role);
|
|
7747
|
+
const folderKeyId = role === "agent" ? sessionAgentKeyId : role === "embed" ? sessionEmbedKeyId : sessionCodingKeyId;
|
|
7748
|
+
// No server id = an API older than provider rows (#223): it cannot scope or default a key, so
|
|
7749
|
+
// the account's keys are listed and a pick is this folder's own.
|
|
7750
|
+
const keys = await withPendingStatus(`reading the keys for ${host}`, () =>
|
|
7751
|
+
backendId ? fetchServerKeys(backendId) : fetchCodingKeys()
|
|
7957
7752
|
);
|
|
7753
|
+
if (!keys) {
|
|
7754
|
+
console.log(red(` couldn't read the keys for ${host} — is this machine's API up to date? (\`gu api restart\`)\n`));
|
|
7755
|
+
paintBar();
|
|
7756
|
+
return "";
|
|
7757
|
+
}
|
|
7758
|
+
const inUse = sk.keyInUse(keys, backendId, folderKeyId);
|
|
7759
|
+
const rows = keys.map((k) => {
|
|
7760
|
+
const tags = sk.keyTags(k, { folderKeyId, inUseId: inUse?.id ?? "" });
|
|
7761
|
+
return `${k.name}${dim(` ····${k.tail}`)}${tags.length ? accent(` (${tags.join(" · ")})`) : ""}`;
|
|
7762
|
+
});
|
|
7958
7763
|
const ADD = rows.length;
|
|
7959
7764
|
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
|
-
);
|
|
7765
|
+
console.log("\n" + GUTTER + dim(`Keys for ${host} — ${folderLabel()}'s ${roleLabel}${model ? ` (${model})` : ""}\n`));
|
|
7966
7766
|
console.log(GUTTER + dim("Enter chooses · ctrl+d deletes the highlighted key\n"));
|
|
7967
|
-
const
|
|
7968
|
-
const picked = await pickFromList(rows, at, { allowDelete: true });
|
|
7767
|
+
const picked = await pickFromList(rows, Math.max(0, keys.findIndex((k) => k.id === inUse?.id)), { allowDelete: true });
|
|
7969
7768
|
const idx = picked?.index ?? -1;
|
|
7970
7769
|
if (idx < 0) {
|
|
7971
|
-
console.log(GUTTER + dim("(cancelled —
|
|
7770
|
+
console.log(GUTTER + dim("(cancelled — nothing changed)\n"));
|
|
7972
7771
|
paintBar();
|
|
7973
|
-
return;
|
|
7772
|
+
return "";
|
|
7974
7773
|
}
|
|
7975
7774
|
|
|
7976
7775
|
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
7776
|
if (idx === ADD) {
|
|
7980
7777
|
console.log(GUTTER + dim("(that row is not a key — nothing deleted)\n"));
|
|
7981
7778
|
paintBar();
|
|
7982
|
-
return;
|
|
7779
|
+
return "";
|
|
7983
7780
|
}
|
|
7984
|
-
await
|
|
7985
|
-
|
|
7781
|
+
await deleteServerKeyFlow({
|
|
7782
|
+
role, key: keys[idx], host, folderKeyId,
|
|
7783
|
+
lastOfServer: keys.filter((k) => k.backendId === backendId).length === 1 && keys[idx].backendId === backendId,
|
|
7784
|
+
});
|
|
7785
|
+
return "";
|
|
7986
7786
|
}
|
|
7987
7787
|
|
|
7988
7788
|
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;
|
|
7789
|
+
const id = await promptNewCodingKey(backendId, host);
|
|
7790
|
+
if (!id) {
|
|
7791
|
+
paintBar();
|
|
7792
|
+
return "";
|
|
7999
7793
|
}
|
|
8000
|
-
|
|
8001
|
-
|
|
7794
|
+
// A key added here is one you mean to use. The API made it the server's default when it was
|
|
7795
|
+
// the server's first; otherwise the question is the same as for a picked key.
|
|
7796
|
+
const fresh = await fetchServerKeys(backendId);
|
|
7797
|
+
const added = fresh?.find((k) => k.id === id);
|
|
7798
|
+
if (added?.default) {
|
|
7799
|
+
console.log(accent(` ✓ ${added.name} is ${host}'s default key\n`));
|
|
7800
|
+
paintBar();
|
|
7801
|
+
return id;
|
|
7802
|
+
}
|
|
7803
|
+
return await applyKeyPick({ role, backendId, host, key: added ?? { id, name: "the new key", tail: "" } });
|
|
7804
|
+
}
|
|
7805
|
+
|
|
7806
|
+
const key = keys[idx];
|
|
7807
|
+
// A KEY TIED TO NO SERVER is tied to this one before it is used: it belongs to one URL now.
|
|
7808
|
+
if (!key.backendId && backendId) {
|
|
7809
|
+
const ok = await withPendingStatus(`tying ${key.name} to ${host}`, () => bindKeyToServer(key.id, backendId));
|
|
7810
|
+
if (!ok) {
|
|
7811
|
+
console.log(red(` couldn't tie ${key.name} to ${host} — nothing changed.\n`));
|
|
7812
|
+
paintBar();
|
|
7813
|
+
return "";
|
|
7814
|
+
}
|
|
7815
|
+
}
|
|
7816
|
+
return await applyKeyPick({ role, backendId, host, key });
|
|
7817
|
+
}
|
|
7818
|
+
|
|
7819
|
+
/**
|
|
7820
|
+
* Where a picked key applies (task #245): just this folder's role, or the server's default.
|
|
7821
|
+
* Picking the server's default itself in a folder that had its own pick CLEARS the pick — the
|
|
7822
|
+
* folder goes back to following the default, which is what choosing the default means.
|
|
7823
|
+
*/
|
|
7824
|
+
async function applyKeyPick({ role, backendId, host, key }) {
|
|
7825
|
+
const sk = await import("./server-keys.mjs");
|
|
7826
|
+
const { label: roleLabel } = sk.roleOf(role);
|
|
7827
|
+
let scope = "folder";
|
|
7828
|
+
if (!backendId) {
|
|
7829
|
+
await saveFolderKeyPick(role, key.id);
|
|
7830
|
+
console.log(accent(` ✓ ${folderLabel()}'s ${roleLabel} → ${key.name}`) + dim(` ····${key.tail}\n`));
|
|
7831
|
+
} else if (key.default) {
|
|
7832
|
+
await saveFolderKeyPick(role, "");
|
|
7833
|
+
console.log(accent(` ✓ ${folderLabel()}'s ${roleLabel} → ${key.name}`) + dim(` ····${key.tail} · ${host}'s default\n`));
|
|
7834
|
+
} else {
|
|
7835
|
+
const choices = sk.scopeChoices({ host, role });
|
|
7836
|
+
console.log("\n" + GUTTER + dim(`Use ${key.name} for…`));
|
|
7837
|
+
const at = await pickFromList(choices.map((c) => c.label), 0);
|
|
7838
|
+
if (at < 0) {
|
|
7839
|
+
console.log(GUTTER + dim("(cancelled — nothing changed)\n"));
|
|
7840
|
+
paintBar();
|
|
7841
|
+
return "";
|
|
7842
|
+
}
|
|
7843
|
+
scope = choices[at].scope;
|
|
7844
|
+
if (scope === "default") {
|
|
7845
|
+
const ok = await withPendingStatus(`making ${key.name} the default for ${host}`, () => setServerDefaultKey(backendId, key.id));
|
|
7846
|
+
if (!ok) {
|
|
7847
|
+
console.log(red(" couldn't change the default — nothing changed.\n"));
|
|
7848
|
+
paintBar();
|
|
7849
|
+
return "";
|
|
7850
|
+
}
|
|
7851
|
+
// This folder follows the default now, rather than pinning a copy of it.
|
|
7852
|
+
await saveFolderKeyPick(role, "");
|
|
7853
|
+
console.log(accent(` ✓ ${key.name} is now ${host}'s default`) + dim(` ····${key.tail} · every folder without its own pick\n`));
|
|
7854
|
+
} else {
|
|
7855
|
+
await saveFolderKeyPick(role, key.id);
|
|
7856
|
+
console.log(accent(` ✓ ${folderLabel()}'s ${roleLabel} → ${key.name}`) + dim(` ····${key.tail} · this folder only\n`));
|
|
7857
|
+
}
|
|
7858
|
+
}
|
|
7859
|
+
if (role === "coding") {
|
|
7860
|
+
keyChoicePending = false;
|
|
7861
|
+
codingKeyMissingKnown = false;
|
|
7862
|
+
sessionApiKeyTail = key.tail || "";
|
|
8002
7863
|
}
|
|
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
7864
|
paintBar();
|
|
7865
|
+
return key.id;
|
|
7866
|
+
}
|
|
7867
|
+
|
|
7868
|
+
/** Tie a key that belongs to no server to this one (PATCH, the binding only — never the secret). */
|
|
7869
|
+
async function bindKeyToServer(id, backendId) {
|
|
7870
|
+
try {
|
|
7871
|
+
const r = await fetch(`${apiBase}/api/worker/coding-keys/${encodeURIComponent(id)}`, {
|
|
7872
|
+
method: "PATCH",
|
|
7873
|
+
headers: { "Content-Type": "application/json", "X-Worker-Key": workerKey },
|
|
7874
|
+
body: JSON.stringify({ backendId }),
|
|
7875
|
+
signal: AbortSignal.timeout(API_CALL_TIMEOUT_MS),
|
|
7876
|
+
});
|
|
7877
|
+
return r.ok;
|
|
7878
|
+
} catch {
|
|
7879
|
+
return false;
|
|
7880
|
+
}
|
|
8017
7881
|
}
|
|
8018
7882
|
|
|
8019
7883
|
/**
|
|
8020
|
-
* Confirm and delete one key (task #222).
|
|
7884
|
+
* Confirm and delete one key (task #222; the warnings are #245's).
|
|
8021
7885
|
*
|
|
8022
|
-
* ASKED, NOT ASSUMED
|
|
8023
|
-
*
|
|
8024
|
-
*
|
|
8025
|
-
* can share a name and only the last four characters tell them apart.
|
|
8026
|
-
*
|
|
8027
|
-
* The two consequences worth stating up front are stated up front: deleting the key this folder
|
|
8028
|
-
* uses, and deleting the last key of all. Neither is refused — both are legitimate — but neither
|
|
8029
|
-
* should be discovered afterwards.
|
|
7886
|
+
* ASKED, NOT ASSUMED — the secret is not recoverable. What goes with it is read from the API's
|
|
7887
|
+
* dry run and said BEFORE the question: the FIRST key entered for the server (its model list was
|
|
7888
|
+
* loaded with it), the server's default, this folder's pick, other folders' picks.
|
|
8030
7889
|
*/
|
|
8031
|
-
async function
|
|
8032
|
-
const
|
|
8033
|
-
const
|
|
8034
|
-
const isLast = keys.length === 1;
|
|
7890
|
+
async function deleteServerKeyFlow({ role, key, host, folderKeyId, lastOfServer = false }) {
|
|
7891
|
+
const sk = await import("./server-keys.mjs");
|
|
7892
|
+
const preview = await withPendingStatus("checking what uses this key", () => previewDeleteKey(key.id));
|
|
8035
7893
|
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
|
-
);
|
|
7894
|
+
for (const line of sk.deleteWarnings(preview, {
|
|
7895
|
+
host,
|
|
7896
|
+
isFolderPick: key.id === folderKeyId,
|
|
7897
|
+
roleLabel: sk.roleOf(role).label,
|
|
7898
|
+
lastOfServer,
|
|
7899
|
+
})) {
|
|
7900
|
+
console.log(GUTTER + orange(` ${line}`));
|
|
7901
|
+
}
|
|
7902
|
+
const yes = await askYesNo(`Delete the API key "${key.name}" (····${key.tail})? This cannot be undone.`, false);
|
|
8048
7903
|
if (!yes) {
|
|
8049
7904
|
console.log(GUTTER + dim("(cancelled — nothing was deleted)\n"));
|
|
8050
7905
|
paintBar();
|
|
8051
7906
|
return;
|
|
8052
7907
|
}
|
|
8053
|
-
const out = await deleteCodingKey(
|
|
7908
|
+
const out = await deleteCodingKey(key.id);
|
|
8054
7909
|
if (!out.ok) {
|
|
8055
7910
|
console.log(red(" couldn't delete the key — try again, or remove it in web Settings → Agent\n"));
|
|
8056
7911
|
paintBar();
|
|
8057
7912
|
return;
|
|
8058
7913
|
}
|
|
8059
|
-
// A folder pointing at a key
|
|
8060
|
-
|
|
8061
|
-
|
|
8062
|
-
|
|
8063
|
-
await saveSessionCodingKeyId(resolve(process.cwd()));
|
|
7914
|
+
// A folder pointing at a deleted key would keep sending a dead id; put it back on the default.
|
|
7915
|
+
for (const r of ["coding", "agent", "embed"]) {
|
|
7916
|
+
const mine = r === "agent" ? sessionAgentKeyId : r === "embed" ? sessionEmbedKeyId : sessionCodingKeyId;
|
|
7917
|
+
if (mine === key.id) await saveFolderKeyPick(r, "");
|
|
8064
7918
|
}
|
|
8065
|
-
console.log(accent(` ✓ deleted ${out.name ||
|
|
8066
|
-
dim(` ····${victim.tail} · ${out.remaining} key(s) left\n`));
|
|
7919
|
+
console.log(accent(` ✓ deleted ${out.name || key.name}`) + dim(` ····${key.tail} · ${out.remaining} key(s) left\n`));
|
|
8067
7920
|
paintBar();
|
|
8068
7921
|
}
|
|
8069
7922
|
|
|
@@ -8641,6 +8494,10 @@ let sessionCodingModel = "";
|
|
|
8641
8494
|
* a folder keeps meaning until someone picks.
|
|
8642
8495
|
*/
|
|
8643
8496
|
let sessionCodingKeyId = "";
|
|
8497
|
+
// THIS FOLDER'S OWN KEY for the agent / the embedder (task #245) — per role, like the coder's.
|
|
8498
|
+
// "" = the server's default key, which is what a folder means until someone picks otherwise.
|
|
8499
|
+
let sessionAgentKeyId = "";
|
|
8500
|
+
let sessionEmbedKeyId = "";
|
|
8644
8501
|
// Last 4 characters of the current API key, for display in the bottom bar
|
|
8645
8502
|
let sessionApiKeyTail = "";
|
|
8646
8503
|
// The friendly model name behind sessionCodingModel, for display only: the token footer
|
|
@@ -8936,24 +8793,17 @@ async function runAgentTurn(history, { attachJobId = "" } = {}) {
|
|
|
8936
8793
|
// Deploy target chosen via /server — host/user only (no secret), for key-auth deploys.
|
|
8937
8794
|
...(selectedServer ? { deployServer: selectedServer } : {}),
|
|
8938
8795
|
...(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
|
|
8796
|
+
// THIS FOLDER'S OWN KEY PICKS, PER ROLE (task #245) — ids, never secrets, recorded on the
|
|
8797
|
+
// job so two folders' concurrent turns each resolve their own.
|
|
8949
8798
|
//
|
|
8950
|
-
//
|
|
8951
|
-
//
|
|
8952
|
-
//
|
|
8953
|
-
// the API
|
|
8954
|
-
//
|
|
8955
|
-
|
|
8956
|
-
|
|
8799
|
+
// They were dropped once (#222 → the "folder names no key" rule) because an UNBOUND key
|
|
8800
|
+
// picked in a folder followed the folder from provider to provider, and each provider was
|
|
8801
|
+
// handed the other's credential (api.openai.com and api.moonshot.ai both 401'd). That is
|
|
8802
|
+
// now impossible at the API: a pick counts only when it is one of the turn's SERVER'S OWN
|
|
8803
|
+
// keys (resolveCodingApiKey), and anything else falls back to that server's default key.
|
|
8804
|
+
...(sessionCodingKeyId ? { codingKeyId: sessionCodingKeyId } : {}),
|
|
8805
|
+
...(sessionAgentKeyId ? { agentKeyId: sessionAgentKeyId } : {}),
|
|
8806
|
+
...(sessionEmbedKeyId ? { embedKeyId: sessionEmbedKeyId } : {}),
|
|
8957
8807
|
}),
|
|
8958
8808
|
});
|
|
8959
8809
|
const data = res ? await res.json().catch(() => ({})) : {};
|
|
@@ -10154,7 +10004,35 @@ async function runAgentTurn(history, { attachJobId = "" } = {}) {
|
|
|
10154
10004
|
}
|
|
10155
10005
|
|
|
10156
10006
|
// ---------- REPL ----------
|
|
10007
|
+
/**
|
|
10008
|
+
* LOAD EVERY LAZILY-IMPORTED MODULE NOW, so this process never reads one from disk later.
|
|
10009
|
+
*
|
|
10010
|
+
* REPORTED: "✓ gu 1.0.722 installed — Then restart gu", then /embed-model in the SAME terminal
|
|
10011
|
+
* crashed it with ERR_MODULE_NOT_FOUND …/ollama-host-snippets.mjs. The running process was still
|
|
10012
|
+
* 1.0.721; its /embed-model lazily imported ollama-host-snippets.mjs, and npm had just
|
|
10013
|
+
* replaced the package directory with 1.0.722, which no longer ships that file. A module that
|
|
10014
|
+
* still EXISTS is worse, not better: the old REPL silently runs against the new helper, two
|
|
10015
|
+
* versions mixed in one process.
|
|
10016
|
+
*
|
|
10017
|
+
* The ESM loader caches a module by its resolved URL, so once each one is loaded here every later
|
|
10018
|
+
* lazy import of that name gets THIS version back without touching the disk — whatever an in-app update
|
|
10019
|
+
* wrote there. The list is read from this file's own source, so a new lazy import is covered
|
|
10020
|
+
* without anyone remembering to add it. gu-cli.mjs is excluded: importing it RUNS the CLI.
|
|
10021
|
+
* Fire-and-forget: each import is ~5 ms, verified side-effect free (no output, no files).
|
|
10022
|
+
*/
|
|
10023
|
+
function preloadLazyModules() {
|
|
10024
|
+
let text = "";
|
|
10025
|
+
try {
|
|
10026
|
+
text = readFileSync(fileURLToPath(import.meta.url), "utf8");
|
|
10027
|
+
} catch {
|
|
10028
|
+
return;
|
|
10029
|
+
}
|
|
10030
|
+
for (const name of lazyModuleNames(text)) import(`./${name}`).catch(() => {});
|
|
10031
|
+
}
|
|
10032
|
+
|
|
10033
|
+
|
|
10157
10034
|
async function main() {
|
|
10035
|
+
preloadLazyModules();
|
|
10158
10036
|
// The startup banner (logo + status facts) is printed AFTER the payload fetch below, once the
|
|
10159
10037
|
// model/coder/RAG are known — see renderBanner (task #131). Nothing prints here so the banner
|
|
10160
10038
|
// lands as one clean block.
|
|
@@ -10328,10 +10206,14 @@ async function main() {
|
|
|
10328
10206
|
sessionCodingUrl = dbChoice.codingUrl;
|
|
10329
10207
|
}
|
|
10330
10208
|
if (dbChoice.codingKeyId) sessionCodingKeyId = dbChoice.codingKeyId;
|
|
10209
|
+
sessionAgentKeyId = dbChoice.agentKeyId;
|
|
10210
|
+
sessionEmbedKeyId = dbChoice.embedKeyId;
|
|
10331
10211
|
// Refresh the local cache so the file matches the database.
|
|
10332
10212
|
try {
|
|
10333
10213
|
await updateSessionFile(resolve(process.cwd()), (raw) => {
|
|
10334
10214
|
raw.codingKeyId = dbChoice.codingKeyId;
|
|
10215
|
+
raw.agentKeyId = dbChoice.agentKeyId;
|
|
10216
|
+
raw.embedKeyId = dbChoice.embedKeyId;
|
|
10335
10217
|
raw.codingModel = dbChoice.codingModel;
|
|
10336
10218
|
raw.codingLabel = dbChoice.codingLabel;
|
|
10337
10219
|
raw.codingUrl = dbChoice.codingUrl;
|
|
@@ -10349,6 +10231,8 @@ async function main() {
|
|
|
10349
10231
|
sessionCodingUrl = c.url;
|
|
10350
10232
|
}
|
|
10351
10233
|
sessionCodingKeyId = await loadSessionCodingKeyId(resolve(process.cwd())); // task #222
|
|
10234
|
+
sessionAgentKeyId = await loadSessionFolderKeyId(resolve(process.cwd()), "agent"); // task #245
|
|
10235
|
+
sessionEmbedKeyId = await loadSessionFolderKeyId(resolve(process.cwd()), "embed");
|
|
10352
10236
|
}
|
|
10353
10237
|
}
|
|
10354
10238
|
// Fetch the API key tail after loading the session, if a key is configured
|