@tiens.nguyen/gu-cli 1.0.718 → 1.0.719
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/embed-model-command.mjs +26 -0
- package/gu-repl.mjs +273 -68
- package/package.json +1 -1
package/embed-model-command.mjs
CHANGED
|
@@ -86,6 +86,32 @@ export function embedChoiceRows(current) {
|
|
|
86
86
|
return rows;
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
/**
|
|
90
|
+
* THE /embed-model MENU — the agent-model parity half (#169 follow-up).
|
|
91
|
+
*
|
|
92
|
+
* Choosing WHICH embedder is only one of the decisions; the others are WHERE it runs and HOW it
|
|
93
|
+
* authenticates. A chat model swap is reversible and cheap, so /agent-model offers its server its
|
|
94
|
+
* own row; an embedder swap rewrites every vector on disk, so here the model and the server are
|
|
95
|
+
* ONE decision — but the server and its credential still deserve to be set on their own, for the
|
|
96
|
+
* same reason /agent-model lets you point at another OpenAI-compatible provider or re-key
|
|
97
|
+
* ollama.com: the embedder may live on a remote Ollama, on ollama.com, or behind an
|
|
98
|
+
* OpenAI-compatible /v1/embeddings, and each wants a key named so it stays readable.
|
|
99
|
+
*
|
|
100
|
+
* Pure, like the rest of this module: the REPL owns prompt/save; this owns the SHAPE of the menu.
|
|
101
|
+
* `host` is the host the embedder is currently configured against ("" = not yet); `keyRequired`
|
|
102
|
+
* is the REPL's read of isOllamaCloudUrl(url) || an OpenAI row — passed in, not derived, so this
|
|
103
|
+
* never re-imports the network-layer predicate.
|
|
104
|
+
*/
|
|
105
|
+
export function embedMenuOptions({ current = DEFAULT_EMBED_MODEL, host = "", keyRequired = false } = {}) {
|
|
106
|
+
const where = host || "this server";
|
|
107
|
+
return [
|
|
108
|
+
{ action: "model", label: `switch the embedder model (currently ${current})` },
|
|
109
|
+
{ action: "ollama", label: "use an Ollama server for embedding…" },
|
|
110
|
+
{ action: "provider", label: "use another OpenAI-compatible provider…" },
|
|
111
|
+
...(keyRequired ? [{ action: "key", label: `enter a new API key for ${where}…` }] : []),
|
|
112
|
+
];
|
|
113
|
+
}
|
|
114
|
+
|
|
89
115
|
/**
|
|
90
116
|
* WHAT SWITCHING EMBEDDERS DOES TO WHAT IS ALREADY INDEXED — the half of this command that is
|
|
91
117
|
* not a menu.
|
package/gu-repl.mjs
CHANGED
|
@@ -132,7 +132,7 @@ import {
|
|
|
132
132
|
import { createHoverController, applyToggleShift } from "./terminal-hover.mjs";
|
|
133
133
|
// The required pair, imported rather than re-typed: the startup "no models" message names them,
|
|
134
134
|
// and a second copy of these strings would drift from the check that enforces them.
|
|
135
|
-
import { REQUIRED_AGENT_MODEL, REQUIRED_AGENT_PREFIX } from "./ollama-setup.mjs";
|
|
135
|
+
import { ACCEPTED_EMBED_MODELS, REQUIRED_AGENT_MODEL, REQUIRED_AGENT_PREFIX } from "./ollama-setup.mjs";
|
|
136
136
|
// The brand accent — indigo, the same token the web app paints its wordmark with. It is NOT a
|
|
137
137
|
// plain SGR code: terminals disagree about colour, so accent.mjs picks 24-bit, 256 or the
|
|
138
138
|
// sixteen-colour magenta from what this one actually claims to support.
|
|
@@ -6274,9 +6274,8 @@ async function fetchWorkerSettings() {
|
|
|
6274
6274
|
return null; // offline — callers fall back to showing what they know
|
|
6275
6275
|
}
|
|
6276
6276
|
}
|
|
6277
|
-
|
|
6278
6277
|
/**
|
|
6279
|
-
* `/embed-model` — switch the RAG embedder (task #169).
|
|
6278
|
+
* `/embed-model` — switch the RAG embedder, the embed server, or the embed key (task #169).
|
|
6280
6279
|
*
|
|
6281
6280
|
* NOT THE SAME SHAPE AS /model, and the difference is the point. A coding model can be swapped
|
|
6282
6281
|
* between turns with no consequences; an embedder decides the shape of every vector already on
|
|
@@ -6284,99 +6283,305 @@ async function fetchWorkerSettings() {
|
|
|
6284
6283
|
* over two models returns a number, rag_search prints its top-k, and the model answers from it.
|
|
6285
6284
|
* So this command shows what it is about to invalidate and asks, instead of just writing.
|
|
6286
6285
|
*
|
|
6286
|
+
* Same menu shape as /agent-model (#44) now that one embed setup is three decisions: WHICH model
|
|
6287
|
+
* (model), WHERE it runs (ollama / an OpenAI-compatible provider) and HOW it authenticates (key).
|
|
6288
|
+
* Only a model change rebuilds indexes — moving the SAME embedder to another server, or changing
|
|
6289
|
+
* only its key, leaves every vector valid, so those paths save without the plan wall. The worker
|
|
6290
|
+
* already speaks both dialects: gonext_agent_chat._embed() POSTs {base}/v1/embeddings first, then
|
|
6291
|
+
* Ollama /api/embed and /api/embeddings, and sends the "embed-ollama" store key as a Bearer when
|
|
6292
|
+
* one exists — so pointing at an OpenAI-compatible provider here needs no worker-side change.
|
|
6293
|
+
*
|
|
6287
6294
|
* The decisions live in embed-model-command.mjs; this is the terminal half only.
|
|
6288
6295
|
*/
|
|
6289
|
-
|
|
6290
|
-
|
|
6291
|
-
|
|
6292
|
-
|
|
6293
|
-
|
|
6294
|
-
|
|
6295
|
-
|
|
6296
|
-
const
|
|
6297
|
-
|
|
6298
|
-
console.log(dim(` Embedding model (currently ${current})`));
|
|
6299
|
-
console.log(dim(` Choose the embedder (${PICK_HINT}):`));
|
|
6300
|
-
const labels = rows.map(
|
|
6301
|
-
(r) => `${r.model}${r.dims ? dim(` · ${r.dims}d`) : ""}${dim(` · ${r.note}`)}` +
|
|
6302
|
-
(r.current ? accent(" (current)") : "")
|
|
6296
|
+
|
|
6297
|
+
// THE SAVE, ONE WAY. Every sub-flow ends at the same write so the failure wording and the
|
|
6298
|
+
// red-not-dim treatment can't drift: a failed write must never look like a success, or the next
|
|
6299
|
+
// turn keeps using the old embedder while the user was told the index was rebuilt for a change
|
|
6300
|
+
// that never landed — worse than the original confusion (#169 again).
|
|
6301
|
+
async function saveEmbedSettings(fields) {
|
|
6302
|
+
const md = await import("./model-doctor.mjs");
|
|
6303
|
+
const saved = await withPendingStatus("saving your embedder", () =>
|
|
6304
|
+
md.saveWorkerSettings(apiBase, workerKey, fields)
|
|
6303
6305
|
);
|
|
6304
|
-
|
|
6305
|
-
|
|
6306
|
-
|
|
6307
|
-
|
|
6306
|
+
if (!saved) console.log(red(" couldn't save that to your account — nothing changed.\n"));
|
|
6307
|
+
return saved;
|
|
6308
|
+
}
|
|
6309
|
+
|
|
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: "" };
|
|
6308
6344
|
}
|
|
6309
|
-
const
|
|
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
|
+
}
|
|
6310
6353
|
|
|
6311
|
-
|
|
6312
|
-
|
|
6313
|
-
|
|
6354
|
+
// THE RECOGNITION STEP, ONE WAY. Switching embedders invalidates the indexes; naming the projects
|
|
6355
|
+
// that stop working is the decision the user is actually making, so the plan is read BEFORE the
|
|
6356
|
+
// "switch / keep" picker — never after, never implied. Returns { changed:false } when the user
|
|
6357
|
+
// keeps the current embedder or the choice is a no-op.
|
|
6358
|
+
async function confirmEmbedSwitch(ec, current, chosen) {
|
|
6314
6359
|
let indexes = [];
|
|
6315
6360
|
try {
|
|
6361
|
+
const md = await import("./model-doctor.mjs");
|
|
6316
6362
|
indexes = await md.listLocalRagIndexes();
|
|
6317
6363
|
} catch {
|
|
6318
|
-
/* unreadable store — the plan degrades to "we can't tell", handled
|
|
6364
|
+
/* unreadable store — the plan degrades to "we can't tell", handled by embedChangePlan */
|
|
6319
6365
|
}
|
|
6320
6366
|
const plan = ec.embedChangePlan({ current, chosen, indexes });
|
|
6321
6367
|
if (!plan.changed) {
|
|
6322
6368
|
console.log(dim(` already using ${current} — nothing changed.\n`));
|
|
6323
|
-
return;
|
|
6369
|
+
return { changed: false, plan: null };
|
|
6324
6370
|
}
|
|
6371
|
+
// "Are you sure?" with nothing behind it is a prompt people learn to dismiss; the four projects
|
|
6372
|
+
// that stop working are a decision they can make.
|
|
6325
6373
|
for (const l of ec.describeEmbedChange(plan)) console.log(GUTTER + dim(l));
|
|
6326
6374
|
const go = await pickFromList([`switch to ${chosen}`, `keep ${current}`], plan.stale.length ? 1 : 0);
|
|
6327
6375
|
if (go !== 0) {
|
|
6328
6376
|
console.log(dim(" (kept)\n"));
|
|
6329
|
-
return;
|
|
6377
|
+
return { changed: false, plan: null };
|
|
6330
6378
|
}
|
|
6379
|
+
return { changed: true, plan };
|
|
6380
|
+
}
|
|
6331
6381
|
|
|
6332
|
-
|
|
6333
|
-
|
|
6334
|
-
|
|
6335
|
-
|
|
6336
|
-
|
|
6337
|
-
|
|
6338
|
-
|
|
6339
|
-
|
|
6340
|
-
|
|
6341
|
-
|
|
6342
|
-
|
|
6343
|
-
|
|
6344
|
-
embedKeyId = "";
|
|
6345
|
-
} else if (embedUrl && isOllamaCloudUrl(embedUrl)) {
|
|
6346
|
-
console.log(GUTTER + dim('A name just tells keys apart — "embed", "work" '));
|
|
6347
|
-
const name = (await askKeyName()).trim();
|
|
6348
|
-
console.log(GUTTER + dim(SECRET_KEY_HINT));
|
|
6349
|
-
const key = (await askSecret(` API key for ${embedUrl}${name ? ` (${name})` : ""}: `)).trim();
|
|
6350
|
-
if (key) {
|
|
6351
|
-
const id = await withPendingStatus("saving the key", () => addCodingKey(name, key, "embed-ollama"));
|
|
6352
|
-
if (!id) console.log(red(" couldn't save the embed key — searching stays unauthenticated.\n"));
|
|
6353
|
-
else { embedKeyId = id; console.log(GUTTER + dim(`embed key "${name}" saved.\n`)); }
|
|
6354
|
-
}
|
|
6355
|
-
}
|
|
6356
|
-
const saved = await md.saveWorkerSettings(apiBase, workerKey, {
|
|
6357
|
-
ragEmbedModel: chosen,
|
|
6358
|
-
ragEmbedUrl: embedUrl,
|
|
6359
|
-
ragEmbedKeyId: embedKeyId,
|
|
6360
|
-
});
|
|
6361
|
-
if (!saved) {
|
|
6362
|
-
// A FAILED WRITE MUST NOT LOOK LIKE A SUCCESS. The next turn would keep using the old
|
|
6363
|
-
// embedder, and the user would be told their index was rebuilt for a change that never
|
|
6364
|
-
// happened — worse than the original confusion, because now they trust it.
|
|
6365
|
-
console.log(red(` couldn't save that to your account — still using ${current}.\n`));
|
|
6366
|
-
return;
|
|
6382
|
+
// WHICH EMBEDDER, ASKED ONCE. The model sub-flow of /embed-model: the curated picker, then the
|
|
6383
|
+
// shared recognition step. Returns { changed, chosen, plan }.
|
|
6384
|
+
async function pickEmbedModel(ec, current) {
|
|
6385
|
+
const rows = ec.embedChoiceRows(current);
|
|
6386
|
+
console.log(dim(` Choose the embedder (${PICK_HINT}):`));
|
|
6387
|
+
const labels = rows.map(
|
|
6388
|
+
(r) => `${r.model}${r.dims ? dim(` · ${r.dims}d`) : ""}${dim(` · ${r.note}`)}` + (r.current ? accent(" (current)") : "")
|
|
6389
|
+
);
|
|
6390
|
+
const idx = await pickFromList(labels, Math.max(0, rows.findIndex((r) => r.current)));
|
|
6391
|
+
if (idx < 0) {
|
|
6392
|
+
console.log(dim(" (cancelled)\n"));
|
|
6393
|
+
return { changed: false, chosen: "", plan: null };
|
|
6367
6394
|
}
|
|
6368
|
-
|
|
6369
|
-
|
|
6395
|
+
const chosen = rows[idx].model;
|
|
6396
|
+
const sw = await confirmEmbedSwitch(ec, current, chosen);
|
|
6397
|
+
return { changed: sw.changed, chosen: sw.changed ? chosen : "", plan: sw.plan };
|
|
6398
|
+
}
|
|
6399
|
+
|
|
6400
|
+
// WHAT A SAVED CHANGE SOUNDS LIKE. Success lines used to live at the end of one long function;
|
|
6401
|
+
// four exit paths now say them, so they are said once here: the embedder line, the rebuild note
|
|
6402
|
+
// that only a REAL model change earns, and the pull hint — Ollama serves the embedder itself, so
|
|
6403
|
+
// a model that was never pulled fails at the first search, not here. Say it once rather than let
|
|
6404
|
+
// it surface as "RAG is broken".
|
|
6405
|
+
function announceEmbedSaved({ chosen, plan, url }) {
|
|
6406
|
+
console.log(accent(` ✓ embedding model → ${chosen}` + (url ? ` on ${hostOf(url)}` : "")));
|
|
6407
|
+
if (plan?.stale.length) {
|
|
6370
6408
|
console.log(
|
|
6371
6409
|
dim(` ${plan.stale.length} knowledge base${plan.stale.length === 1 ? "" : "s"} ` +
|
|
6372
6410
|
"will rebuild on the next search — the first one will be slower.")
|
|
6373
6411
|
);
|
|
6374
6412
|
}
|
|
6375
|
-
// Ollama serves the embedder itself, so a model that was never pulled fails at the first
|
|
6376
|
-
// search rather than here. Say it once, rather than let it surface as "RAG is broken".
|
|
6377
6413
|
console.log(dim(` Make sure it is pulled there: ollama pull ${chosen}\n`));
|
|
6378
6414
|
}
|
|
6379
6415
|
|
|
6416
|
+
async function chooseEmbedModel() {
|
|
6417
|
+
const ec = await import("./embed-model-command.mjs");
|
|
6418
|
+
const settings = await withPendingStatus("reading your embedder", fetchWorkerSettings);
|
|
6419
|
+
if (!settings) {
|
|
6420
|
+
console.log(red(" couldn't reach the API to read your current embedder.\n"));
|
|
6421
|
+
return;
|
|
6422
|
+
}
|
|
6423
|
+
const current = ec.currentEmbedModel(settings);
|
|
6424
|
+
const currentUrl = String(settings.ragEmbedUrl || "").trim() || "http://127.0.0.1:11434";
|
|
6425
|
+
const host = hostOf(currentUrl);
|
|
6426
|
+
// WHEN TO SHOW THE KEY ROW (embedMenuOptions' keyRequired): a key is clearly the question when
|
|
6427
|
+
// the server is ollama.com (it rejects anonymous calls) or already looks OpenAI-compatible (the
|
|
6428
|
+
// /v1 shape), or when one is already bound — replacing the key people can no longer read is the
|
|
6429
|
+
// very thing they come back for. A keyless localhost Ollama sees three rows instead.
|
|
6430
|
+
const keyRequired = isOllamaCloudUrl(currentUrl) || /\/v1\/?$/i.test(currentUrl) ||
|
|
6431
|
+
Boolean(String(settings.ragEmbedKeyId ?? "").trim());
|
|
6432
|
+
const menu = ec.embedMenuOptions({ current, host, keyRequired });
|
|
6433
|
+
console.log(dim(` Embedding model (currently ${current} on ${host || currentUrl})`));
|
|
6434
|
+
const mi = await pickFromList(menu.map((o) => o.label), 0);
|
|
6435
|
+
const action = mi >= 0 ? menu[mi].action : "cancel";
|
|
6436
|
+
if (action === "cancel") {
|
|
6437
|
+
console.log(dim(" (cancelled)\n"));
|
|
6438
|
+
return;
|
|
6439
|
+
}
|
|
6440
|
+
|
|
6441
|
+
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
|
+
const { changed, chosen, plan } = await pickEmbedModel(ec, current);
|
|
6449
|
+
if (!changed) return;
|
|
6450
|
+
const embedUrl = await pickEmbedServerUrl({ currentUrl });
|
|
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 });
|
|
6465
|
+
return;
|
|
6466
|
+
}
|
|
6467
|
+
|
|
6468
|
+
if (action === "ollama") {
|
|
6469
|
+
// SAME MODEL, NEW OLLAMA. Vectors come from the model, not the box — moving nomic-embed-text
|
|
6470
|
+
// from 127.0.0.1 to another Ollama changes no numbers, so no wall and no rebuild. The
|
|
6471
|
+
// ollama.com arm still collects the key; a keyless host clears the stale ragEmbedKeyId label.
|
|
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.
|
|
6501
|
+
const ps = await import("./coding-provider-setup.mjs");
|
|
6502
|
+
let base = "";
|
|
6503
|
+
for (;;) {
|
|
6504
|
+
const raw = await ask(` provider base URL (e.g. https://openrouter.ai/api/v1): `);
|
|
6505
|
+
if (raw === null || !raw.trim()) {
|
|
6506
|
+
console.log(dim(" (cancelled)\n"));
|
|
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;
|
|
6546
|
+
} else {
|
|
6547
|
+
console.log(dim(` ${providerHost} did not list its models — type the embedding model id exactly as ${providerHost} calls it.`));
|
|
6548
|
+
const raw = await ask(` embedding model id [${current}]: `);
|
|
6549
|
+
if (raw === null) {
|
|
6550
|
+
console.log(dim(" (cancelled)\n"));
|
|
6551
|
+
return;
|
|
6552
|
+
}
|
|
6553
|
+
chosen = raw.trim() || current;
|
|
6554
|
+
}
|
|
6555
|
+
// A MODEL CHANGE THROUGH THE PROVIDER ROW IS STILL A REINDEX — same wall, same wording. Same
|
|
6556
|
+
// model = a pure server/key move, and no knowledge base needs a rebuild.
|
|
6557
|
+
let plan = null;
|
|
6558
|
+
if (!ec.sameEmbedModel(chosen, current)) {
|
|
6559
|
+
const sw = await confirmEmbedSwitch(ec, current, chosen);
|
|
6560
|
+
if (!sw.changed) return;
|
|
6561
|
+
plan = sw.plan;
|
|
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`));
|
|
6582
|
+
}
|
|
6583
|
+
}
|
|
6584
|
+
|
|
6380
6585
|
/** Save the agent model on this machine: the URL the API sends turns to, and the model name. */
|
|
6381
6586
|
async function saveAgentModel(agentUrl, model, host) {
|
|
6382
6587
|
const md = await import("./model-doctor.mjs");
|
|
@@ -10504,7 +10709,7 @@ async function main() {
|
|
|
10504
10709
|
"\n Nothing can run until it knows which Ollama server to use." +
|
|
10505
10710
|
"\n Start `gu`, then `/model` → change the Ollama server this machine uses." +
|
|
10506
10711
|
`\n The agent server needs a ${REQUIRED_AGENT_PREFIX} model (e.g. ${REQUIRED_AGENT_MODEL})` +
|
|
10507
|
-
`\n and any ONE of these embedders: ${
|
|
10712
|
+
`\n and any ONE of these embedders: ${ACCEPTED_EMBED_MODELS.map((m) => m.replace(/:latest$/, "")).join(", ")};` +
|
|
10508
10713
|
"\n the agent and coding models are then yours to pick."
|
|
10509
10714
|
)
|
|
10510
10715
|
);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tiens.nguyen/gu-cli",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.719",
|
|
4
4
|
"description": "Gu CLI — the gu terminal plus the local worker that runs agent / OCR / PDF / embedding jobs on your Mac (Ollama / MLX / OpenAI-compatible).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|