broapp 0.4.6 → 0.4.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "broapp",
3
- "version": "0.4.6",
3
+ "version": "0.4.7",
4
4
  "type": "module",
5
5
  "description": "Build tooling and runtime for local applications made of a Bun host, a browser UI, and a Brobridge connection between them",
6
6
  "license": "MIT",
@@ -20,15 +20,16 @@ import type { Bridge } from 'brobridge';
20
20
  // this code from being bundled into a page.
21
21
  import { createPendingApprovals, createReservedHostApp } from '../../host/index.ts';
22
22
  import type { HostApp, HostLogger, StreamSink } from '../../host/app.ts';
23
- import { publicError } from '../../shared/errors.ts';
23
+ import { isPublicError, publicError, type PublicError } from '../../shared/errors.ts';
24
24
  import { aiContract, type AiContract } from '../shared/contract.ts';
25
- import type { ChatTurn, ProviderInfo } from '../shared/types.ts';
25
+ import { formatModelRef } from '../shared/model-ref.ts';
26
+ import type { BroappModel, ChatTurn, ProviderInfo } from '../shared/types.ts';
26
27
 
27
28
  import { AdapterError, toPublicError, type AdapterConfig, type ProviderAdapter } from './adapter.ts';
28
29
  import { createRegistry, type Registry } from './registry.ts';
29
30
  import { runChat, type RunDeps } from './run.ts';
30
31
  import type { ChatEvent } from './run-types.ts';
31
- import { createFileSecretStore, createMemorySecretStore } from './secrets.ts';
32
+ import { apiKeySecretName, createFileSecretStore, createMemorySecretStore } from './secrets.ts';
32
33
  import { createSettingsStore } from './settings.ts';
33
34
  import { openThreads, type ThreadStore } from './threads.ts';
34
35
  import { GUARDED, type AiContextProviders, type AiTool, type ContextDocument } from './tool.ts';
