@tiagojct/subsub 0.22.0 → 0.24.0

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/src/subsub.ts CHANGED
@@ -45,14 +45,15 @@ import { describeArgs, formatPreview, isEmptyPreview } from "./preview.ts";
45
45
  import { perMinuteWait, providerTrouble, TROUBLE_TEXT, TROUBLE_TEXT_PT } from "./keys.ts";
46
46
  import { headerLines, LIBRARIAN_THEME, type LibraryState, paintPreview, QUOTES, type Scheme, schemeFromAnsi, themeName } from "./look.ts";
47
47
  import { footerLines, toolRenderers, usageTotals } from "./chrome.ts";
48
+ import { type CostLevel, costStatus, money, usesModel } from "./cost.ts";
48
49
  import { ApprovalView } from "./dialogs.ts";
49
50
  import { allowedFile, listNotes, OPENABLE, openFile } from "./files.ts";
50
- import { LibraryView, NotesView, type LibraryItem } from "./screens.ts";
51
+ import { itemRef, LibraryView, NotesView, type LibraryItem } from "./screens.ts";
51
52
  import { glyphs, paintFromTheme, workingFrames } from "./skin.ts";
52
53
  import { loadWords, toolWord, type Words } from "./words.ts";
53
54
  import { systemAddition } from "./prompt.ts";
54
55
  import { logUsage } from "./usage.ts";
55
- import { gateKind, SERVER_PREFIXES, toolsFor, unavailableReason, outboundProblem } from "./roles.ts";
56
+ import { activeTools, gateKind, SERVER_PREFIXES, toolsFor, unavailableReason, outboundProblem } from "./roles.ts";
56
57
 
57
58
  const MODE_ENTRY = "subsub-mode";
58
59
 
@@ -181,6 +182,8 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
181
182
  let resumeTimer: ReturnType<typeof setTimeout> | undefined;
182
183
  let turnStart = 0;
183
184
  let turnTools = 0;
185
+ /** The cost level already noted in this session, so each note comes once. */
186
+ let costNoted: CostLevel | undefined;
184
187
  let webCtx: ExtensionContext | undefined;
185
188
  function emitWeb(): void {
186
189
  const ctx = webCtx;
@@ -195,6 +198,8 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
195
198
  userName: cfg.userName ?? null,
196
199
  vault: cfg.vault ?? null,
197
200
  starbuck: starbuckEnabled(cfg),
201
+ // Shown next to the model only when there is a limit.
202
+ cost: cfg.costLimit ? { spent: usageTotals(ctx.sessionManager.getEntries()).cost, limit: cfg.costLimit } : null,
198
203
  };
