broapp 0.2.0 → 0.3.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/package.json +1 -1
- package/src/ai/host/adapter.ts +4 -1
- package/src/ai/host/create-ai.ts +72 -6
- package/src/ai/host/fake.ts +11 -4
- package/src/ai/host/from-contract.ts +35 -15
- package/src/ai/host/index.ts +6 -1
- package/src/ai/host/registry.ts +19 -8
- package/src/ai/host/run.ts +247 -27
- package/src/ai/host/threads.ts +366 -0
- package/src/ai/host/tool.ts +59 -53
- package/src/ai/react/AiChat.tsx +49 -1
- package/src/ai/react/AiSettings.tsx +8 -2
- package/src/ai/react/ai.css +14 -0
- package/src/ai/react/index.tsx +3 -0
- package/src/ai/react/use-ai-chat.ts +9 -1
- package/src/ai/shared/contract.ts +110 -1
- package/src/ai/shared/index.ts +3 -0
- package/src/ai/shared/types.check.ts +30 -2
- package/src/ai/shared/types.ts +62 -2
- package/src/host/app.ts +99 -16
- package/src/host/approvals.ts +115 -0
- package/src/host/gate.ts +380 -0
- package/src/host/index.ts +22 -2
- package/src/host/paths.ts +6 -2
- package/src/host/runtime.ts +23 -2
- package/src/shared/contract.ts +49 -6
- package/src/shared/countdown.ts +36 -0
- package/src/shared/errors.ts +56 -2
- package/src/shared/index.ts +6 -1
- package/src/shared/schema.ts +16 -0
package/package.json
CHANGED
package/src/ai/host/adapter.ts
CHANGED
|
@@ -50,7 +50,10 @@ export interface ProviderAdapter {
|
|
|
50
50
|
/** Stable id, stored in settings: `'anthropic'`, `'ollama'`, `'fake'`. */
|
|
51
51
|
readonly id: string;
|
|
52
52
|
readonly label: string;
|
|
53
|
-
readonly needs: {
|
|
53
|
+
readonly needs: {
|
|
54
|
+
readonly apiKey: 'required' | 'optional' | 'none';
|
|
55
|
+
readonly baseUrl: 'required' | 'optional' | 'none';
|
|
56
|
+
};
|
|
54
57
|
readonly defaultBaseUrl: string | null;
|
|
55
58
|
/** Whether requests stay on this machine under this config. */
|
|
56
59
|
local(config: AdapterConfig): boolean;
|
package/src/ai/host/create-ai.ts
CHANGED
|
@@ -17,7 +17,7 @@ import type { Bridge } from 'brobridge';
|
|
|
17
17
|
// is what makes a browser bundle of `broapp/ai/host` fail to build. Bun's
|
|
18
18
|
// browser target polyfills `node:fs`, so the file stores alone would not stop
|
|
19
19
|
// this code from being bundled into a page.
|
|
20
|
-
import { createReservedHostApp } from '../../host/index.ts';
|
|
20
|
+
import { createPendingApprovals, createReservedHostApp } from '../../host/index.ts';
|
|
21
21
|
import type { HostApp, HostLogger } from '../../host/app.ts';
|
|
22
22
|
import { publicError } from '../../shared/errors.ts';
|
|
23
23
|
import { aiContract, type AiContract } from '../shared/contract.ts';
|
|
@@ -28,13 +28,23 @@ import { createRegistry, type Registry } from './registry.ts';
|
|
|
28
28
|
import { runChat, type RunDeps } from './run.ts';
|
|
29
29
|
import { createFileSecretStore, createMemorySecretStore } from './secrets.ts';
|
|
30
30
|
import { createSettingsStore } from './settings.ts';
|
|
31
|
-
import {
|
|
31
|
+
import { openThreads, type ThreadStore } from './threads.ts';
|
|
32
|
+
import { GUARDED, type AiContextProviders, type AiTool } from './tool.ts';
|
|
32
33
|
|
|
33
34
|
/** What the application is, in the words a model is given. */
|
|
34
35
|
export interface AiAppDescription {
|
|
35
36
|
readonly name: string;
|
|
36
37
|
readonly purpose: string;
|
|
37
38
|
readonly terminology?: readonly string[];
|
|
39
|
+
/**
|
|
40
|
+
* Extra standing instructions, appended verbatim after the purpose.
|
|
41
|
+
*
|
|
42
|
+
* For an assistant whose job needs more than a sentence to describe — the
|
|
43
|
+
* shape of a workspace it edits, a sequence it has to follow, things it may
|
|
44
|
+
* not do. It is host-authored text, not anything a browser or a model
|
|
45
|
+
* supplied, and it goes in front of the documents rather than among them.
|
|
46
|
+
*/
|
|
47
|
+
readonly instructions?: string;
|
|
38
48
|
}
|
|
39
49
|
|
|
40
50
|
/** Options for {@link createAi}. */
|
|
@@ -53,6 +63,19 @@ export interface CreateAiOptions {
|
|
|
53
63
|
readonly maxSteps?: number;
|
|
54
64
|
/** How long a `confirm` tool waits for the user. Default 300_000 ms. */
|
|
55
65
|
readonly confirmTimeoutMs?: number;
|
|
66
|
+
/**
|
|
67
|
+
* Called once when a chat turn ends, however it ends.
|
|
68
|
+
*
|
|
69
|
+
* Autoapp's run store uses it to close the record the gate has been writing
|
|
70
|
+
* steps into: the browser's run identifier is the prefix of every request
|
|
71
|
+
* identifier the turn produced, so this is the one signal that ties the two
|
|
72
|
+
* together. An application that does not record runs leaves it unset.
|
|
73
|
+
*/
|
|
74
|
+
readonly onRunEnd?: (
|
|
75
|
+
runId: string,
|
|
76
|
+
status: 'succeeded' | 'failed' | 'cancelled',
|
|
77
|
+
summary: string,
|
|
78
|
+
) => void;
|
|
56
79
|
}
|
|
57
80
|
|
|
58
81
|
/**
|
|
@@ -68,6 +91,16 @@ const TOOL_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_.]*$/;
|
|
|
68
91
|
export interface Ai {
|
|
69
92
|
mount(bridge: Bridge): void;
|
|
70
93
|
abortAll(reason: string): void;
|
|
94
|
+
/**
|
|
95
|
+
* Release what the layer holds open. Call it from the application's
|
|
96
|
+
* shutdown, beside `abortAll`.
|
|
97
|
+
*
|
|
98
|
+
* Today that is the conversation store: closing it checkpoints the WAL, so
|
|
99
|
+
* what is left on disk is one complete database rather than one that needs
|
|
100
|
+
* its sidecars. Calling it twice is harmless, and an application that never
|
|
101
|
+
* opened a conversation has nothing to close.
|
|
102
|
+
*/
|
|
103
|
+
close(): void;
|
|
71
104
|
readonly activeStreams: number;
|
|
72
105
|
/** For tests, and for applications that read settings on the host. */
|
|
73
106
|
readonly registry: Registry;
|
|
@@ -93,10 +126,19 @@ export function createAi(options: CreateAiOptions): Ai {
|
|
|
93
126
|
}
|
|
94
127
|
seen.add(adapter.id);
|
|
95
128
|
}
|
|
96
|
-
for (const name of Object.
|
|
129
|
+
for (const [name, definition] of Object.entries(options.tools ?? {})) {
|
|
97
130
|
if (!TOOL_NAME_PATTERN.test(name)) {
|
|
98
131
|
throw new TypeError(`tool name ${JSON.stringify(name)} must be letters, digits, "_" or "."`);
|
|
99
132
|
}
|
|
133
|
+
// A tool is host code that a model gets to trigger. Whether it asked
|
|
134
|
+
// anybody first is not visible in its type, so the brand is required
|
|
135
|
+
// rather than hoped for: an application cannot hand a model an ungated
|
|
136
|
+
// capability by forgetting one wrapper.
|
|
137
|
+
if ((definition as { [GUARDED]?: true })[GUARDED] !== true) {
|
|
138
|
+
throw new TypeError(
|
|
139
|
+
`tool ${JSON.stringify(name)} does not pass the gate; build it with guardedTool()`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
100
142
|
}
|
|
101
143
|
|
|
102
144
|
// Both stores are built once and kept. `remember` chooses between them, and
|
|
@@ -162,7 +204,7 @@ export function createAi(options: CreateAiOptions): Ai {
|
|
|
162
204
|
}
|
|
163
205
|
});
|
|
164
206
|
|
|
165
|
-
const
|
|
207
|
+
const approvals = createPendingApprovals(options.logger);
|
|
166
208
|
const runDeps: RunDeps = {
|
|
167
209
|
registry,
|
|
168
210
|
app: options.app,
|
|
@@ -171,15 +213,35 @@ export function createAi(options: CreateAiOptions): Ai {
|
|
|
171
213
|
contextBudgetChars: options.contextBudgetChars ?? DEFAULT_CONTEXT_BUDGET_CHARS,
|
|
172
214
|
maxSteps: options.maxSteps ?? DEFAULT_MAX_STEPS,
|
|
173
215
|
confirmTimeoutMs: options.confirmTimeoutMs ?? DEFAULT_CONFIRM_TIMEOUT_MS,
|
|
174
|
-
|
|
216
|
+
approvals,
|
|
217
|
+
...(options.onRunEnd === undefined ? {} : { onRunEnd: options.onRunEnd }),
|
|
175
218
|
logger: options.logger ?? console,
|
|
176
219
|
};
|
|
177
220
|
|
|
178
221
|
host.stream('ai.chat', (params, sink) => runChat(params, sink, runDeps));
|
|
222
|
+
// The wire shape is unchanged: a run and a call name the question, and
|
|
223
|
+
// `accepted` says whether anybody was waiting on it. What changed is where
|
|
224
|
+
// the answer goes — into the same approval table the gate asks.
|
|
179
225
|
host.operation('ai.chatConfirm', ({ runId, callId, approve }) => ({
|
|
180
|
-
accepted:
|
|
226
|
+
accepted:
|
|
227
|
+
approvals.answer({ requestId: `${runId}:${callId}`, approved: approve }) === 'accepted',
|
|
181
228
|
}));
|
|
182
229
|
|
|
230
|
+
// Opened on the first conversation route and not before: an application
|
|
231
|
+
// whose user never opens the panel should not find a database in its data
|
|
232
|
+
// directory, and `createAi` is built unconditionally by every application
|
|
233
|
+
// that offers AI at all.
|
|
234
|
+
let threads: ThreadStore | null = null;
|
|
235
|
+
const threadStore = (): ThreadStore => (threads ??= openThreads(options.dataDir));
|
|
236
|
+
|
|
237
|
+
host.operation('ai.threadsList', () => ({ threads: threadStore().list() }));
|
|
238
|
+
host.operation('ai.threadsCreate', (input) => threadStore().create(input));
|
|
239
|
+
host.operation('ai.threadsGet', ({ id }) => threadStore().get(id));
|
|
240
|
+
host.operation('ai.threadsSave', (input) => threadStore().save(input));
|
|
241
|
+
host.operation('ai.threadsUpdate', (input) => threadStore().update(input));
|
|
242
|
+
host.operation('ai.threadsDelete', ({ id }) => ({ deleted: threadStore().remove(id) }));
|
|
243
|
+
host.operation('ai.threadsClear', () => ({ deleted: threadStore().clear() }));
|
|
244
|
+
|
|
183
245
|
/** The current provider config, or the "not set up" error. */
|
|
184
246
|
async function requireConfig(): Promise<{ adapter: ProviderAdapter; config: AdapterConfig }> {
|
|
185
247
|
const current = await registry.currentConfig();
|
|
@@ -192,6 +254,10 @@ export function createAi(options: CreateAiOptions): Ai {
|
|
|
192
254
|
return {
|
|
193
255
|
mount: (bridge: Bridge) => host.mount(bridge),
|
|
194
256
|
abortAll: (reason: string) => host.abortAll(reason),
|
|
257
|
+
close: () => {
|
|
258
|
+
threads?.close();
|
|
259
|
+
threads = null;
|
|
260
|
+
},
|
|
195
261
|
get activeStreams() {
|
|
196
262
|
return host.activeStreams;
|
|
197
263
|
},
|
package/src/ai/host/fake.ts
CHANGED
|
@@ -48,6 +48,13 @@ export interface FakeAdapterOptions {
|
|
|
48
48
|
readonly script?: readonly FakeStep[];
|
|
49
49
|
/** Delay between chunks, so a cancel test can catch a stream mid-flight. */
|
|
50
50
|
readonly chunkDelayMs?: number;
|
|
51
|
+
/**
|
|
52
|
+
* Whether the default model says it can read images. Default `false`.
|
|
53
|
+
*
|
|
54
|
+
* The default stays `false` so a test that does not mention images keeps
|
|
55
|
+
* proving that a turn with images is refused by a model that cannot see.
|
|
56
|
+
*/
|
|
57
|
+
readonly vision?: boolean;
|
|
51
58
|
}
|
|
52
59
|
|
|
53
60
|
/** A fake adapter, plus what the test wants to know about it afterwards. */
|
|
@@ -60,12 +67,12 @@ export interface FakeAdapter extends ProviderAdapter {
|
|
|
60
67
|
readonly aborted: number;
|
|
61
68
|
}
|
|
62
69
|
|
|
63
|
-
function defaultModel(providerId: string): BroappModel {
|
|
70
|
+
function defaultModel(providerId: string, vision: boolean): BroappModel {
|
|
64
71
|
return {
|
|
65
72
|
provider: providerId,
|
|
66
73
|
modelId: 'fake-1',
|
|
67
74
|
label: 'Fake 1',
|
|
68
|
-
capabilities: { tools: true, vision
|
|
75
|
+
capabilities: { tools: true, vision, structuredOutput: true },
|
|
69
76
|
};
|
|
70
77
|
}
|
|
71
78
|
|
|
@@ -127,7 +134,7 @@ function chunksFor(step: FakeStep, callIndex: number): StreamPart[] {
|
|
|
127
134
|
/** Build an adapter that needs no provider. */
|
|
128
135
|
export function createFakeAdapter(options: FakeAdapterOptions = {}): FakeAdapter {
|
|
129
136
|
const id = options.id ?? 'fake';
|
|
130
|
-
const models = options.models ?? [defaultModel(id)];
|
|
137
|
+
const models = options.models ?? [defaultModel(id, options.vision === true)];
|
|
131
138
|
const script = options.script ?? [{ kind: 'text', chunks: ['fake reply'] } as const];
|
|
132
139
|
const steps = flatten(script);
|
|
133
140
|
const delay = options.chunkDelayMs ?? 0;
|
|
@@ -140,7 +147,7 @@ export function createFakeAdapter(options: FakeAdapterOptions = {}): FakeAdapter
|
|
|
140
147
|
const adapter: FakeAdapter = {
|
|
141
148
|
id,
|
|
142
149
|
label: 'Fake provider',
|
|
143
|
-
needs: { apiKey: options.needsKey === true, baseUrl: 'none' },
|
|
150
|
+
needs: { apiKey: options.needsKey === true ? 'required' : 'none', baseUrl: 'none' },
|
|
144
151
|
defaultBaseUrl: null,
|
|
145
152
|
// Nothing leaves the process, so this is true whatever the configuration.
|
|
146
153
|
local: () => true,
|
|
@@ -7,17 +7,20 @@
|
|
|
7
7
|
* that does not exist, and a change to an operation's input reaches the tool
|
|
8
8
|
* description without anybody remembering to update it.
|
|
9
9
|
*
|
|
10
|
-
* The
|
|
11
|
-
* the
|
|
12
|
-
*
|
|
13
|
-
* the
|
|
10
|
+
* The lists here *select*; they no longer decide. What a call is allowed to do
|
|
11
|
+
* is the route's own `effect`, and the gate reads it from the contract. The
|
|
12
|
+
* list a route sits in has to agree with what it declares, and a route that
|
|
13
|
+
* declares nothing takes the list's word for it — which is how an application
|
|
14
|
+
* written before effects existed still says what it meant. Nothing is a tool
|
|
15
|
+
* unless it is named here, so the default for an application's surface is
|
|
16
|
+
* still that the model cannot reach it.
|
|
14
17
|
*/
|
|
15
18
|
import type { HostApp } from '../../host/app.ts';
|
|
19
|
+
import type { Effect } from '../../shared/contract.ts';
|
|
16
20
|
import type { AnyContract, OperationName } from '../../shared/contract.ts';
|
|
17
21
|
import type { JsonSchema } from '../../shared/schema.ts';
|
|
18
|
-
import type { ToolPermission } from '../shared/types.ts';
|
|
19
22
|
|
|
20
|
-
import type
|
|
23
|
+
import { GUARDED, type GuardedTool } from './tool.ts';
|
|
21
24
|
|
|
22
25
|
/** Which operations a model may call, and how much ceremony each needs. */
|
|
23
26
|
export interface ContractToolAllowList<C extends AnyContract> {
|
|
@@ -25,12 +28,15 @@ export interface ContractToolAllowList<C extends AnyContract> {
|
|
|
25
28
|
readonly confirm?: readonly OperationName<C>[];
|
|
26
29
|
}
|
|
27
30
|
|
|
31
|
+
/** What each list means about a route that does not declare an effect. */
|
|
32
|
+
const IMPLIED: Record<'read' | 'confirm', Effect> = { read: 'read', confirm: 'write' };
|
|
33
|
+
|
|
28
34
|
/** Build tools from operations the contract already describes. */
|
|
29
35
|
export function fromContract<C extends AnyContract>(
|
|
30
36
|
contract: C,
|
|
31
37
|
app: HostApp<C>,
|
|
32
38
|
allow: ContractToolAllowList<C>,
|
|
33
|
-
): Record<string,
|
|
39
|
+
): Record<string, GuardedTool> {
|
|
34
40
|
const read = allow.read ?? [];
|
|
35
41
|
const confirm = allow.confirm ?? [];
|
|
36
42
|
|
|
@@ -41,12 +47,12 @@ export function fromContract<C extends AnyContract>(
|
|
|
41
47
|
);
|
|
42
48
|
}
|
|
43
49
|
|
|
44
|
-
const tools: Record<string,
|
|
45
|
-
const groups: readonly (readonly [readonly OperationName<C>[],
|
|
50
|
+
const tools: Record<string, GuardedTool> = {};
|
|
51
|
+
const groups: readonly (readonly [readonly OperationName<C>[], 'read' | 'confirm'])[] = [
|
|
46
52
|
[read, 'read'],
|
|
47
53
|
[confirm, 'confirm'],
|
|
48
54
|
];
|
|
49
|
-
for (const [routes,
|
|
55
|
+
for (const [routes, list] of groups) {
|
|
50
56
|
for (const route of routes) {
|
|
51
57
|
const spec = contract.operations[route];
|
|
52
58
|
if (spec === undefined) {
|
|
@@ -59,6 +65,17 @@ export function fromContract<C extends AnyContract>(
|
|
|
59
65
|
`operation ${JSON.stringify(route)} needs a summary before it can be offered to a model`,
|
|
60
66
|
);
|
|
61
67
|
}
|
|
68
|
+
const declared = spec.effect;
|
|
69
|
+
// A list that disagrees with the contract is a misunderstanding about
|
|
70
|
+
// what an operation does, and the two readings differ in exactly the way
|
|
71
|
+
// that matters: one asks the user and the other does not. Neither is
|
|
72
|
+
// safe to guess at, so it is refused where a developer can see it.
|
|
73
|
+
if (declared !== undefined && declared !== IMPLIED[list] && !(list === 'confirm' && declared === 'external')) {
|
|
74
|
+
throw new TypeError(
|
|
75
|
+
`operation ${JSON.stringify(route)} is listed as a ${list} tool but declares effect ${JSON.stringify(declared)}`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
const effect: Effect = declared ?? IMPLIED[list];
|
|
62
79
|
const describe = (spec.input as { toJsonSchema?: () => JsonSchema }).toJsonSchema;
|
|
63
80
|
if (typeof describe !== 'function') {
|
|
64
81
|
throw new TypeError(
|
|
@@ -71,13 +88,16 @@ export function fromContract<C extends AnyContract>(
|
|
|
71
88
|
// lost by turning it back into "no argument" here.
|
|
72
89
|
const takesNothing = spec.input.kind === 'void';
|
|
73
90
|
tools[route] = {
|
|
91
|
+
[GUARDED]: true,
|
|
74
92
|
description: spec.summary,
|
|
75
93
|
inputSchema: describe.call(spec.input),
|
|
76
|
-
|
|
77
|
-
// `invoke` validates the input and applies the same error
|
|
78
|
-
// call from the browser gets, so a model's arguments are no
|
|
79
|
-
// trusted than a tab's.
|
|
80
|
-
|
|
94
|
+
effect,
|
|
95
|
+
// `invoke` validates the input, guards it and applies the same error
|
|
96
|
+
// boundary a call from the browser gets, so a model's arguments are no
|
|
97
|
+
// more trusted than a tab's. The hint is what the list decided, and it
|
|
98
|
+
// only applies where the contract itself is silent.
|
|
99
|
+
execute: (input, envelope) =>
|
|
100
|
+
app.invoke(route, takesNothing ? undefined : input, { ...envelope, effectHint: effect }),
|
|
81
101
|
};
|
|
82
102
|
}
|
|
83
103
|
}
|
package/src/ai/host/index.ts
CHANGED
|
@@ -18,15 +18,20 @@ export type { FakeAdapter, FakeAdapterOptions, FakeStep } from './fake.ts';
|
|
|
18
18
|
export { fromContract } from './from-contract.ts';
|
|
19
19
|
export type { ContractToolAllowList } from './from-contract.ts';
|
|
20
20
|
|
|
21
|
+
export { GUARDED, guardedTool } from './tool.ts';
|
|
21
22
|
export type {
|
|
22
23
|
AiContextProviders,
|
|
23
24
|
AiTool,
|
|
24
|
-
Confirmations,
|
|
25
25
|
ContextDocument,
|
|
26
26
|
ContextRef,
|
|
27
|
+
GuardedTool,
|
|
28
|
+
GuardedToolDefinition,
|
|
27
29
|
} from './tool.ts';
|
|
28
30
|
|
|
29
31
|
export { apiKeySecretName, createFileSecretStore, createMemorySecretStore } from './secrets.ts';
|
|
30
32
|
export type { SecretStore } from './secrets.ts';
|
|
31
33
|
|
|
32
34
|
export type { Registry, ResolvedModel, UpdatePatch } from './registry.ts';
|
|
35
|
+
|
|
36
|
+
export { DEFAULT_THREAD_TITLE, openThreads } from './threads.ts';
|
|
37
|
+
export type { ThreadStore } from './threads.ts';
|
package/src/ai/host/registry.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* "would `resolve()` succeed", rather than a second copy of the same
|
|
8
8
|
* conditions that can drift from the first.
|
|
9
9
|
*/
|
|
10
|
-
import {
|
|
10
|
+
import { isPublicError, publicError } from '../../shared/errors.ts';
|
|
11
11
|
import type { AiSettings } from '../shared/types.ts';
|
|
12
12
|
|
|
13
13
|
import type { AdapterConfig, ProviderAdapter } from './adapter.ts';
|
|
@@ -36,8 +36,15 @@ export interface Registry {
|
|
|
36
36
|
adapter(id: string): ProviderAdapter | null;
|
|
37
37
|
/** Current settings plus the key, for adapter calls. */
|
|
38
38
|
currentConfig(): Promise<{ adapter: ProviderAdapter; config: AdapterConfig } | null>;
|
|
39
|
-
/**
|
|
40
|
-
|
|
39
|
+
/**
|
|
40
|
+
* Everything needed to run a chat, or a `PublicError` explaining what is
|
|
41
|
+
* missing.
|
|
42
|
+
*
|
|
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.
|
|
46
|
+
*/
|
|
47
|
+
resolve(override?: { readonly modelId?: string | undefined }): Promise<ResolvedModel>;
|
|
41
48
|
/** The public view: settings without the key. */
|
|
42
49
|
settings(): Promise<AiSettings>;
|
|
43
50
|
update(patch: UpdatePatch): Promise<AiSettings>;
|
|
@@ -112,7 +119,7 @@ export function createRegistry(options: RegistryOptions): Registry {
|
|
|
112
119
|
return { adapter, config: configFrom(settings, adapter, apiKey) };
|
|
113
120
|
},
|
|
114
121
|
|
|
115
|
-
async resolve() {
|
|
122
|
+
async resolve(override) {
|
|
116
123
|
const settings = options.settingsStore.read();
|
|
117
124
|
if (settings.provider === null) throw publicError.unavailable(NOT_SET_UP);
|
|
118
125
|
const adapter = byId.get(settings.provider);
|
|
@@ -123,20 +130,24 @@ export function createRegistry(options: RegistryOptions): Registry {
|
|
|
123
130
|
// models is fetched *from* the provider, so telling a user to choose one
|
|
124
131
|
// before they can see any is an instruction they cannot follow.
|
|
125
132
|
const apiKey = await keyFor(settings, adapter.id);
|
|
126
|
-
if (adapter.needs.apiKey && (apiKey === null || apiKey === '')) {
|
|
133
|
+
if (adapter.needs.apiKey === 'required' && (apiKey === null || apiKey === '')) {
|
|
127
134
|
throw publicError.unavailable(`An API key is required for ${adapter.label}.`);
|
|
128
135
|
}
|
|
129
136
|
const config = configFrom(settings, adapter, apiKey);
|
|
130
137
|
if (adapter.needs.baseUrl === 'required' && (config.baseUrl === null || config.baseUrl === '')) {
|
|
131
138
|
throw publicError.unavailable(`A server address is required for ${adapter.label}.`);
|
|
132
139
|
}
|
|
133
|
-
|
|
140
|
+
// Read after the provider, the key and the address, so a conversation
|
|
141
|
+
// carrying its own model still hears "AI is not set up yet" first: the
|
|
142
|
+
// model is the last thing missing, never the first.
|
|
143
|
+
const modelId = override?.modelId ?? settings.modelId;
|
|
144
|
+
if (modelId === null) {
|
|
134
145
|
// Distinct from "not set up": the user is looking at the settings panel
|
|
135
146
|
// with a provider selected, and being told to choose a provider is an
|
|
136
147
|
// instruction they have already followed.
|
|
137
148
|
throw publicError.unavailable(`Choose a model for ${adapter.label}.`);
|
|
138
149
|
}
|
|
139
|
-
return { adapter, config, modelId
|
|
150
|
+
return { adapter, config, modelId };
|
|
140
151
|
},
|
|
141
152
|
|
|
142
153
|
async settings() {
|
|
@@ -149,7 +160,7 @@ export function createRegistry(options: RegistryOptions): Registry {
|
|
|
149
160
|
} catch (cause) {
|
|
150
161
|
// Anything that is not a deliberate "not configured" is a real fault
|
|
151
162
|
// and must not be reported as merely unconfigured.
|
|
152
|
-
if (!(cause
|
|
163
|
+
if (!isPublicError(cause)) throw cause;
|
|
153
164
|
configured = false;
|
|
154
165
|
}
|
|
155
166
|
return {
|