@tiens.nguyen/gu-cli 1.0.719 → 1.0.722

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/add-server.mjs ADDED
@@ -0,0 +1,207 @@
1
+ /**
2
+ * ADD A MODEL SERVER — ONE SCREEN FOR ALL THREE ROLES (task #245). The decisions, pure.
3
+ *
4
+ * There were three screens: /model's "add an OpenAI-compatible provider", /agent-model's "use an
5
+ * Ollama server", and /embed-model's Ollama and provider arms. Each asked for a URL, a key and a
6
+ * model its own way, and each had drifted: one refused a pasted "/v1" that another stripped, one
7
+ * named the key and one did not, one reused a saved server and one said "already configured".
8
+ * gu-repl's addServer() now asks the same questions in the same order for every role:
9
+ *
10
+ * URL → the server's key (reused when it has one) → its models, listed WITH that key → pick
11
+ * → save the server, its key, then the role's own setting.
12
+ *
13
+ * What genuinely differs per role lives in SERVER_ROLES below and nowhere else. What differs per
14
+ * server kind ("dialect": an Ollama's /api/tags vs an OpenAI-compatible /v1/models) lives in
15
+ * serverTarget() and listRequest().
16
+ */
17
+ import { defaultProviderUrl, ensureV1Root, hostLocality, normalizeProviderUrl, parseProviderModels, MAX_MODELS_PER_SERVER } from "./coding-provider-setup.mjs";
18
+ import { agentModelsFromProvider, isLikelyEmbedder, ollamaAgentDefault, ollamaAgentUrl, parseOllamaTags } from "./agent-model-command.mjs";
19
+
20
+ /**
21
+ * The only per-role differences. Every one is a decision the user made:
22
+ * · noun / question — what the model is FOR, and the word used afterwards.
23
+ * · filterCoders — coding hides non-coding ids (43 hidden on api.openai.com); the others
24
+ * show everything, labelled. Deliberately asymmetric — do not unify.
25
+ * · embeddersFirst — the embedder's list puts embedding models first; a box that also serves
26
+ * the agent legitimately lists both.
27
+ * · extras — only the coder offers more than one model per server in /model.
28
+ * · rowModel — what a NEW server row offers in /model: the coder's pick, the agent's
29
+ * pick (so the agent's server is also offered as a coder), or nothing for
30
+ * an embedder (an embedding model cannot write code).
31
+ */
32
+ export const SERVER_ROLES = {
33
+ coding: {
34
+ noun: "coding model",
35
+ question: "Which model writes the code?",
36
+ filterCoders: true,
37
+ embeddersFirst: false,
38
+ extras: true,
39
+ rowModel: true,
40
+ },
41
+ agent: {
42
+ noun: "agent model",
43
+ question: "Which model talks?",
44
+ filterCoders: false,
45
+ embeddersFirst: false,
46
+ extras: false,
47
+ rowModel: true,
48
+ },
49
+ embed: {
50
+ noun: "embedding model",
51
+ question: "Which model embeds?",
52
+ filterCoders: false,
53
+ embeddersFirst: true,
54
+ extras: false,
55
+ rowModel: false,
56
+ },
57
+ };
58
+
59
+ export function serverRole(role) {
60
+ return SERVER_ROLES[role] ?? SERVER_ROLES.coding;
61
+ }
62
+
63
+ /**
64
+ * A typed server URL → everything the flow needs, for either dialect. Forgiving about what people
65
+ * paste ("127.0.0.1:11434", ".../v1", ".../api/tags", "ollama.com") and SAYS what it used; strict
66
+ * about credentials in the URL — keys go in the key prompt, never in an address that is printed.
67
+ *
68
+ * Returns { ok: true, dialect, url, root, listUrl, host, locality, cloud, roleUrl(role) } or
69
+ * { ok: false, why } — `why` finishes the sentence `"<typed>" is <why>`.
70
+ * · url — the provider ROW's url (always the /v1 root: what the API normalises to).
71
+ * · listUrl — where the models are listed (Ollama /api/tags, else /v1/models).
72
+ * · roleUrl — what each role's setting stores: the agent the /v1 root (every existing install
73
+ * stores that shape), the embedder an Ollama's bare root (the worker appends
74
+ * /v1/embeddings, /api/embed, /api/embeddings itself), the coder the row url.
75
+ */
76
+ export function serverTarget(dialect, typed) {
77
+ const raw = String(typed ?? "").trim();
78
+ if (!raw) return { ok: false, why: "empty" };
79
+ if (/^[a-z][a-z0-9+.-]*:\/\/[^/]*@/i.test(raw)) {
80
+ return { ok: false, why: "carrying credentials — keys go in the key prompt" };
81
+ }
82
+ if (dialect === "ollama") {
83
+ const r = ollamaAgentUrl(raw);
84
+ if (!r.ok) return r;
85
+ const hostname = new URL(r.root).hostname;
86
+ return {
87
+ ok: true,
88
+ dialect,
89
+ url: r.agentUrl,
90
+ root: r.root,
91
+ listUrl: r.tagsUrl,
92
+ host: r.host,
93
+ locality: r.cloud ? "public" : hostLocality(hostname),
94
+ cloud: r.cloud,
95
+ roleUrl: (role) => (role === "embed" ? r.root : r.agentUrl),
96
+ };
97
+ }
98
+ const r = normalizeProviderUrl(raw);
99
+ if (!r.ok) return { ok: false, why: r.why };
100
+ return {
101
+ ok: true,
102
+ dialect: "openai",
103
+ url: r.url,
104
+ root: r.url,
105
+ listUrl: `${ensureV1Root(r.url)}/models`,
106
+ host: r.host,
107
+ locality: r.locality,
108
+ cloud: false,
109
+ roleUrl: () => r.url,
110
+ };
111
+ }
112
+
113
+ /**
114
+ * Does this server need a key? "required" | "optional" | "none".
115
+ * · An Ollama on this machine or the LAN answers anonymously — "none", not even asked.
116
+ * · The Ollama cloud refuses anonymous calls — "required".
117
+ * · An OpenAI-compatible server on this machine / the LAN (vLLM, LM Studio) usually answers
118
+ * anonymously — "optional", Enter skips. A hosted one needs a key — "required".
119
+ */
120
+ export function keyRequirement(target) {
121
+ if (!target?.ok) return "none";
122
+ if (target.dialect === "ollama") return target.cloud ? "required" : "none";
123
+ return target.locality === "loopback" || target.locality === "private" ? "optional" : "required";
124
+ }
125
+
126
+ /** The request that lists a server's models: its URL and headers (a Bearer only when keyed). */
127
+ export function listRequest(target, key = "") {
128
+ const k = String(key ?? "").trim();
129
+ return { url: target.listUrl, headers: k && k !== "local" ? { Authorization: `Bearer ${k}` } : {} };
130
+ }
131
+
132
+ /**
133
+ * A list response → the model ids for this role, in the order to offer them. Shaped like
134
+ * parseProviderModels' answer ({ models, why, total, retired }) for both dialects, so the caller
135
+ * has one branch for "nothing listed".
136
+ * · coding — retired ids dropped (offering one is a 404 several steps into a task).
137
+ * · agent — everything, retired and non-chat ids sunk and labelled by the list itself.
138
+ * · embed — everything, embedding models first.
139
+ */
140
+ export function modelsForRole(role, dialect, body) {
141
+ const r = serverRole(role);
142
+ let out;
143
+ if (dialect === "ollama") {
144
+ const rows = parseOllamaTags(body);
145
+ out = { models: rows.map((m) => m.model), why: rows.length ? "" : "empty", total: rows.length, retired: 0 };
146
+ if (r.embeddersFirst) {
147
+ const ordered = [...rows].sort(
148
+ (a, b) => Number(isLikelyEmbedder(b)) - Number(isLikelyEmbedder(a)) || a.model.localeCompare(b.model)
149
+ );
150
+ out.models = ordered.map((m) => m.model);
151
+ }
152
+ return out;
153
+ }
154
+ if (role === "coding") return parseProviderModels(body);
155
+ const rows = agentModelsFromProvider(body);
156
+ const models = rows.map((m) => m.model);
157
+ if (r.embeddersFirst) {
158
+ models.sort((a, b) => Number(isLikelyEmbedder({ model: b })) - Number(isLikelyEmbedder({ model: a })) || a.localeCompare(b));
159
+ }
160
+ return { models, why: rows.length ? "" : "empty", total: rows.length, retired: 0 };
161
+ }
162
+
163
+ /**
164
+ * The provider row to save for this role — `changed: false` when the existing row already has
165
+ * what it needs. At most MAX_MODELS_PER_SERVER models per server, the default counted (the API refuses
166
+ * more): a NEW row gets the role's model plus extras up to the cap; an EXISTING row gains the
167
+ * coder's model only if it is not there yet and there is room. `full: true` = no room.
168
+ */
169
+ export function rowForSave(role, { existing = null, id = "", kind = "openai", url = "", model = "", extras = [] } = {}) {
170
+ const r = serverRole(role);
171
+ if (!existing) {
172
+ const dflt = r.rowModel ? model : "";
173
+ const more = r.extras ? extras.filter((m) => m && m !== dflt).slice(0, MAX_MODELS_PER_SERVER - 1) : [];
174
+ return { row: { id, kind, enabled: true, url, defaultModel: dflt, models: more }, changed: true, full: false };
175
+ }
176
+ if (role !== "coding" || !model) return { row: existing, changed: false, full: false };
177
+ const have = new Set([String(existing.defaultModel ?? "").trim(), ...(existing.models ?? [])].filter(Boolean));
178
+ if (have.has(model)) return { row: existing, changed: false, full: false };
179
+ if (!String(existing.defaultModel ?? "").trim()) {
180
+ return { row: { ...existing, defaultModel: model }, changed: true, full: false };
181
+ }
182
+ if (have.size >= MAX_MODELS_PER_SERVER) return { row: existing, changed: false, full: true };
183
+ return { row: { ...existing, models: [...(existing.models ?? []), model] }, changed: true, full: false };
184
+ }
185
+
186
+ /**
187
+ * What Enter takes at the URL prompt — the server this role already uses when it is of this
188
+ * kind, else the obvious one: this machine's Ollama, or https://api.openai.com/v1 (the address
189
+ * the whole OpenAI-compatible category is named after). "" when there is nothing sensible to
190
+ * offer (api.openai.com already configured), in which case Enter cancels.
191
+ */
192
+ export function defaultServerUrl(role, dialect, settings, existingRows = []) {
193
+ const LOCAL_OLLAMA = "http://127.0.0.1:11434";
194
+ if (dialect === "ollama") {
195
+ if (role === "agent") return ollamaAgentDefault(settings);
196
+ if (role === "embed") {
197
+ const cur = String(settings?.ragEmbedUrl ?? "").trim();
198
+ return cur && !/\/v1\/?$/i.test(cur) ? cur : LOCAL_OLLAMA;
199
+ }
200
+ return LOCAL_OLLAMA;
201
+ }
202
+ if (role === "embed") {
203
+ const cur = String(settings?.ragEmbedUrl ?? "").trim();
204
+ if (/\/v1\/?$/i.test(cur)) return cur;
205
+ }
206
+ return defaultProviderUrl(existingRows);
207
+ }
@@ -439,7 +439,9 @@ export function agentMenuOptions({ host = "", modelCount = 0, needsKey = false }
439
439
  { action: "models", label: `pick another model on ${where}${modelCount ? ` (${modelCount})` : ""}` },
440
440
  { action: "ollama", label: "use an Ollama server for the agent…" },
441
441
  { action: "provider", label: "use another OpenAI-compatible provider…" },
442
- ...(needsKey ? [{ action: "key", label: `enter a new API key for ${where}…` }] : []),
442
+ // Task #245: the SAME key manager /model's ctrl+k and /embed-model open — switch, add, make
443
+ // default, delete — for this server's keys only.
444
+ ...(needsKey ? [{ action: "key", label: `API keys for ${where} — switch, add or remove…` }] : []),
443
445
  ];
