broapp 0.1.0 → 0.2.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/README.md +13 -4
- package/package.json +10 -2
- package/src/ai/host/adapter.ts +106 -0
- package/src/ai/host/create-ai.ts +200 -0
- package/src/ai/host/fake.ts +223 -0
- package/src/ai/host/from-contract.ts +85 -0
- package/src/ai/host/index.ts +32 -0
- package/src/ai/host/registry.ts +221 -0
- package/src/ai/host/run-types.ts +14 -0
- package/src/ai/host/run.ts +320 -0
- package/src/ai/host/secrets.ts +118 -0
- package/src/ai/host/settings.ts +83 -0
- package/src/ai/host/tool.ts +89 -0
- package/src/ai/react/AiChat.tsx +194 -0
- package/src/ai/react/AiSettings.tsx +222 -0
- package/src/ai/react/ai.css +152 -0
- package/src/ai/react/index.tsx +35 -0
- package/src/ai/react/provider.tsx +97 -0
- package/src/ai/react/use-ai-chat.ts +309 -0
- package/src/ai/react/use-ai-models.ts +70 -0
- package/src/ai/react/use-ai-settings.ts +105 -0
- package/src/ai/shared/contract.ts +138 -0
- package/src/ai/shared/index.ts +16 -0
- package/src/ai/shared/types.check.ts +43 -0
- package/src/ai/shared/types.ts +87 -0
- package/src/host/app.ts +94 -30
- package/src/host/index.ts +5 -0
- package/src/react/hooks.tsx +37 -3
- package/src/react/index.ts +1 -0
- package/src/shared/contract.ts +56 -2
- package/src/shared/errors.ts +10 -0
- package/src/shared/index.ts +8 -2
- package/src/shared/schema.ts +125 -28
package/README.md
CHANGED
|
@@ -22,10 +22,18 @@ Full documentation lives in the
|
|
|
22
22
|
| `broapp/client` | The framework-agnostic browser client. |
|
|
23
23
|
| `broapp/react` | `BroappProvider`, `useOperation`, `useStream`, `useConnection`. |
|
|
24
24
|
| `broapp/build` | `buildPage`, `buildBinary`, `defineConfig`. |
|
|
25
|
+
| `broapp/ai` | The AI contract and its types. Safe for both sides. |
|
|
26
|
+
| `broapp/ai/host` | `createAi`, `fromContract`, the secret stores, `createFakeAdapter`. Host only. |
|
|
27
|
+
| `broapp/ai/react` | `AiProvider`, `useAiChat`, `useAiSettings`, `useAiModels`, `<AiSettings/>`, `<AiChat/>`. |
|
|
25
28
|
|
|
26
|
-
Never import `broapp/host` from browser code:
|
|
27
|
-
`Bun.spawn
|
|
28
|
-
intended outcome.
|
|
29
|
+
Never import `broapp/host` or `broapp/ai/host` from browser code: they pull in
|
|
30
|
+
`node:fs`, `Bun.spawn` and the AI SDK, and a browser bundle that reaches either
|
|
31
|
+
fails the build — which is the intended outcome.
|
|
32
|
+
|
|
33
|
+
The AI layer is optional. `broapp/ai/host` needs the `ai` peer dependency and
|
|
34
|
+
at least one provider package (`broapp-ai-anthropic`, `broapp-ai-compatible`);
|
|
35
|
+
an application that never imports it carries none of that. See
|
|
36
|
+
[docs/ai.md](https://github.com/praveenvijayan/broapp/blob/main/docs/ai.md).
|
|
29
37
|
|
|
30
38
|
## The command
|
|
31
39
|
|
|
@@ -39,7 +47,8 @@ broapp build --all-targets Every supported target
|
|
|
39
47
|
## Requirements
|
|
40
48
|
|
|
41
49
|
Bun 1.2 or newer. Peer dependency on React 18 or newer, and only if you use
|
|
42
|
-
`broapp/react`.
|
|
50
|
+
`broapp/react`. Peer dependency on `ai@7.0.93`, and only if you use
|
|
51
|
+
`broapp/ai/host`.
|
|
43
52
|
|
|
44
53
|
## Licence
|
|
45
54
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "broapp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
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",
|
|
@@ -38,6 +38,10 @@
|
|
|
38
38
|
"exports": {
|
|
39
39
|
".": "./src/shared/index.ts",
|
|
40
40
|
"./shared": "./src/shared/index.ts",
|
|
41
|
+
"./ai": "./src/ai/shared/index.ts",
|
|
42
|
+
"./ai/host": "./src/ai/host/index.ts",
|
|
43
|
+
"./ai/react": "./src/ai/react/index.tsx",
|
|
44
|
+
"./ai/react/ai.css": "./src/ai/react/ai.css",
|
|
41
45
|
"./host": "./src/host/index.ts",
|
|
42
46
|
"./client": "./src/client/index.ts",
|
|
43
47
|
"./react": "./src/react/index.ts",
|
|
@@ -50,11 +54,15 @@
|
|
|
50
54
|
"brobridge": "^0.2.1"
|
|
51
55
|
},
|
|
52
56
|
"peerDependencies": {
|
|
53
|
-
"react": ">=18"
|
|
57
|
+
"react": ">=18",
|
|
58
|
+
"ai": "7.0.93"
|
|
54
59
|
},
|
|
55
60
|
"peerDependenciesMeta": {
|
|
56
61
|
"react": {
|
|
57
62
|
"optional": true
|
|
63
|
+
},
|
|
64
|
+
"ai": {
|
|
65
|
+
"optional": true
|
|
58
66
|
}
|
|
59
67
|
},
|
|
60
68
|
"devDependencies": {
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a provider package has to implement.
|
|
3
|
+
*
|
|
4
|
+
* An adapter is the only place in the AI layer that knows a provider exists.
|
|
5
|
+
* It answers four questions — what do you need, what models do you have, does
|
|
6
|
+
* this configuration work, and give me a model — and Broapp's host code is
|
|
7
|
+
* written entirely against those four.
|
|
8
|
+
*
|
|
9
|
+
* Every adapter takes its `fetch` from {@link AdapterConfig} rather than
|
|
10
|
+
* reaching for the global one. That is what makes a test able to prove no
|
|
11
|
+
* request left the machine, and what makes the AI SDK's gateway trap
|
|
12
|
+
* (see `reports/01-spike.md`) impossible to fall into by accident.
|
|
13
|
+
*/
|
|
14
|
+
import type { LanguageModel } from 'ai';
|
|
15
|
+
|
|
16
|
+
import { PublicError, publicError } from '../../shared/errors.ts';
|
|
17
|
+
import type { BroappModel } from '../shared/types.ts';
|
|
18
|
+
|
|
19
|
+
/** Everything an adapter needs to reach its provider. */
|
|
20
|
+
export interface AdapterConfig {
|
|
21
|
+
readonly apiKey: string | null;
|
|
22
|
+
readonly baseUrl: string | null;
|
|
23
|
+
/** Injected so tests never touch the network. Defaults to `globalThis.fetch`. */
|
|
24
|
+
readonly fetch: typeof fetch;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Why an adapter call failed, in terms the layer above can act on. */
|
|
28
|
+
export type AdapterErrorCode = 'auth' | 'network' | 'not_found' | 'rate_limited' | 'provider';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A failure an adapter reports deliberately.
|
|
32
|
+
*
|
|
33
|
+
* `message` is shown to the user, so it must name the problem in plain words
|
|
34
|
+
* and never include a key, a URL with credentials, or a raw provider response
|
|
35
|
+
* body. Anything an adapter cannot describe safely should be thrown as an
|
|
36
|
+
* ordinary error instead, and the host's error boundary will reduce it.
|
|
37
|
+
*/
|
|
38
|
+
export class AdapterError extends Error {
|
|
39
|
+
readonly code: AdapterErrorCode;
|
|
40
|
+
|
|
41
|
+
constructor(code: AdapterErrorCode, message: string, options?: { cause?: unknown }) {
|
|
42
|
+
super(message, options);
|
|
43
|
+
this.name = 'AdapterError';
|
|
44
|
+
this.code = code;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** One provider, as Broapp's host uses it. */
|
|
49
|
+
export interface ProviderAdapter {
|
|
50
|
+
/** Stable id, stored in settings: `'anthropic'`, `'ollama'`, `'fake'`. */
|
|
51
|
+
readonly id: string;
|
|
52
|
+
readonly label: string;
|
|
53
|
+
readonly needs: { readonly apiKey: boolean; readonly baseUrl: 'required' | 'optional' | 'none' };
|
|
54
|
+
readonly defaultBaseUrl: string | null;
|
|
55
|
+
/** Whether requests stay on this machine under this config. */
|
|
56
|
+
local(config: AdapterConfig): boolean;
|
|
57
|
+
/** List models. Must reject with {@link AdapterError} on failure. */
|
|
58
|
+
models(config: AdapterConfig, signal: AbortSignal): Promise<BroappModel[]>;
|
|
59
|
+
/** Cheapest possible proof the config works. Must reject with {@link AdapterError}. */
|
|
60
|
+
test(config: AdapterConfig, signal: AbortSignal): Promise<void>;
|
|
61
|
+
/** The AI SDK model. Only `broapp/ai/host` calls this. */
|
|
62
|
+
model(config: AdapterConfig, modelId: string): LanguageModel;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Hosts that mean "this machine". */
|
|
66
|
+
const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1', '[::1]']);
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* True when a URL points at this machine.
|
|
70
|
+
*
|
|
71
|
+
* Used to tell the user whether their prompts leave the computer. An address
|
|
72
|
+
* that cannot be parsed is not loopback: an unknown destination is exactly the
|
|
73
|
+
* case where the honest answer is "no".
|
|
74
|
+
*/
|
|
75
|
+
export function isLoopbackUrl(url: string): boolean {
|
|
76
|
+
try {
|
|
77
|
+
const parsed = new URL(url);
|
|
78
|
+
// `URL.hostname` strips the brackets from an IPv6 literal, so both spellings
|
|
79
|
+
// are in the set above.
|
|
80
|
+
return LOOPBACK_HOSTS.has(parsed.hostname);
|
|
81
|
+
} catch {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Translate an adapter failure into something the browser may see.
|
|
88
|
+
*
|
|
89
|
+
* Anything that is not an `AdapterError` is rethrown unchanged, so the host's
|
|
90
|
+
* existing boundary logs it with its stack and the browser gets the fixed
|
|
91
|
+
* internal sentence. An adapter that wants a message shown has to say so by
|
|
92
|
+
* choosing an `AdapterError` code.
|
|
93
|
+
*/
|
|
94
|
+
export function toPublicError(cause: unknown): PublicError {
|
|
95
|
+
if (!(cause instanceof AdapterError)) throw cause;
|
|
96
|
+
switch (cause.code) {
|
|
97
|
+
case 'auth':
|
|
98
|
+
return publicError.rejected(cause.message);
|
|
99
|
+
case 'not_found':
|
|
100
|
+
return publicError.notFound(cause.message);
|
|
101
|
+
case 'network':
|
|
102
|
+
case 'rate_limited':
|
|
103
|
+
case 'provider':
|
|
104
|
+
return publicError.unavailable(cause.message);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The AI layer's host runtime.
|
|
3
|
+
*
|
|
4
|
+
* `createAi(...)` builds a second `HostApp` — over Broapp's own AI contract —
|
|
5
|
+
* that an application mounts on the same bridge as its own. Keeping them
|
|
6
|
+
* separate means an application's route table never grows Broapp's routes, and
|
|
7
|
+
* an application that does not call `createAi` carries none of this.
|
|
8
|
+
*
|
|
9
|
+
* Chat arrives in the next layer up; the routes are registered here so that
|
|
10
|
+
* `mount` has an implementation for every route in the contract, which it
|
|
11
|
+
* insists on.
|
|
12
|
+
*/
|
|
13
|
+
import type { Bridge } from 'brobridge';
|
|
14
|
+
|
|
15
|
+
// Imported from the host entry point rather than from `host/app.ts` directly:
|
|
16
|
+
// the AI layer is part of the host runtime, and depending on that entry point
|
|
17
|
+
// is what makes a browser bundle of `broapp/ai/host` fail to build. Bun's
|
|
18
|
+
// browser target polyfills `node:fs`, so the file stores alone would not stop
|
|
19
|
+
// this code from being bundled into a page.
|
|
20
|
+
import { createReservedHostApp } from '../../host/index.ts';
|
|
21
|
+
import type { HostApp, HostLogger } from '../../host/app.ts';
|
|
22
|
+
import { publicError } from '../../shared/errors.ts';
|
|
23
|
+
import { aiContract, type AiContract } from '../shared/contract.ts';
|
|
24
|
+
import type { ProviderInfo } from '../shared/types.ts';
|
|
25
|
+
|
|
26
|
+
import { AdapterError, toPublicError, type AdapterConfig, type ProviderAdapter } from './adapter.ts';
|
|
27
|
+
import { createRegistry, type Registry } from './registry.ts';
|
|
28
|
+
import { runChat, type RunDeps } from './run.ts';
|
|
29
|
+
import { createFileSecretStore, createMemorySecretStore } from './secrets.ts';
|
|
30
|
+
import { createSettingsStore } from './settings.ts';
|
|
31
|
+
import { createConfirmations, type AiContextProviders, type AiTool } from './tool.ts';
|
|
32
|
+
|
|
33
|
+
/** What the application is, in the words a model is given. */
|
|
34
|
+
export interface AiAppDescription {
|
|
35
|
+
readonly name: string;
|
|
36
|
+
readonly purpose: string;
|
|
37
|
+
readonly terminology?: readonly string[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Options for {@link createAi}. */
|
|
41
|
+
export interface CreateAiOptions {
|
|
42
|
+
readonly dataDir: string;
|
|
43
|
+
readonly providers: readonly ProviderAdapter[];
|
|
44
|
+
readonly app: AiAppDescription;
|
|
45
|
+
/** Defaults to `globalThis.fetch`. Tests inject a fake. */
|
|
46
|
+
readonly fetch?: typeof fetch;
|
|
47
|
+
readonly logger?: HostLogger;
|
|
48
|
+
readonly context?: AiContextProviders;
|
|
49
|
+
readonly tools?: Record<string, AiTool>;
|
|
50
|
+
/** Character budget for context documents in one turn. Default 40_000. */
|
|
51
|
+
readonly contextBudgetChars?: number;
|
|
52
|
+
/** Max model steps (tool round trips) per turn. Default 8. */
|
|
53
|
+
readonly maxSteps?: number;
|
|
54
|
+
/** How long a `confirm` tool waits for the user. Default 300_000 ms. */
|
|
55
|
+
readonly confirmTimeoutMs?: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* What a tool may be called.
|
|
60
|
+
*
|
|
61
|
+
* Dots are allowed so that a contract route can be its own tool name, which is
|
|
62
|
+
* what `fromContract` does. Anything else risks a provider rejecting the whole
|
|
63
|
+
* request over a name the application chose carelessly.
|
|
64
|
+
*/
|
|
65
|
+
const TOOL_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_.]*$/;
|
|
66
|
+
|
|
67
|
+
/** The AI layer, ready to mount. */
|
|
68
|
+
export interface Ai {
|
|
69
|
+
mount(bridge: Bridge): void;
|
|
70
|
+
abortAll(reason: string): void;
|
|
71
|
+
readonly activeStreams: number;
|
|
72
|
+
/** For tests, and for applications that read settings on the host. */
|
|
73
|
+
readonly registry: Registry;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** How long a provider is given to answer a listing or a connection test. */
|
|
77
|
+
const PROVIDER_TIMEOUT_MS = 20_000;
|
|
78
|
+
|
|
79
|
+
/** Defaults for the run loop, all overridable per application. */
|
|
80
|
+
const DEFAULT_CONTEXT_BUDGET_CHARS = 40_000;
|
|
81
|
+
const DEFAULT_MAX_STEPS = 8;
|
|
82
|
+
const DEFAULT_CONFIRM_TIMEOUT_MS = 300_000;
|
|
83
|
+
|
|
84
|
+
/** Build the AI layer for one application. */
|
|
85
|
+
export function createAi(options: CreateAiOptions): Ai {
|
|
86
|
+
if (options.providers.length === 0) {
|
|
87
|
+
throw new TypeError('createAi needs at least one provider adapter');
|
|
88
|
+
}
|
|
89
|
+
const seen = new Set<string>();
|
|
90
|
+
for (const adapter of options.providers) {
|
|
91
|
+
if (seen.has(adapter.id)) {
|
|
92
|
+
throw new TypeError(`two provider adapters share the id ${JSON.stringify(adapter.id)}`);
|
|
93
|
+
}
|
|
94
|
+
seen.add(adapter.id);
|
|
95
|
+
}
|
|
96
|
+
for (const name of Object.keys(options.tools ?? {})) {
|
|
97
|
+
if (!TOOL_NAME_PATTERN.test(name)) {
|
|
98
|
+
throw new TypeError(`tool name ${JSON.stringify(name)} must be letters, digits, "_" or "."`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Both stores are built once and kept. `remember` chooses between them, and
|
|
103
|
+
// switching has to move a key from one to the other rather than construct a
|
|
104
|
+
// new store and lose what the old one held.
|
|
105
|
+
const registry = createRegistry({
|
|
106
|
+
adapters: options.providers,
|
|
107
|
+
settingsStore: createSettingsStore(options.dataDir),
|
|
108
|
+
fileSecrets: createFileSecretStore(options.dataDir),
|
|
109
|
+
memorySecrets: createMemorySecretStore(),
|
|
110
|
+
fetch: options.fetch ?? globalThis.fetch,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const host: HostApp<AiContract> = createReservedHostApp<AiContract>(aiContract, {
|
|
114
|
+
...(options.logger === undefined ? {} : { logger: options.logger }),
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
host.operation('ai.settingsGet', () => registry.settings());
|
|
118
|
+
host.operation('ai.settingsUpdate', (input) => registry.update(input));
|
|
119
|
+
|
|
120
|
+
host.operation('ai.providersList', () => ({
|
|
121
|
+
providers: options.providers.map((adapter): ProviderInfo => {
|
|
122
|
+
// Deliberately computed without the key: whether requests leave this
|
|
123
|
+
// machine is a property of the address, and the user is entitled to the
|
|
124
|
+
// answer before they have entered anything.
|
|
125
|
+
const config = registry.configFor(adapter);
|
|
126
|
+
return {
|
|
127
|
+
id: adapter.id,
|
|
128
|
+
label: adapter.label,
|
|
129
|
+
local: adapter.local(config),
|
|
130
|
+
needs: { apiKey: adapter.needs.apiKey, baseUrl: adapter.needs.baseUrl },
|
|
131
|
+
defaultBaseUrl: adapter.defaultBaseUrl,
|
|
132
|
+
};
|
|
133
|
+
}),
|
|
134
|
+
}));
|
|
135
|
+
|
|
136
|
+
host.operation('ai.modelsList', async () => {
|
|
137
|
+
const { adapter, config } = await requireConfig();
|
|
138
|
+
try {
|
|
139
|
+
const models = await adapter.models(config, AbortSignal.timeout(PROVIDER_TIMEOUT_MS));
|
|
140
|
+
return { models };
|
|
141
|
+
} catch (cause) {
|
|
142
|
+
throw toPublicError(cause);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
host.operation('ai.connectionTest', async () => {
|
|
147
|
+
// `resolve()` rather than `currentConfig()`: testing a connection that is
|
|
148
|
+
// missing its key would just ask the provider to reject it, and the layer
|
|
149
|
+
// already knows the answer and can say it in better words.
|
|
150
|
+
const { adapter, config } = await registry.resolve();
|
|
151
|
+
const started = Bun.nanoseconds();
|
|
152
|
+
const elapsed = (): number => Math.round((Bun.nanoseconds() - started) / 1_000_000);
|
|
153
|
+
try {
|
|
154
|
+
await adapter.test(config, AbortSignal.timeout(PROVIDER_TIMEOUT_MS));
|
|
155
|
+
return { ok: true, message: `Connected to ${adapter.label}.`, latencyMs: elapsed() };
|
|
156
|
+
} catch (cause) {
|
|
157
|
+
// A failed connection test is the answer to the question, not a failure
|
|
158
|
+
// of the route: the UI shows the reason next to the button. Anything
|
|
159
|
+
// that is not a deliberate adapter failure is still a fault.
|
|
160
|
+
if (!(cause instanceof AdapterError)) throw cause;
|
|
161
|
+
return { ok: false, message: cause.message, latencyMs: elapsed() };
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const confirmations = createConfirmations();
|
|
166
|
+
const runDeps: RunDeps = {
|
|
167
|
+
registry,
|
|
168
|
+
app: options.app,
|
|
169
|
+
context: options.context ?? {},
|
|
170
|
+
tools: options.tools ?? {},
|
|
171
|
+
contextBudgetChars: options.contextBudgetChars ?? DEFAULT_CONTEXT_BUDGET_CHARS,
|
|
172
|
+
maxSteps: options.maxSteps ?? DEFAULT_MAX_STEPS,
|
|
173
|
+
confirmTimeoutMs: options.confirmTimeoutMs ?? DEFAULT_CONFIRM_TIMEOUT_MS,
|
|
174
|
+
confirmations,
|
|
175
|
+
logger: options.logger ?? console,
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
host.stream('ai.chat', (params, sink) => runChat(params, sink, runDeps));
|
|
179
|
+
host.operation('ai.chatConfirm', ({ runId, callId, approve }) => ({
|
|
180
|
+
accepted: confirmations.answer(runId, callId, approve),
|
|
181
|
+
}));
|
|
182
|
+
|
|
183
|
+
/** The current provider config, or the "not set up" error. */
|
|
184
|
+
async function requireConfig(): Promise<{ adapter: ProviderAdapter; config: AdapterConfig }> {
|
|
185
|
+
const current = await registry.currentConfig();
|
|
186
|
+
if (current === null) {
|
|
187
|
+
throw publicError.unavailable('AI is not set up yet. Open Settings to choose a provider.');
|
|
188
|
+
}
|
|
189
|
+
return current;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
mount: (bridge: Bridge) => host.mount(bridge),
|
|
194
|
+
abortAll: (reason: string) => host.abortAll(reason),
|
|
195
|
+
get activeStreams() {
|
|
196
|
+
return host.activeStreams;
|
|
197
|
+
},
|
|
198
|
+
registry,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An adapter that answers without a provider.
|
|
3
|
+
*
|
|
4
|
+
* It exists for Broapp's own tests and for the tests of applications built on
|
|
5
|
+
* Broapp: the AI layer is a lot of behaviour that has nothing to do with any
|
|
6
|
+
* particular vendor, and none of it should need a key or a network to test.
|
|
7
|
+
*
|
|
8
|
+
* The model it returns is the AI SDK's own `MockLanguageModelV4` from
|
|
9
|
+
* `ai/test`, so `streamText` runs its real loop — tool calls, steps, finish
|
|
10
|
+
* reasons and all — over a scripted set of chunks.
|
|
11
|
+
*/
|
|
12
|
+
import { simulateReadableStream } from 'ai';
|
|
13
|
+
import type { LanguageModel } from 'ai';
|
|
14
|
+
import { MockLanguageModelV4 } from 'ai/test';
|
|
15
|
+
|
|
16
|
+
import type { BroappModel } from '../shared/types.ts';
|
|
17
|
+
|
|
18
|
+
import { AdapterError, type AdapterConfig, type ProviderAdapter } from './adapter.ts';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The provider-level stream types, taken from the mock rather than imported.
|
|
22
|
+
*
|
|
23
|
+
* `@ai-sdk/provider` is not a dependency of `broapp` — it arrives under `ai` —
|
|
24
|
+
* so the shapes are read off the class that has to accept them. A change in
|
|
25
|
+
* the AI SDK then shows up here as a type error rather than a wrong chunk.
|
|
26
|
+
*/
|
|
27
|
+
type StreamResult = Awaited<ReturnType<MockLanguageModelV4['doStream']>>;
|
|
28
|
+
type StreamPart = StreamResult['stream'] extends ReadableStream<infer P> ? P : never;
|
|
29
|
+
type CallOptions = Parameters<MockLanguageModelV4['doStream']>[0];
|
|
30
|
+
|
|
31
|
+
/** One scripted model step. */
|
|
32
|
+
export type FakeStep =
|
|
33
|
+
| { readonly kind: 'text'; readonly chunks: readonly string[] }
|
|
34
|
+
/** `then` runs after the tool result comes back. */
|
|
35
|
+
| { readonly kind: 'tool'; readonly name: string; readonly input: unknown; readonly then: readonly FakeStep[] };
|
|
36
|
+
|
|
37
|
+
/** How to shape a fake adapter for one test. */
|
|
38
|
+
export interface FakeAdapterOptions {
|
|
39
|
+
/** Default `'fake'`. */
|
|
40
|
+
readonly id?: string;
|
|
41
|
+
/** Default: one model, `fake-1`. */
|
|
42
|
+
readonly models?: readonly BroappModel[];
|
|
43
|
+
/** Default `false`. */
|
|
44
|
+
readonly needsKey?: boolean;
|
|
45
|
+
/** When set, `test()` rejects with it. */
|
|
46
|
+
readonly failTestWith?: AdapterError;
|
|
47
|
+
/** What the model says, step by step. Default: one text step. */
|
|
48
|
+
readonly script?: readonly FakeStep[];
|
|
49
|
+
/** Delay between chunks, so a cancel test can catch a stream mid-flight. */
|
|
50
|
+
readonly chunkDelayMs?: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A fake adapter, plus what the test wants to know about it afterwards. */
|
|
54
|
+
export interface FakeAdapter extends ProviderAdapter {
|
|
55
|
+
/** Every prompt the model was given, in order. */
|
|
56
|
+
readonly calls: readonly unknown[];
|
|
57
|
+
/** How many times `model()` was called. Proves no string model id was used. */
|
|
58
|
+
readonly modelCalls: number;
|
|
59
|
+
/** How many times a call was abandoned because its signal aborted. */
|
|
60
|
+
readonly aborted: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function defaultModel(providerId: string): BroappModel {
|
|
64
|
+
return {
|
|
65
|
+
provider: providerId,
|
|
66
|
+
modelId: 'fake-1',
|
|
67
|
+
label: 'Fake 1',
|
|
68
|
+
capabilities: { tools: true, vision: false, structuredOutput: true },
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The usage every fake step reports. Small and constant, so tests can assert it. */
|
|
73
|
+
const USAGE = {
|
|
74
|
+
inputTokens: { total: 11, noCache: 11, cacheRead: 0, cacheWrite: 0 },
|
|
75
|
+
outputTokens: { total: 7, text: 7, reasoning: 0 },
|
|
76
|
+
} as const;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Flatten a script into the sequence of steps `streamText` will ask for.
|
|
80
|
+
*
|
|
81
|
+
* `doStream` is called once per step of the agent loop, so a `tool` step's
|
|
82
|
+
* `then` steps are simply the ones that follow it. Nesting is how a test
|
|
83
|
+
* writes "and after the tool comes back, say this".
|
|
84
|
+
*/
|
|
85
|
+
function flatten(script: readonly FakeStep[]): FakeStep[] {
|
|
86
|
+
const out: FakeStep[] = [];
|
|
87
|
+
for (const step of script) {
|
|
88
|
+
out.push(step);
|
|
89
|
+
if (step.kind === 'tool') out.push(...flatten(step.then));
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The chunks for one step. */
|
|
95
|
+
function chunksFor(step: FakeStep, callIndex: number): StreamPart[] {
|
|
96
|
+
if (step.kind === 'text') {
|
|
97
|
+
const parts: StreamPart[] = [{ type: 'text-start', id: String(callIndex) }];
|
|
98
|
+
for (const chunk of step.chunks) {
|
|
99
|
+
parts.push({ type: 'text-delta', id: String(callIndex), delta: chunk });
|
|
100
|
+
}
|
|
101
|
+
parts.push({ type: 'text-end', id: String(callIndex) });
|
|
102
|
+
parts.push({
|
|
103
|
+
type: 'finish',
|
|
104
|
+
finishReason: { unified: 'stop', raw: 'stop' },
|
|
105
|
+
usage: USAGE,
|
|
106
|
+
});
|
|
107
|
+
return parts;
|
|
108
|
+
}
|
|
109
|
+
// A tool call arrives whole: this mock has no reason to dribble the input
|
|
110
|
+
// out in deltas, and the loop under test does not care if it did.
|
|
111
|
+
const toolCallId = `call-${String(callIndex)}`;
|
|
112
|
+
return [
|
|
113
|
+
{
|
|
114
|
+
type: 'tool-call',
|
|
115
|
+
toolCallId,
|
|
116
|
+
toolName: step.name,
|
|
117
|
+
input: JSON.stringify(step.input),
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
type: 'finish',
|
|
121
|
+
finishReason: { unified: 'tool-calls', raw: 'tool_calls' },
|
|
122
|
+
usage: USAGE,
|
|
123
|
+
},
|
|
124
|
+
];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Build an adapter that needs no provider. */
|
|
128
|
+
export function createFakeAdapter(options: FakeAdapterOptions = {}): FakeAdapter {
|
|
129
|
+
const id = options.id ?? 'fake';
|
|
130
|
+
const models = options.models ?? [defaultModel(id)];
|
|
131
|
+
const script = options.script ?? [{ kind: 'text', chunks: ['fake reply'] } as const];
|
|
132
|
+
const steps = flatten(script);
|
|
133
|
+
const delay = options.chunkDelayMs ?? 0;
|
|
134
|
+
|
|
135
|
+
const calls: unknown[] = [];
|
|
136
|
+
let callIndex = 0;
|
|
137
|
+
let modelCalls = 0;
|
|
138
|
+
let aborted = 0;
|
|
139
|
+
|
|
140
|
+
const adapter: FakeAdapter = {
|
|
141
|
+
id,
|
|
142
|
+
label: 'Fake provider',
|
|
143
|
+
needs: { apiKey: options.needsKey === true, baseUrl: 'none' },
|
|
144
|
+
defaultBaseUrl: null,
|
|
145
|
+
// Nothing leaves the process, so this is true whatever the configuration.
|
|
146
|
+
local: () => true,
|
|
147
|
+
|
|
148
|
+
models: (_config: AdapterConfig, _signal: AbortSignal) => Promise.resolve([...models]),
|
|
149
|
+
|
|
150
|
+
test: (_config: AdapterConfig, _signal: AbortSignal) =>
|
|
151
|
+
options.failTestWith === undefined ? Promise.resolve() : Promise.reject(options.failTestWith),
|
|
152
|
+
|
|
153
|
+
get calls() {
|
|
154
|
+
return calls;
|
|
155
|
+
},
|
|
156
|
+
get modelCalls() {
|
|
157
|
+
return modelCalls;
|
|
158
|
+
},
|
|
159
|
+
get aborted() {
|
|
160
|
+
return aborted;
|
|
161
|
+
},
|
|
162
|
+
|
|
163
|
+
model(_config: AdapterConfig, modelId: string): LanguageModel {
|
|
164
|
+
modelCalls += 1;
|
|
165
|
+
return new MockLanguageModelV4({
|
|
166
|
+
provider: id,
|
|
167
|
+
modelId,
|
|
168
|
+
doStream: (callOptions: CallOptions): Promise<StreamResult> => {
|
|
169
|
+
calls.push(callOptions.prompt);
|
|
170
|
+
if (callOptions.abortSignal?.aborted === true) {
|
|
171
|
+
aborted += 1;
|
|
172
|
+
// The name is what `streamText` checks for, so a cancelled turn
|
|
173
|
+
// looks like a cancelled turn rather than a provider failure.
|
|
174
|
+
const error = new Error('aborted');
|
|
175
|
+
error.name = 'AbortError';
|
|
176
|
+
return Promise.reject(error);
|
|
177
|
+
}
|
|
178
|
+
const step = steps[callIndex] ?? { kind: 'text' as const, chunks: [''] };
|
|
179
|
+
const parts = chunksFor(step, callIndex);
|
|
180
|
+
callIndex += 1;
|
|
181
|
+
const stream = simulateReadableStream<StreamPart>({
|
|
182
|
+
initialDelayInMs: delay === 0 ? 0 : delay,
|
|
183
|
+
chunkDelayInMs: delay,
|
|
184
|
+
chunks: parts,
|
|
185
|
+
});
|
|
186
|
+
// The signal has to reach the stream too: a cancel that arrives
|
|
187
|
+
// while chunks are still being delivered must stop them, not wait
|
|
188
|
+
// for the script to run out.
|
|
189
|
+
return Promise.resolve({ stream: withAbort(stream, callOptions.abortSignal, () => {
|
|
190
|
+
aborted += 1;
|
|
191
|
+
}) });
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
return adapter;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Wrap a stream so an aborted signal ends it instead of letting it run on. */
|
|
200
|
+
function withAbort<T>(
|
|
201
|
+
stream: ReadableStream<T>,
|
|
202
|
+
signal: AbortSignal | undefined,
|
|
203
|
+
onAbort: () => void,
|
|
204
|
+
): ReadableStream<T> {
|
|
205
|
+
if (signal === undefined) return stream;
|
|
206
|
+
const reader = stream.getReader();
|
|
207
|
+
return new ReadableStream<T>({
|
|
208
|
+
async pull(controller) {
|
|
209
|
+
if (signal.aborted) {
|
|
210
|
+
onAbort();
|
|
211
|
+
await reader.cancel().catch(() => undefined);
|
|
212
|
+
controller.close();
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
const { done, value } = await reader.read();
|
|
216
|
+
if (done) controller.close();
|
|
217
|
+
else controller.enqueue(value);
|
|
218
|
+
},
|
|
219
|
+
async cancel(reason) {
|
|
220
|
+
await reader.cancel(reason).catch(() => undefined);
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turning an application's own operations into tools a model may call.
|
|
3
|
+
*
|
|
4
|
+
* The contract already says what each operation takes, what it returns and,
|
|
5
|
+
* in its `summary`, what it is for — which is exactly what a tool definition
|
|
6
|
+
* needs. Deriving tools from it means a model cannot be offered an operation
|
|
7
|
+
* that does not exist, and a change to an operation's input reaches the tool
|
|
8
|
+
* description without anybody remembering to update it.
|
|
9
|
+
*
|
|
10
|
+
* The permission split is the important part. `read` operations run as soon as
|
|
11
|
+
* the model asks; `confirm` operations wait for the user. Nothing is a tool
|
|
12
|
+
* unless it is named here, so the default for an application's surface is that
|
|
13
|
+
* the model cannot reach it.
|
|
14
|
+
*/
|
|
15
|
+
import type { HostApp } from '../../host/app.ts';
|
|
16
|
+
import type { AnyContract, OperationName } from '../../shared/contract.ts';
|
|
17
|
+
import type { JsonSchema } from '../../shared/schema.ts';
|
|
18
|
+
import type { ToolPermission } from '../shared/types.ts';
|
|
19
|
+
|
|
20
|
+
import type { AiTool } from './tool.ts';
|
|
21
|
+
|
|
22
|
+
/** Which operations a model may call, and how much ceremony each needs. */
|
|
23
|
+
export interface ContractToolAllowList<C extends AnyContract> {
|
|
24
|
+
readonly read?: readonly OperationName<C>[];
|
|
25
|
+
readonly confirm?: readonly OperationName<C>[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Build tools from operations the contract already describes. */
|
|
29
|
+
export function fromContract<C extends AnyContract>(
|
|
30
|
+
contract: C,
|
|
31
|
+
app: HostApp<C>,
|
|
32
|
+
allow: ContractToolAllowList<C>,
|
|
33
|
+
): Record<string, AiTool> {
|
|
34
|
+
const read = allow.read ?? [];
|
|
35
|
+
const confirm = allow.confirm ?? [];
|
|
36
|
+
|
|
37
|
+
const both = read.filter((route) => (confirm as readonly string[]).includes(route));
|
|
38
|
+
if (both.length > 0) {
|
|
39
|
+
throw new TypeError(
|
|
40
|
+
`operation ${JSON.stringify(both[0])} is listed as both a read tool and a confirm tool`,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const tools: Record<string, AiTool> = {};
|
|
45
|
+
const groups: readonly (readonly [readonly OperationName<C>[], ToolPermission])[] = [
|
|
46
|
+
[read, 'read'],
|
|
47
|
+
[confirm, 'confirm'],
|
|
48
|
+
];
|
|
49
|
+
for (const [routes, permission] of groups) {
|
|
50
|
+
for (const route of routes) {
|
|
51
|
+
const spec = contract.operations[route];
|
|
52
|
+
if (spec === undefined) {
|
|
53
|
+
throw new TypeError(`operation ${JSON.stringify(route)} is not declared in the contract`);
|
|
54
|
+
}
|
|
55
|
+
if (spec.summary === undefined || spec.summary === '') {
|
|
56
|
+
// Without a summary the model is told a name and nothing else, and it
|
|
57
|
+
// will guess. Better to refuse at startup than to guess in production.
|
|
58
|
+
throw new TypeError(
|
|
59
|
+
`operation ${JSON.stringify(route)} needs a summary before it can be offered to a model`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
const describe = (spec.input as { toJsonSchema?: () => JsonSchema }).toJsonSchema;
|
|
63
|
+
if (typeof describe !== 'function') {
|
|
64
|
+
throw new TypeError(
|
|
65
|
+
`operation ${JSON.stringify(route)} uses a validator with no toJsonSchema(); pass a hand-written tool for it instead`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
// An operation taking `s.void()` is described to the model as an object
|
|
69
|
+
// with no properties, because that is what a tool's arguments have to
|
|
70
|
+
// be. The model then sends `{}`, which `s.void()` refuses. Nothing is
|
|
71
|
+
// lost by turning it back into "no argument" here.
|
|
72
|
+
const takesNothing = spec.input.kind === 'void';
|
|
73
|
+
tools[route] = {
|
|
74
|
+
description: spec.summary,
|
|
75
|
+
inputSchema: describe.call(spec.input),
|
|
76
|
+
permission,
|
|
77
|
+
// `invoke` validates the input and applies the same error boundary a
|
|
78
|
+
// call from the browser gets, so a model's arguments are no more
|
|
79
|
+
// trusted than a tab's.
|
|
80
|
+
execute: (input) => app.invoke(route, takesNothing ? undefined : input),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return tools;
|
|
85
|
+
}
|