broapp 0.4.5 → 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 +1 -1
- package/src/ai/host/create-ai.ts +151 -15
- package/src/ai/host/registry.ts +167 -57
- package/src/ai/host/settings.ts +69 -20
- package/src/ai/react/AiSettings.tsx +452 -157
- package/src/ai/react/ai.css +386 -10
- package/src/ai/react/use-ai-models.ts +24 -13
- package/src/ai/react/use-ai-settings.ts +18 -11
- package/src/ai/shared/contract.ts +39 -7
- package/src/ai/shared/index.ts +4 -0
- package/src/ai/shared/model-ref.ts +139 -0
- package/src/ai/shared/types.check.ts +14 -0
- package/src/ai/shared/types.ts +46 -6
package/package.json
CHANGED
package/src/ai/host/create-ai.ts
CHANGED
|
@@ -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
|
|
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,
|
|
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
|
|
269
|
-
fileSecrets
|
|
270
|
-
memorySecrets
|
|
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
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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(
|
|
513
|
+
throw publicError.unavailable(NOT_SET_UP);
|
|
378
514
|
}
|
|
379
515
|
return current;
|
|
380
516
|
}
|
package/src/ai/host/registry.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
45
|
-
*
|
|
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,
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
|
|
107
|
-
|
|
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.
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
125
|
-
|
|
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
|
-
//
|
|
130
|
-
//
|
|
131
|
-
|
|
132
|
-
|
|
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 =
|
|
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
|
|
156
|
-
|
|
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.
|
|
168
|
-
modelId:
|
|
169
|
-
baseUrl:
|
|
170
|
-
hasKey: 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 = {
|
|
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
|
-
|
|
183
|
-
if (
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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 &&
|
|
201
|
-
const name = apiKeySecretName(
|
|
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);
|