444
446
  }
445
447
 
@@ -361,6 +361,10 @@ export function shouldMentionContextWindow(locality) {
361
361
  */
362
362
  export const MAX_BACKENDS = 8;
363
363
 
364
+ /** How many models ONE server URL may offer, its default included (task #245). Mirrors
365
+ * MAX_MODELS_PER_SERVER in api/src/store/settingsTypes.ts, which refuses a row that grows past it. */
366
+ export const MAX_MODELS_PER_SERVER = 3;
367
+
364
368
  /**
365
369
  * The backend rows an account ALREADY has, in the shape a save must preserve.
366
370
  *
@@ -574,6 +578,25 @@ export function removeProvider(rows, id) {
574
578
  return (Array.isArray(rows) ? rows : []).filter((b) => !rowMatches(b, id));
575
579
  }
576
580
 
581
+ /**
582
+ * THE PROVIDER ROW THAT SERVES A URL — how every role finds its server's keys (task #245).
583
+ *
584
+ * A key is bound to a row, and a role (agent, embedder) finds its row by URL; the API does the
585
+ * same in serverKeyForUrl (api/src/app.ts). Compared after ensureV1Root and case-folded, as the
586
+ * API compares against its normalised row url, so "https://ollama.com" and "HTTPS://ollama.com/v1"
587
+ * are one server. Returns { row, id } — id is what a key binds to: the row's own id, or its kind
588
+ * for a pre-#223 row that carries none ("auto" for the legacy kind "") — or null when no row
589
+ * serves that URL.
590
+ */
591
+ export function providerRowForUrl(rows, url) {
592
+ const want = ensureV1Root(String(url ?? "").trim()).toLowerCase();
593
+ if (!String(url ?? "").trim() || !Array.isArray(rows)) return null;
594
+ const row = rows.find((b) => b && typeof b === "object" && ensureV1Root(String(b.url ?? "").trim()).toLowerCase() === want);
595
+ if (!row) return null;
596
+ const id = String(row.id ?? "").trim() || String(row.kind ?? "").trim() || "auto";
597
+ return { row, id };
598
+ }
599
+
577
600
  /** A row is the one named when its id matches — or, for a row that carries none, when the id IS
578
601
  * its kind, which is exactly how the API identifies a pre-#223 row. */
579
602
  function rowMatches(b, id) {
@@ -5317,23 +5317,25 @@ def _rag_embed_failure_hint(err) -> str:
5317
5317
 
5318
5318
 
5319
5319
  # The API key for the EMBEDDING endpoint, resolved once per job from the API
5320
- # (backend id "embed-ollama" - where /embed-model stores it, same store as coding/agent
5321
- # keys). A module global because _embed() is a tiny hot path reached through the rag_*
5320
+ # (role=embed - the key bound to the provider row for the embed server's URL, same store and
5321
+ # same rule as the agent's key). A module global because _embed() is a tiny hot path reached through the rag_*
5322
5322
  # tools - passing a key down that chain just for a header would churn every signature
5323
5323
  # above it for no behaviour.
5324
5324
  _RAG_EMBED_API_KEY = ""
5325
5325
 
5326
5326
 
5327
- def _rag_resolve_embed_api_key(api_base: str, worker_key: str) -> str:
5327
+ def _rag_resolve_embed_api_key(api_base: str, worker_key: str, job_id: str = "") -> str:
5328
5328
  """Fetch the embedding-server API key from the shared key store, or "" when none is set.
5329
5329
 
5330
- The key was typed at /embed-model and POSTed to /api/worker/coding-keys under
5331
- backendId="embed-ollama" - reusing the coding-keys store means it already lands in
5332
- every mode (local file in Client Expert, Mongo for cloud users) with no new persistence.
5333
- The API masks keys in settings reads but returns the real secret here precisely so the
5334
- daemon that holds the worker key can authenticate to the embedder. Never raises: an
5335
- unreachable store means "" and the embed call goes unsigned, right for a keyless local
5336
- Ollama.
5330
+ role=embed: the API resolves it the way it resolves the agent's key - through the provider
5331
+ row for ragEmbedUrl, with ragEmbedKeyId as the pick (embedApiKeyFor in api/src/app.ts) - so
5332
+ the embedder never borrows a key stored for another server. backendId=embed-ollama rides
5333
+ along for an API that predates role=embed and answers the old way. The store is the user's
5334
+ settings row in MongoDB in every mode (hosted Mongo, or the local Mongo in Client Expert).
5335
+ job_id: the turn's job, which records THIS folder's own embed-key pick (task #245) - two
5336
+ folders' turns can run at once, so the pick travels on the job, as the coder's does.
5337
+ Never raises: an unreachable store means "" and the embed call goes unsigned, right for a
5338
+ keyless local Ollama.
5337
5339
  """
5338
5340
  base = (api_base or "").rstrip("/")
5339
5341
  if not base or not worker_key:
@@ -5341,7 +5343,7 @@ def _rag_resolve_embed_api_key(api_base: str, worker_key: str) -> str:
5341
5343
  try:
5342
5344
  from urllib.parse import urlencode
5343
5345
  req = urllib.request.Request(
5344
- f"{base}/api/worker/coding-key?{urlencode({'backendId': 'embed-ollama'})}",
5346
+ f"{base}/api/worker/coding-key?{urlencode({'role': 'embed', 'backendId': 'embed-ollama', **({'jobId': job_id} if job_id else {})})}",
5345
5347
  headers={"X-Worker-Key": worker_key}, method="GET")
5346
5348
  with urllib.request.urlopen(req, timeout=8, context=_ssl_context()) as resp:
5347
5349
  data = json.loads(resp.read().decode("utf-8", "replace") or "{}")
@@ -6988,11 +6990,11 @@ def run_agent_chat(cfg):
6988
6990
  rag_embed_model = (cfg.get("ragEmbedModel") or "").strip()
6989
6991
  rag_embed_url = (cfg.get("ragEmbedUrl") or "").strip()
6990
6992
  # The RAG embedder may be a KEYED server (task: embed api key in /embed-model). Resolve
6991
- # its key once, from the shared key store (backendId "embed-ollama"), so every _embed()
6993
+ # its key once, from the shared key store (role=embed), so every _embed()
6992
6994
  # call this job carries the Bearer header. Empty for a keyless local Ollama - _embed()
6993
6995
  # then sends no Authorization, exactly the old behaviour.
6994
6996
  global _RAG_EMBED_API_KEY
6995
- _RAG_EMBED_API_KEY = _rag_resolve_embed_api_key(worker_api_base, worker_api_key) if rag_embed_url else ""
6997
+ _RAG_EMBED_API_KEY = _rag_resolve_embed_api_key(worker_api_base, worker_api_key, _job_id) if rag_embed_url else ""
6996
6998
  try:
6997
6999
  rag_top_k = int(cfg.get("ragTopK") or 6)
6998
7000
  except (TypeError, ValueError):