@@ -64,7 +65,7 @@ export interface DeliveredContext {
64
65
  export interface InProcessTurn {
65
66
  readonly runId: string;
66
67
  readonly message: string;
67
- /** The model for this turn, within the configured provider. */
68
+ /** The model for this turn: a model reference, bare or naming an enabled provider. */
68
69
  readonly modelId?: string;
69
70
  /**
70
71
  * Earlier turns, as a browser would send them. An assistant turn naming a
@@ -228,6 +229,73 @@ const ANSWER_INTERVAL_MS = 5;
228
229
  /** How long a provider is given to answer a listing or a connection test. */
229
230
  const PROVIDER_TIMEOUT_MS = 20_000;
230
231
 
232
+ /** The contract's bound on `ai.modelsList`, over every provider's list together. */
233
+ const MAX_LISTED_MODELS = 1000;
234
+
235
+ const NOT_SET_UP = 'AI is not set up yet. Open Settings to choose a provider.';
236
+
237
+ /**
238
+ * A model id no provider offers, for asking `resolve` whether a provider's
239
+ * key and address are there. `resolve` checks the model last and never sends
240
+ * anything, so the id is never used.
241
+ */
242
+ const NO_MODEL = '-';
243
+
244
+ /** One provider's list, read. */
245
+ interface Listed {
246
+ readonly adapter: ProviderAdapter;
247
+ readonly models: BroappModel[];
248
+ }
249
+
250
+ /** One provider that could not be listed, and the error that says why. */
251
+ interface Unlisted {
252
+ readonly adapter: ProviderAdapter;
253
+ readonly failure: PublicError;
254
+ }
255
+
256
+ /**
257
+ * What stops a provider being tested, in `resolve`'s words, or `null`.
258
+ *
259
+ * Written here as well as in the registry because a provider that is off
260
+ * cannot go through `resolve` — which refuses it for being off — and still has
261
+ * to be testable. A test holds the two sets of sentences equal.
262
+ */
263
+ function unmet(adapter: ProviderAdapter, config: AdapterConfig): string | null {
264
+ if (adapter.needs.apiKey === 'required' && (config.apiKey === null || config.apiKey === '')) {
265
+ return `An API key is required for ${adapter.label}.`;
266
+ }
267
+ if (adapter.needs.baseUrl === 'required' && (config.baseUrl === null || config.baseUrl === '')) {
268
+ return `A server address is required for ${adapter.label}.`;
269
+ }
270
+ return null;
271
+ }
272
+
273
+ /**
274
+ * Split `limit` rows between lists of the given lengths so that no list gives
275
+ * up rows while another keeps more than it: a short list is shown whole, and
276
+ * what is left is shared equally between the long ones.
277
+ */
278
+ export function fairShares(lengths: readonly number[], limit: number): number[] {
279
+ const shares = lengths.map(() => 0);
280
+ let left = limit;
281
+ let open = lengths.map((_, index) => index).filter((index) => (lengths[index] ?? 0) > 0);
282
+ // Each round gives every list still wanting rows an equal part of what is
283
+ // left, at least one, so the loop ends: rows run out or every list is whole.
284
+ while (open.length > 0 && left > 0) {
285
+ const each = Math.max(1, Math.floor(left / open.length));
286
+ const next: number[] = [];
287
+ for (const index of open) {
288
+ if (left === 0) break;
289
+ const given = Math.min((lengths[index] ?? 0) - (shares[index] ?? 0), each, left);
290
+ shares[index] = (shares[index] ?? 0) + given;
291
+ left -= given;
292
+ if ((shares[index] ?? 0) < (lengths[index] ?? 0)) next.push(index);
293
+ }
294
+ open = next;
295
+ }
296
+ return shares;
297
+ }
298
+
231
299
  /** Defaults for the run loop, all overridable per application. */
232
300
  const DEFAULT_CONTEXT_BUDGET_CHARS = 40_000;
233
301
  const DEFAULT_MAX_STEPS = 8;
@@ -263,11 +331,14 @@ export function createAi(options: CreateAiOptions): Ai {
263
331
  // Both stores are built once and kept. `remember` chooses between them, and
264
332
  // switching has to move a key from one to the other rather than construct a
265
333
  // new store and lose what the old one held.
334
+ const settingsStore = createSettingsStore(options.dataDir);
335
+ const fileSecrets = createFileSecretStore(options.dataDir);
336
+ const memorySecrets = createMemorySecretStore();
266
337
  const registry = createRegistry({
267
338
  adapters: options.providers,
268
- settingsStore: createSettingsStore(options.dataDir),
269
- fileSecrets: createFileSecretStore(options.dataDir),
270
- memorySecrets: createMemorySecretStore(),
339
+ settingsStore,
340
+ fileSecrets,
341
+ memorySecrets,
271
342
  fetch: options.fetch ?? globalThis.fetch,
272
343
  });
273
344
 
@@ -295,13 +366,38 @@ export function createAi(options: CreateAiOptions): Ai {
295
366
  }));
296
367
 
297
368
  host.operation('ai.modelsList', async () => {
298
- const { adapter, config } = await requireConfig();
299
- try {
300
- const models = await adapter.models(config, AbortSignal.timeout(PROVIDER_TIMEOUT_MS));
301
- return { models };
302
- } catch (cause) {
303
- throw toPublicError(cause);
369
+ // Nothing set up is still "not set up", in today's words, whatever else is
370
+ // turned on: a launcher with no provider in use has not been set up.
371
+ await requireConfig();
372
+ const settings = await registry.settings();
373
+ const enabled = options.providers.filter((adapter) =>
374
+ settings.providers.some((entry) => entry.id === adapter.id && entry.enabled),
375
+ );
376
+ // Every enabled provider at once, each under its own deadline, so one that
377
+ // is slow or down costs its own group and not the whole list.
378
+ const answers = await Promise.all(enabled.map((adapter) => listOf(adapter)));
379
+ const read = answers.filter((answer): answer is Listed => 'models' in answer);
380
+ const failed = answers.filter((answer): answer is Unlisted => 'failure' in answer);
381
+ if (read.length === 0) {
382
+ // An application with one provider sees exactly what it saw: that
383
+ // provider's own error. With several, the first in the build's order.
384
+ const first = failed[0];
385
+ throw first === undefined ? publicError.unavailable(NOT_SET_UP) : first.failure;
304
386
  }
387
+ const shares = fairShares(read.map((answer) => answer.models.length), MAX_LISTED_MODELS);
388
+ const unavailable = failed.map((answer) => ({ provider: answer.adapter.id, message: answer.failure.message }));
389
+ const models: BroappModel[] = [];
390
+ read.forEach((answer, index) => {
391
+ const share = shares[index] ?? 0;
392
+ models.push(...answer.models.slice(0, share));
393
+ if (share < answer.models.length) {
394
+ unavailable.push({
395
+ provider: answer.adapter.id,
396
+ message: `${answer.adapter.label}: only the first ${String(share)} models are shown.`,
397
+ });
398
+ }
399
+ });
400
+ return { models, unavailable };
305
401
  });
306
402
 
307
403
  host.operation('ai.connectionTest', async () => {
@@ -309,6 +405,30 @@ export function createAi(options: CreateAiOptions): Ai {
309
405
  // missing its key would just ask the provider to reject it, and the layer
310
406
  // already knows the answer and can say it in better words.
311
407
  const { adapter, config } = await registry.resolve();
408
+ return tryConnection(adapter, config);
409
+ });
410
+
411
+ host.operation('ai.providerTest', async ({ provider }) => {
412
+ const adapter = registry.adapter(provider);
413
+ if (adapter === null) throw publicError.invalidInput('Unknown provider.');
414
+ // Its own stored address and key, whether or not it is turned on: a person
415
+ // tests a provider before they decide to use it. Testing leaves it as it was.
416
+ const settings = settingsStore.read();
417
+ const secrets = settings.remember ? fileSecrets : memorySecrets;
418
+ const config: AdapterConfig = {
419
+ ...registry.configFor(adapter),
420
+ apiKey: await secrets.get(apiKeySecretName(adapter.id)),
421
+ };
422
+ const problem = unmet(adapter, config);
423
+ if (problem !== null) throw publicError.unavailable(problem);
424
+ return tryConnection(adapter, config);
425
+ });
426
+
427
+ /** One test of one provider: an answer either way, never a failed route for a provider's refusal. */
428
+ async function tryConnection(
429
+ adapter: ProviderAdapter,
430
+ config: AdapterConfig,
431
+ ): Promise<{ ok: boolean; message: string; latencyMs: number }> {
312
432
  const started = Bun.nanoseconds();
313
433
  const elapsed = (): number => Math.round((Bun.nanoseconds() - started) / 1_000_000);
314
434
  try {
@@ -321,7 +441,23 @@ export function createAi(options: CreateAiOptions): Ai {
321
441
  if (!(cause instanceof AdapterError)) throw cause;
322
442
  return { ok: false, message: cause.message, latencyMs: elapsed() };
323
443
  }
324
- });
444
+ }
445
+
446
+ /**
447
+ * One enabled provider's list, or why there is none. A provider whose key
448
+ * or address is missing is not asked: `resolve` names what is missing, in
449
+ * the same words a turn would hear, so the rule stays written once.
450
+ */
451
+ async function listOf(adapter: ProviderAdapter): Promise<Listed | Unlisted> {
452
+ try {
453
+ const { config } = await registry.resolve({ modelId: formatModelRef(adapter.id, NO_MODEL) });
454
+ return { adapter, models: await adapter.models(config, AbortSignal.timeout(PROVIDER_TIMEOUT_MS)) };
455
+ } catch (cause) {
456
+ if (cause instanceof AdapterError) return { adapter, failure: toPublicError(cause) };
457
+ if (isPublicError(cause)) return { adapter, failure: cause };
458
+ throw cause;
459
+ }
460
+ }
325
461
 
326
462
  // Opened on the first conversation route and not before: an application
327
463
  // whose user never opens the panel should not find a database in its data
@@ -374,7 +510,7 @@ export function createAi(options: CreateAiOptions): Ai {
374
510
  async function requireConfig(): Promise<{ adapter: ProviderAdapter; config: AdapterConfig }> {
375
511
  const current = await registry.currentConfig();
376
512
  if (current === null) {
377
- throw publicError.unavailable('AI is not set up yet. Open Settings to choose a provider.');
513
+ throw publicError.unavailable(NOT_SET_UP);
378
514
  }
379
515
  return current;
380
516
  }
@@ -8,47 +8,70 @@
8
8
  * conditions that can drift from the first.
9
9
  */
10
10
  import { isPublicError, publicError } from '../../shared/errors.ts';
11
- import type { AiSettings } from '../shared/types.ts';
11
+ import { parseModelRef } from '../shared/model-ref.ts';
12
+ import type { AiSettings, ProviderSettings } from '../shared/types.ts';
12
13
 
13
14
  import type { AdapterConfig, ProviderAdapter } from './adapter.ts';
14
15
  import { apiKeySecretName, type SecretStore } from './secrets.ts';
15
- import type { SettingsStore, StoredSettings } from './settings.ts';
16
+ import type { ProviderEntry, SettingsStore, StoredSettings } from './settings.ts';
17
+
16
18
 
17
19
  /** Everything needed to run one chat turn. */
18
20
  export interface ResolvedModel {
19
21
  readonly adapter: ProviderAdapter;
20
22
  readonly config: AdapterConfig;
23
+ /** The provider's own id for the model: never a qualified reference. */
21
24
  readonly modelId: string;
22
25
  }
23
26
 
24
27
  /** The fields `ai.settings.update` may change. */
25
28
  export interface UpdatePatch {
29
+ /** Make this provider the one in use. */
26
30
  readonly provider?: string | undefined;
31
+ /**
32
+ * The provider `modelId`, `baseUrl`, `apiKey` and `enabled` apply to. Absent
33
+ * means the provider in use, which is what every field meant before there
34
+ * was more than one.
35
+ */
36
+ readonly target?: string | undefined;
27
37
  readonly modelId?: string | undefined;
28
38
  readonly baseUrl?: string | null | undefined;
29
39
  readonly apiKey?: string | null | undefined;
40
+ readonly enabled?: boolean | undefined;
30
41
  readonly remember?: boolean | undefined;
31
42
  }
32
43
 
33
- /** The adapters this build has, and the settings pointing at one of them. */
44
+ /** The adapters this build has, and the settings pointing at them. */
34
45
  export interface Registry {
35
46
  readonly adapters: readonly ProviderAdapter[];
36
47
  adapter(id: string): ProviderAdapter | null;
37
- /** Current settings plus the key, for adapter calls. */
48
+ /**
49
+ * The id of the provider in use, read now and without a key: for a caller
50
+ * that must say, as a turn ends, whether it ran somewhere else.
51
+ */
52
+ activeProvider(): string | null;
53
+ /** The provider in use and its key, for adapter calls. */
38
54
  currentConfig(): Promise<{ adapter: ProviderAdapter; config: AdapterConfig } | null>;
55
+ /**
56
+ * One provider's own address and key, for adapter calls; `null` when this
57
+ * build has no such provider or it is not turned on — a provider that is
58
+ * off is sent nothing, not even a request for its list.
59
+ */
60
+ configOf(providerId: string): Promise<{ adapter: ProviderAdapter; config: AdapterConfig } | null>;
39
61
  /**
40
62
  * Everything needed to run a chat, or a `PublicError` explaining what is
41
63
  * missing.
42
64
  *
43
- * `override.modelId` replaces the model Settings names, for this call only
44
- * and only within the configured provider a conversation may pin a model,
45
- * never a vendor.
65
+ * `override.modelId` replaces the model Settings names, for this call only.
66
+ * It is a model reference: bare, it is a model of the provider in use; as
67
+ * `<provider>:<model>` it runs on that provider, with that provider's own
68
+ * key and address, provided the person turned it on.
46
69
  */
47
70
  resolve(override?: { readonly modelId?: string | undefined }): Promise<ResolvedModel>;
48
71
  /** The public view: settings without the key. */
49
72
  settings(): Promise<AiSettings>;
50
73
  update(patch: UpdatePatch): Promise<AiSettings>;
51
- /** The config an adapter would get today, ignoring which one is selected. */
74
+ /** The config an adapter would get today from its own entry, whether or not it is in use. No key. */
52
75
  configFor(adapter: ProviderAdapter): AdapterConfig;
53
76
  }
54
77
 
@@ -73,10 +96,28 @@ function hint(key: string | null): string | null {
73
96
  return key.slice(-4);
74
97
  }
75
98
 
99
+ /**
100
+ * A provider's entry, or what one it has never had would hold: its default
101
+ * address, no model, and off.
102
+ */
103
+ function entryOf(settings: StoredSettings, adapter: ProviderAdapter): ProviderEntry {
104
+ return settings.providers[adapter.id] ?? { baseUrl: adapter.defaultBaseUrl, modelId: null, enabled: false };
105
+ }
106
+
107
+ /** The provider in use is always on, whatever its entry says. */
108
+ function isEnabled(settings: StoredSettings, providerId: string): boolean {
109
+ return settings.active === providerId || settings.providers[providerId]?.enabled === true;
110
+ }
111
+
112
+ function hasValue(value: string | null): value is string {
113
+ return value !== null && value !== '';
114
+ }
115
+
76
116
  export function createRegistry(options: RegistryOptions): Registry {
77
117
  const byId = new Map(options.adapters.map((adapter) => [adapter.id, adapter]));
118
+ const ids = options.adapters.map((adapter) => adapter.id);
78
119
 
79
- /** The store the key currently lives in, which `remember` decides. */
120
+ /** The store the keys currently live in, which `remember` decides. */
80
121
  function store(settings: StoredSettings): SecretStore {
81
122
  return settings.remember ? options.fileSecrets : options.memorySecrets;
82
123
  }
@@ -85,62 +126,89 @@ export function createRegistry(options: RegistryOptions): Registry {
85
126
  return store(settings).get(apiKeySecretName(providerId));
86
127
  }
87
128
 
88
- function configFrom(settings: StoredSettings, adapter: ProviderAdapter, apiKey: string | null): AdapterConfig {
129
+ /**
130
+ * The config a provider gets: its own entry's address and its own key. An
131
+ * address is only ever applied to the provider it was typed for.
132
+ */
133
+ function configFrom(entry: ProviderEntry, adapter: ProviderAdapter, apiKey: string | null): AdapterConfig {
89
134
  return {
90
135
  apiKey,
91
- baseUrl: settings.baseUrl ?? adapter.defaultBaseUrl,
136
+ baseUrl: entry.baseUrl ?? adapter.defaultBaseUrl,
92
137
  fetch: options.fetch,
93
138
  };
94
139
  }
95
140
 
141
+ /**
142
+ * What stops a provider being used, in the words a person is shown, or
143
+ * `null` when its key and address are both there. The key and the address
144
+ * come before the model on purpose: the list of models is fetched *from* the
145
+ * provider, so telling a user to choose one before they can see any is an
146
+ * instruction they cannot follow.
147
+ */
148
+ function missing(adapter: ProviderAdapter, config: AdapterConfig): string | null {
149
+ if (adapter.needs.apiKey === 'required' && !hasValue(config.apiKey)) {
150
+ return `An API key is required for ${adapter.label}.`;
151
+ }
152
+ if (adapter.needs.baseUrl === 'required' && !hasValue(config.baseUrl)) {
153
+ return `A server address is required for ${adapter.label}.`;
154
+ }
155
+ return null;
156
+ }
157
+
96
158
  const registry: Registry = {
97
159
  adapters: options.adapters,
98
160
 
99
161
  adapter: (id) => byId.get(id) ?? null,
100
162
 
163
+ activeProvider: () => options.settingsStore.read().active,
164
+
101
165
  configFor(adapter) {
102
- const settings = options.settingsStore.read();
103
- // The stored base URL belongs to the *selected* provider. Applying it to
104
- // the others would have told the user that Anthropic runs on their
105
- // computer, simply because they had Ollama selected a moment ago.
106
- const scoped: StoredSettings =
107
- settings.provider === adapter.id ? settings : { ...settings, baseUrl: null };
108
- // No key either: whether a provider stays on this machine is a property
109
- // of the address, and the answer must not depend on what is stored.
110
- return configFrom(scoped, adapter, null);
166
+ // Each provider's own entry, so the fault this once had cannot come
167
+ // back: an address typed for Ollama applied to Anthropic told the user
168
+ // that Anthropic runs on their computer. No key either: whether a
169
+ // provider stays on this machine is a property of the address, and the
170
+ // answer must not depend on what is stored.
171
+ return configFrom(entryOf(options.settingsStore.read(), adapter), adapter, null);
111
172
  },
112
173
 
113
174
  async currentConfig() {
114
175
  const settings = options.settingsStore.read();
115
- if (settings.provider === null) return null;
116
- const adapter = byId.get(settings.provider);
117
- if (adapter === undefined) return null;
176
+ if (settings.active === null) return null;
177
+ return registry.configOf(settings.active);
178
+ },
179
+
180
+ async configOf(providerId) {
181
+ const settings = options.settingsStore.read();
182
+ const adapter = byId.get(providerId);
183
+ if (adapter === undefined || !isEnabled(settings, providerId)) return null;
118
184
  const apiKey = await keyFor(settings, adapter.id);
119
- return { adapter, config: configFrom(settings, adapter, apiKey) };
185
+ return { adapter, config: configFrom(entryOf(settings, adapter), adapter, apiKey) };
120
186
  },
121
187
 
122
188
  async resolve(override) {
123
189
  const settings = options.settingsStore.read();
124
- if (settings.provider === null) throw publicError.unavailable(NOT_SET_UP);
125
- const adapter = byId.get(settings.provider);
190
+ // First even for a reference naming another provider: a launcher with
191
+ // nothing set up is not set up.
192
+ if (settings.active === null) throw publicError.unavailable(NOT_SET_UP);
193
+ const ref = override?.modelId === undefined ? null : parseModelRef(override.modelId, ids);
194
+ const providerId = ref?.provider ?? settings.active;
195
+ const adapter = byId.get(providerId);
126
196
  if (adapter === undefined) {
127
197
  throw publicError.unavailable('The configured AI provider is not available in this build.');
128
198
  }
129
- // The key and the address come before the model on purpose: the list of
130
- // models is fetched *from* the provider, so telling a user to choose one
131
- // before they can see any is an instruction they cannot follow.
132
- const apiKey = await keyFor(settings, adapter.id);
133
- if (adapter.needs.apiKey === 'required' && (apiKey === null || apiKey === '')) {
134
- throw publicError.unavailable(`An API key is required for ${adapter.label}.`);
135
- }
136
- const config = configFrom(settings, adapter, apiKey);
137
- if (adapter.needs.baseUrl === 'required' && (config.baseUrl === null || config.baseUrl === '')) {
138
- throw publicError.unavailable(`A server address is required for ${adapter.label}.`);
199
+ // Before the key is read and before anything is sent: a line in a tier
200
+ // file must not reach a provider the person never turned on.
201
+ if (!isEnabled(settings, providerId)) {
202
+ throw publicError.unavailable(`${adapter.label} is not turned on in Settings.`);
139
203
  }
204
+ const entry = entryOf(settings, adapter);
205
+ const config = configFrom(entry, adapter, await keyFor(settings, adapter.id));
206
+ const problem = missing(adapter, config);
207
+ if (problem !== null) throw publicError.unavailable(problem);
140
208
  // Read after the provider, the key and the address, so a conversation
141
209
  // carrying its own model still hears "AI is not set up yet" first: the
142
210
  // model is the last thing missing, never the first.
143
- const modelId = override?.modelId ?? settings.modelId;
211
+ const modelId = ref?.modelId ?? entry.modelId;
144
212
  if (modelId === null) {
145
213
  // Distinct from "not set up": the user is looking at the settings panel
146
214
  // with a provider selected, and being told to choose a provider is an
@@ -152,8 +220,25 @@ export function createRegistry(options: RegistryOptions): Registry {
152
220
 
153
221
  async settings() {
154
222
  const settings = options.settingsStore.read();
155
- const apiKey =
156
- settings.provider === null ? null : await keyFor(settings, settings.provider);
223
+ const providers: ProviderSettings[] = [];
224
+ for (const adapter of options.adapters) {
225
+ const entry = entryOf(settings, adapter);
226
+ const apiKey = await keyFor(settings, adapter.id);
227
+ providers.push({
228
+ id: adapter.id,
229
+ baseUrl: entry.baseUrl,
230
+ modelId: entry.modelId,
231
+ enabled: isEnabled(settings, adapter.id),
232
+ hasKey: hasValue(apiKey),
233
+ keyHint: hint(apiKey),
234
+ configured: missing(adapter, configFrom(entry, adapter, apiKey)) === null,
235
+ });
236
+ }
237
+ // The top-level fields are the provider in use, so an application
238
+ // written before there was more than one reads what it always read.
239
+ const active = providers.find((element) => element.id === settings.active) ??
240
+ (settings.active === null ? undefined : settings.providers[settings.active]);
241
+ const apiKey = settings.active === null ? null : await keyFor(settings, settings.active);
157
242
  let configured = true;
158
243
  try {
159
244
  await registry.resolve();
@@ -164,41 +249,66 @@ export function createRegistry(options: RegistryOptions): Registry {
164
249
  configured = false;
165
250
  }
166
251
  return {
167
- provider: settings.provider,
168
- modelId: settings.modelId,
169
- baseUrl: settings.baseUrl,
170
- hasKey: apiKey !== null && apiKey !== '',
252
+ provider: settings.active,
253
+ modelId: active?.modelId ?? null,
254
+ baseUrl: active?.baseUrl ?? null,
255
+ hasKey: hasValue(apiKey),
171
256
  keyHint: hint(apiKey),
172
257
  remember: settings.remember,
173
258
  configured,
259
+ providers,
174
260
  };
175
261
  },
176
262
 
177
263
  async update(patch) {
178
264
  const before = options.settingsStore.read();
179
- const next: StoredSettings = { ...before };
265
+ const next: StoredSettings = {
266
+ ...before,
267
+ providers: Object.fromEntries(
268
+ Object.entries(before.providers).map(([id, entry]) => [id, { ...entry }]),
269
+ ),
270
+ };
271
+ /** A provider's entry in `next`, made from its defaults the first time it is touched. */
272
+ const entry = (adapter: ProviderAdapter): ProviderEntry =>
273
+ (next.providers[adapter.id] ??= { ...entryOf(before, adapter) });
180
274
 
275
+ if (patch.target !== undefined && !byId.has(patch.target)) {
276
+ throw publicError.invalidInput('Unknown provider.');
277
+ }
181
278
  if (patch.provider !== undefined) {
182
- if (!byId.has(patch.provider)) throw publicError.invalidInput('Unknown provider.');
183
- if (patch.provider !== before.provider) {
184
- next.provider = patch.provider;
185
- // A model id belongs to the provider that offers it, and a base URL
186
- // points at that provider's server. Carrying either across a change
187
- // would leave the settings describing something that does not exist.
188
- next.modelId = null;
189
- next.baseUrl = byId.get(patch.provider)?.defaultBaseUrl ?? null;
279
+ const chosen = byId.get(patch.provider);
280
+ if (chosen === undefined) throw publicError.invalidInput('Unknown provider.');
281
+ if (patch.target !== undefined && patch.target !== patch.provider) {
282
+ throw publicError.invalidInput('A provider can be made the one in use only by naming it as the target too.');
283
+ }
284
+ // Each provider keeps its own address and model, so changing provider
285
+ // restores the one chosen's rather than clearing both: a model id
286
+ // belongs to the provider that offers it, and it stays with that
287
+ // provider instead of being carried to another or thrown away.
288
+ next.active = patch.provider;
289
+ entry(chosen).enabled = true;
290
+ }
291
+
292
+ const targetId = patch.target ?? next.active;
293
+ const target = targetId === null ? undefined : byId.get(targetId);
294
+ if (target !== undefined) {
295
+ if (patch.modelId !== undefined) entry(target).modelId = patch.modelId;
296
+ if (patch.baseUrl !== undefined) entry(target).baseUrl = patch.baseUrl;
297
+ if (patch.enabled !== undefined) {
298
+ if (!patch.enabled && target.id === next.active) {
299
+ throw publicError.invalidInput('The provider in use cannot be turned off. Choose another first.');
300
+ }
301
+ entry(target).enabled = patch.enabled;
190
302
  }
191
303
  }
192
- if (patch.modelId !== undefined) next.modelId = patch.modelId;
193
- if (patch.baseUrl !== undefined) next.baseUrl = patch.baseUrl;
194
304
 
195
305
  if (patch.remember !== undefined && patch.remember !== before.remember) {
196
306
  next.remember = patch.remember;
197
307
  await moveKeys(before, next);
198
308
  }
199
309
 
200
- if (patch.apiKey !== undefined && next.provider !== null) {
201
- const name = apiKeySecretName(next.provider);
310
+ if (patch.apiKey !== undefined && target !== undefined) {
311
+ const name = apiKeySecretName(target.id);
202
312
  const value = patch.apiKey === null || patch.apiKey === '' ? null : patch.apiKey;
203
313
  if (value === null) await store(next).delete(name);
204
314
  else await store(next).set(name, value);
@@ -214,7 +324,7 @@ export function createRegistry(options: RegistryOptions): Registry {
214
324
  *
215
325
  * Turning `remember` off must not merely stop future writes: the key already
216
326
  * on disk has to leave the disk, or the setting would be a promise the
217
- * layer does not keep.
327
+ * layer does not keep. Every provider's key, not only the one in use.
218
328
  */
219
329
  async function moveKeys(before: StoredSettings, after: StoredSettings): Promise<void> {
220
330
  const from = store(before);