199
204
  try {
200
205
  ctx.ui.setStatus("subsub.web", JSON.stringify(state));
@@ -277,15 +282,11 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
277
282
  /** /library: search as you type; Enter puts /read <item> in the box (the Librarian gets the key), ctrl+n puts /lit-note <item>. */
278
283
  async function openLibrary(ctx: ExtensionContext): Promise<void> {
279
284
  if (!screenOk(ctx)) return;
280
- const ref = (it: LibraryItem) => it.citekey || it.key;
285
+ const ref = itemRef;
281
286
  await ctx.ui.custom<void>(
282
287
  (tui, theme, _keys, done) =>
283
288
  new LibraryView({
284
- search: async (query) => {
285
- const r = await current().call("zotero_find_items", { query, limit: 25 });
286
- if (r.isError) throw new Error(r.text);
287
- return ((r.data as { items?: LibraryItem[] })?.items ?? []).filter((it) => it && it.key);
288
- },
289
+ search: searchLibraries,
289
290
  choose: (it) => {
290
291
  ctx.ui.setEditorText(mode === "researcher" ? `/read ${ref(it)}` : ref(it));
291
292
  done();
@@ -326,6 +327,33 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
326
327
  : text;
327
328
  }
328
329
 
330
+ /** The group libraries in Zotero, from the last library check (read-only; searched with the user's library). */
331
+ let groups: Array<{ library: string; name: string }> = [];
332
+
333
+ /** Search the user's library, then each group library; a group's items carry the group. */
334
+ async function searchLibraries(query: string): Promise<LibraryItem[]> {
335
+ const first = await current().call("zotero_find_items", { query, limit: 25 });
336
+ if (first.isError) throw new Error(first.text);
337
+ const items = ((first.data as { items?: LibraryItem[] })?.items ?? []).filter((it) => it && it.key);
338
+ for (const g of groups) {
339
+ const r = await current().call("zotero_find_items", { query, limit: 25, library: g.library });
340
+ if (r.isError) continue;
341
+ for (const it of (r.data as { items?: LibraryItem[] })?.items ?? []) if (it?.key) items.push({ ...it, library: g.library, group: g.name });
342
+ }
343
+ return items;
344
+ }
345
+
346
+ async function refreshGroups(): Promise<void> {
347
+ if (!current().has("zotero_list_libraries")) return;
348
+ try {
349
+ const r = await current().call("zotero_list_libraries", {});
350
+ const all = ((r.data as { libraries?: Array<{ library: string; name?: string }> })?.libraries ?? []);
351
+ groups = r.isError ? [] : all.filter((l) => l.library?.startsWith("group:")).map((l) => ({ library: l.library, name: l.name || l.library }));
352
+ } catch {
353
+ groups = [];
354
+ }
355
+ }
356
+
329
357
  async function refreshLibrary(): Promise<void> {
330
358
  try {
331
359
  const st = await current().call("zotero_status", {});
@@ -336,6 +364,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
336
364
  const ov = await current().call("zotero_library_overview", {});
337
365
  const o = (ov.data ?? {}) as Record<string, unknown>;
338
366
  Object.assign(library, { zotero: "reachable", items: o.items as number, toReview: o.awaiting_review as number });
367
+ await refreshGroups();
339
368
  }
340
369
  } catch {
341
370
  library.zotero = "down";
@@ -410,7 +439,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
410
439
  /** Set the mode's tools and, unless `switchModel` is false, the mode's model. */
411
440
  async function applyMode(ctx: ExtensionContext, next: Mode, remember: boolean, switchModel = true): Promise<void> {
412
441
  mode = next;
413
- pi.setActiveTools(toolsFor(mode, registered(), toolOpts()));
442
+ pi.setActiveTools(activeTools(pi.getActiveTools(), toolsFor(mode, registered(), toolOpts()), standalone));
414
443
  const spec = cfg.models[mode];
415
444
  if (spec && switchModel) {
416
445
  const i = spec.indexOf("/");
@@ -442,6 +471,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
442
471
  }
443
472
 
444
473
  pi.on("session_start", async (_event, ctx) => {
474
+ costNoted = undefined; // a new or another conversation: its own cost
445
475
  if (!bridge) {
446
476
  const b = new Bridge();
447
477
  await b.start(deps.specs ?? serverSpecs(cfg), cfg.startTimeout * 1000);
@@ -531,6 +561,17 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
531
561
  // A per-minute limit (free tiers): wait as long as the provider says, then continue the task,
532
562
  // up to three times per request of the user. Anything the user types cancels the wait.
533
563
  pi.on("agent_end", async (_event, ctx) => {
564
+ const spent = costStatus(usageTotals(ctx.sessionManager.getEntries()).cost, cfg.costLimit);
565
+ if (spent.level !== "ok" && costNoted !== spent.level) {
566
+ costNoted = spent.level;
567
+ ctx.ui.notify(
568
+ spent.level === "near"
569
+ ? L(`Sub-Sub: this conversation has used 80% of your cost limit (${money(spent.spent)} of ${money(spent.limit!)}).`, `Sub-Sub: esta conversa já usou 80% do seu limite de custo (${money(spent.spent)} de ${money(spent.limit!)}).`)
570
+ : L(`Sub-Sub: this conversation has reached your cost limit (${money(spent.spent)} of ${money(spent.limit!)}). Sub-Sub will ask before each new request.`, `Sub-Sub: esta conversa atingiu o seu limite de custo (${money(spent.spent)} de ${money(spent.limit!)}). O Sub-Sub vai perguntar antes de cada pedido novo.`),
571
+ "warning",
572
+ );
573
+ }
574
+ if (cfg.costLimit) emitWeb(); // the cost next to the model in the browser view
534
575
  if (turnStart) logUsage(cfg.usageLog, { ev: "turn", seconds: Math.round((Date.now() - turnStart) / 1000), tools: turnTools, error: providerTrouble(lastError) ?? (lastError ? "other" : undefined) });
535
576
  turnStart = 0;
536
577
  const wait = perMinuteWait(lastError);
@@ -558,6 +599,19 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
558
599
  resumeTimer = undefined;
559
600
  resumes = 0;
560
601
  const cmd = /^\/([\w-]+)(?:\s|$)/.exec(event.text.trim())?.[1];
602
+ // Over the cost limit: ask before each new request to the model (a running answer is not cut).
603
+ const spent = costStatus(usageTotals(ctx.sessionManager.getEntries()).cost, cfg.costLimit);
604
+ if (spent.level === "over" && usesModel(event.text)) {
605
+ const text = L(
606
+ `This conversation has cost ${money(spent.spent)}, over your limit of ${money(spent.limit!)} (costLimit in the settings). Send this request to the model?`,
607
+ `Esta conversa já custou ${money(spent.spent)}, acima do seu limite de ${money(spent.limit!)} (costLimit nas definições). Enviar este pedido ao modelo?`,
608
+ );
609
+ const go = ctx.hasUI ? await ctx.ui.confirm(L("Sub-Sub: cost limit", "Sub-Sub: limite de custo"), text) : false;
610
+ if (!go) {
611
+ if (!ctx.hasUI) ctx.ui.notify(text, "warning");
612
+ return { action: "handled" };
613
+ }
614
+ }
561
615
  // The usage log keeps the kind of request and its length, never its text.
562
616
  if (cmd !== "subsub-refresh") logUsage(cfg.usageLog, { ev: "ask", kind: cmd ? `/${cmd}` : "message", words: event.text.trim().split(/\s+/).filter(Boolean).length });
563
617
  if (mode === "librarian" && cmd && RESEARCH_PROMPTS.includes(cmd)) {
@@ -578,7 +632,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
578
632
  });
579
633
 
580
634
  pi.on("before_agent_start", async (event, ctx) => ({
581
- systemPrompt: `${event.systemPrompt}\n\n${systemAddition(mode, cfg, ctx.cwd)}`,
635
+ systemPrompt: `${event.systemPrompt}\n\n${systemAddition(mode, cfg, ctx.cwd, { zoteroDown: library.zotero === "down" })}`,
582
636
  }));
583
637
 
584
638
  /** Model test (subsub-bench): record every change instead of asking, and apply none. */
@@ -846,6 +900,8 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
846
900
  ? ov.text
847
901
  : `Items: ${o.items}. Without tags: ${o.without_manual_tags}. Missing facets: ${JSON.stringify(o.missing_facet)}. Awaiting review (_agent): ${o.awaiting_review}.`,
848
902
  );
903
+ await refreshGroups();
904
+ if (groups.length) lines.push(`Group libraries (read-only, searched with yours): ${groups.map((g) => g.name).join(", ")}.`);
849
905
  }
850
906
  }
851
907
  redraw();
@@ -870,8 +926,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
870
926
  const q = String(args ?? "").trim();
871
927
  let out: Record<string, unknown>;
872
928
  try {
873
- const r = await current().call("zotero_find_items", { query: q, limit: 25 });
874
- out = r.isError ? { error: r.text } : { items: (r.data as { items?: unknown[] })?.items ?? [] };
929
+ out = { items: await searchLibraries(q) };
875
930
  } catch (err) {
876
931
  out = { error: (err as Error).message };
877
932
  }
package/src/tui-view.ts CHANGED
@@ -252,7 +252,7 @@ export function sideLines(s: SideState, width: number, rows: number, p: Paint, g
252
252
  const w = Math.max(16, width);
253
253
  const words = SIDE_WORDS[s.lang];
254
254
  const mode = s.mode === "librarian" ? "Librarian" : "Researcher";
255
- const lib = s.library.zotero === "reachable" ? `${s.library.items ?? "?"} ${s.lang === "pt" ? "itens" : "items"}` : s.library.zotero === "down" ? `${g.hollow} Zotero` : "Zotero...";
255
+ const lib = s.library.zotero === "reachable" ? `${s.library.items ?? "?"} ${s.lang === "pt" ? "itens" : "items"}` : s.library.zotero === "down" ? `${g.hollow} Zotero: ${s.lang === "pt" ? "só pesquisa" : "search only"}` : "Zotero...";
256
256
  const lines = [p.mark(cut(` Sub-Sub ${s.version}`, w)), p.text(cut(` ${mode}`, w)), p.muted(cut(` ${lib}`, w)), p.dim(cut(` ${s.model}`, w)), rule(w, "solid", p.muted, g)];
257
257
  for (const [key, word] of [["F2", words.notes], ["F3", words.library], ["F4", words.actions], ["F5", words.mode]] as const) lines.push(p.muted(cut(` ${key.padEnd(4)}${word}`, w)));
258
258
  lines.push(rule(w, "solid", p.muted, g), p.text(cut(` ${words.conversations}`, w)));
package/src/tui.ts CHANGED
@@ -12,12 +12,15 @@ import { CombinedAutocompleteProvider, Editor, HStack, Markdown, matchesKey, Pro
12
12
  import { agentDirFor, chooseCwd, PACKAGE_DIR, packageVersion } from "./cli.ts";
13
13
  import { fill, footerLines } from "./chrome.ts";
14
14
  import { loadConfig, uiLanguage } from "./config.ts";
15
+ import { modelsAfterLogin } from "./init.ts";
15
16
  import { type LibraryState, LIBRARIAN_THEME, DEFAULT_THEME, paintPreview, QUOTES } from "./look.ts";
16
17
  import { PURPOSES } from "./read.ts";
17
18
  import { allowedFile, listNotes, OPENABLE, openFile } from "./files.ts";
18
19
  import { Agent, findLibrary, isDialog, Side, withHints } from "./rpc.ts";
20
+ import { KEY_PROVIDERS, saveApiKey } from "./keys.ts";
21
+ import { CUSTOM_DEFAULT_NAME, saveCustomProvider } from "./providers.ts";
19
22
  import { ApprovalView } from "./dialogs.ts";
20
- import { type Choice, ChoiceView, InputView, LibraryView, type LibraryItem, NotesView } from "./screens.ts";
23
+ import { itemRef, type Choice, ChoiceView, InputView, LibraryView, type LibraryItem, NotesView } from "./screens.ts";
21
24
  import { glyphs as chooseGlyphs, type Glyphs, workingFrames } from "./skin.ts";
22
25
  import { bannerLines, type Entry, entriesFromMessages, loadPalette, messageText, type Palette, schemeFromEnv, type Scheme, SIDE_WIDTH, sideLines, transcriptLines } from "./tui-view.ts";
23
26
  import { loadWords, type Words } from "./words.ts";
@@ -58,6 +61,10 @@ export interface TuiDeps {
58
61
  searchDelay?: number;
59
62
  /** Called when the user quits or pi stops. */
60
63
  onQuit?: (code: number) => void;
64
+ /** pi's agent folder, where /login saves keys (auth.json) and other providers (models.json). */
65
+ agentDir?: string;
66
+ /** After /login saved a provider: set the models when none are set (spec: the other provider's model). */
67
+ afterLogin?: (provider: string, spec?: string) => void;
61
68
  }
62
69
 
63
70
  const SHORT = (s: string, n: number) => (s.length > n ? `${s.slice(0, n - 3)}...` : s);
@@ -213,6 +220,8 @@ export class TuiApp {
213
220
  { name: "library", description: pt ? "Pesquisar na biblioteca à medida que escreve" : "Search your library as you type" },
214
221
  { name: "actions", description: pt ? "As ações da vista no navegador" : "The actions of the browser view" },
215
222
  { name: "sessions", description: pt ? "Conversas anteriores" : "Earlier conversations" },
223
+ { name: "resume", description: pt ? "Retomar uma conversa anterior" : "Resume an earlier conversation" },
224
+ { name: "login", description: pt ? "Ligar um fornecedor do modelo" : "Connect a model provider" },
216
225
  { name: "new", description: pt ? "Uma conversa nova" : "A new conversation" },
217
226
  { name: "model", description: pt ? "Escolher o modelo" : "Choose the model" },
218
227
  { name: "restart", description: pt ? "Recomeçar o pi na mesma conversa" : "Start pi again in the same conversation" },
@@ -492,7 +501,7 @@ export class TuiApp {
492
501
  /** Alt+L, /library: search as you type; Enter puts /read <item> in the editor (the Librarian gets the key), ctrl+n puts /lit-note <item>. */
493
502
  async openLibrary(): Promise<void> {
494
503
  if (this.overlay) return;
495
- const ref = (it: LibraryItem) => it.citekey || it.key;
504
+ const ref = itemRef;
496
505
  this.open(
497
506
  new LibraryView({
498
507
  ...this.common(),
@@ -558,6 +567,70 @@ export class TuiApp {
558
567
  );
559
568
  }
560
569
 
570
+ /** Ask one line on its own screen; undefined when the user presses Esc. */
571
+ private ask(title: string, opts: { placeholder?: string; secret?: boolean } = {}): Promise<string | undefined> {
572
+ return new Promise((resolve) => {
573
+ this.open(
574
+ new InputView({
575
+ ...this.common(),
576
+ title,
577
+ ...opts,
578
+ submit: (text) => (this.close(), resolve(text.trim())),
579
+ cancel: () => (this.close(), resolve(undefined)),
580
+ }),
581
+ true,
582
+ );
583
+ });
584
+ }
585
+
586
+ /** /login: a provider that pi has (its API key) or another provider that speaks the OpenAI API; then pi starts again. */
587
+ async openLogin(): Promise<void> {
588
+ if (this.overlay) return;
589
+ const pt = this.lang === "pt";
590
+ const agentDir = this.d.agentDir;
591
+ if (!agentDir) return void this.notice("error", pt ? "Sem pasta do agente: use subsub web." : "No agent folder: use subsub web.");
592
+ const choices: Choice[] = [
593
+ ...KEY_PROVIDERS.map((p) => ({ label: p.label, hint: p.url, value: p.id })),
594
+ { label: pt ? "Outro fornecedor (API da OpenAI)" : "Another provider (OpenAI API)", hint: pt ? "servidor da instituição, Ollama, LM Studio" : "your institution's server, Ollama, LM Studio", value: CUSTOM_DEFAULT_NAME },
595
+ ];
596
+ const provider = await new Promise<string | undefined>((resolve) => {
597
+ this.open(
598
+ new ChoiceView({
599
+ ...this.common(),
600
+ title: pt ? "Ligar um fornecedor" : "Connect a provider",
601
+ choices,
602
+ choose: (v) => (this.close(), resolve(v)),
603
+ cancel: () => (this.close(), resolve(undefined)),
604
+ }),
605
+ true,
606
+ );
607
+ });
608
+ if (!provider) return;
609
+ let spec: string | undefined;
610
+ try {
611
+ if (provider === CUSTOM_DEFAULT_NAME) {
612
+ const baseUrl = await this.ask(pt ? "Endereço do servidor" : "Server address", { placeholder: "https://llm.example.org/v1" });
613
+ if (baseUrl === undefined) return;
614
+ const model = await this.ask(pt ? "Identificador do modelo" : "Model id");
615
+ if (model === undefined) return;
616
+ const key = await this.ask(pt ? "Chave de API (Enter sem chave, num servidor local)" : "API key (Enter for none, for a local server)", { secret: true });
617
+ if (key === undefined) return;
618
+ [spec] = saveCustomProvider(agentDir, { name: CUSTOM_DEFAULT_NAME, baseUrl, apiKey: key, models: [model] });
619
+ } else {
620
+ const label = KEY_PROVIDERS.find((p) => p.id === provider)?.label ?? provider;
621
+ const key = await this.ask(pt ? `Chave de API de ${label}` : `API key for ${label}`, { secret: true });
622
+ if (!key) return;
623
+ saveApiKey(agentDir, provider, key);
624
+ }
625
+ this.d.afterLogin?.(provider, spec);
626
+ } catch (err) {
627
+ this.notice("error", (err as Error).message);
628
+ return void this.tui.requestRender();
629
+ }
630
+ this.notice("info", pt ? "Guardado. O pi recomeça com o fornecedor." : "Saved. pi starts again with the provider.");
631
+ await this.restart();
632
+ }
633
+
561
634
  /** /new: an empty conversation. */
562
635
  async newConversation(): Promise<void> {
563
636
  try {
@@ -633,12 +706,12 @@ export class TuiApp {
633
706
  this.editor.addToHistory?.(line);
634
707
  this.editor.setText("");
635
708
  if (/^\/(quit|exit)\s*$/.test(line)) return void (await this.quit());
636
- const unavailable = /^\/(login|logout|settings|tree|resume|fork|share|export|import|hotkeys|reload)\b/.exec(line);
709
+ const unavailable = /^\/(logout|settings|tree|fork|share|export|import|hotkeys|reload)\b/.exec(line);
637
710
  if (unavailable) {
638
711
  this.notice("info", this.lang === "pt" ? `/${unavailable[1]} não existe em subsub tui. Use subsub no terminal ou subsub web.` : `/${unavailable[1]} is not in subsub tui. Use subsub in the terminal, or subsub web.`);
639
712
  return void this.tui.requestRender();
640
713
  }
641
- const own: Record<string, () => Promise<void> | void> = { "/restart": () => this.restart(), "/model": () => this.openModels(), "/notes": () => this.openNotes(), "/library": () => this.openLibrary(), "/actions": () => this.openActions(), "/sessions": () => this.openSessions(), "/new": () => this.newConversation() };
714
+ const own: Record<string, () => Promise<void> | void> = { "/restart": () => this.restart(), "/model": () => this.openModels(), "/notes": () => this.openNotes(), "/library": () => this.openLibrary(), "/actions": () => this.openActions(), "/sessions": () => this.openSessions(), "/resume": () => this.openSessions(), "/login": () => this.openLogin(), "/new": () => this.newConversation() };
642
715
  if (own[line]) return void (await own[line]());
643
716
  try {
644
717
  await this.d.agent.request({ type: "prompt", message: line, ...(this.busy ? { streamingBehavior: "followUp" } : {}) });
@@ -703,6 +776,8 @@ export async function runTui(args: string[], env: NodeJS.ProcessEnv = process.en
703
776
  themes: { researcher: typeof cfg.themes === "string" ? cfg.themes : undefined, librarian: cfg.themesLibrarian ?? (typeof cfg.themes === "string" ? cfg.themes : undefined) },
704
777
  sessions: async () => (await SessionManager.list(cwd)).filter((s) => s.messageCount > 0).sort((a, b) => +new Date(b.modified) - +new Date(a.modified)).map((s) => ({ title: s.name ?? s.firstMessage.slice(0, 60), path: s.path })),
705
778
  onQuit: resolve,
779
+ agentDir: env.PI_CODING_AGENT_DIR,
780
+ afterLogin: (provider, spec) => modelsAfterLogin(env, provider, spec),
706
781
  });
707
782
  void app.start();
708
783
  });
package/src/web.ts CHANGED
@@ -21,8 +21,9 @@ import { homedir } from "node:os";
21
21
  import { basename, dirname, extname, join, normalize, relative, resolve, sep } from "node:path";
22
22
  import { agentDirFor, chooseCwd, PACKAGE_DIR, packageVersion } from "./cli.ts";
23
23
  import { configPath, loadConfig, RECOMMENDED_MODELS, readConfigFile, saveConfig, type SubsubConfig, uiLanguage, withStarbuck } from "./config.ts";
24
- import { TESTED_MODELS } from "./init.ts";
24
+ import { modelsAfterLogin } from "./init.ts";
25
25
  import { KEY_PROVIDERS, saveApiKey } from "./keys.ts";
26
+ import { CUSTOM_DEFAULT_NAME, saveCustomProvider } from "./providers.ts";
26
27
 
27
28
  // ---------------------------------------------------------------- options
28
29
 
@@ -479,9 +480,13 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
479
480
  if (busy || switching) return send(res, 409, { error: "busy" });
480
481
  const body = await readJson(req);
481
482
  return withRestart(res, () => {
483
+ if (body.provider === CUSTOM_DEFAULT_NAME) {
484
+ // Another provider (OpenAI API): models.json; its model becomes the default when none is set.
485
+ const [spec] = saveCustomProvider(agentDir, { name: CUSTOM_DEFAULT_NAME, baseUrl: String(body.baseUrl ?? ""), apiKey: String(body.key ?? ""), models: [String(body.model ?? "")] });
486
+ return modelsAfterLogin(env, CUSTOM_DEFAULT_NAME, spec);
487
+ }
482
488
  saveApiKey(agentDir, String(body.provider ?? ""), String(body.key ?? ""));
483
- const current = loadConfig(env);
484
- if (body.provider === "opencode-go" && Object.keys(current.models).length === 0) saveConfig({ models: TESTED_MODELS, model: undefined }, env);
489
+ modelsAfterLogin(env, String(body.provider ?? ""));
485
490
  });
486
491
  }
487
492
  case "GET /api/notes":