@frockbot/plugin-provider-ollama-cloud 0.0.0 → 0.1.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 +115 -1
- package/frockbot.json +79 -0
- package/package.json +39 -6
- package/src/client.test.ts +140 -0
- package/src/client.ts +357 -0
- package/src/endpoint.test.ts +412 -0
- package/src/index.ts +2 -0
- package/src/manifest.ts +3 -0
- package/src/runtime.test.ts +518 -0
- package/src/runtime.ts +193 -0
- package/src/user.test.ts +2230 -0
- package/src/user.ts +2444 -0
- package/src/web-search.test.ts +383 -0
- package/src/web-search.ts +299 -0
- package/tsconfig.json +15 -0
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { Context, Service } from "cordis";
|
|
3
|
+
import { ToolRegistry } from "@frockbot/plugin-tools/agent";
|
|
4
|
+
import type { ToolExecutionContext } from "@frockbot/kernel-contracts";
|
|
5
|
+
import type { CredentialLeaseV1 } from "@frockbot/connection-core";
|
|
6
|
+
import {
|
|
7
|
+
createConfiguredOllamaWebSearchRuntimeContribution,
|
|
8
|
+
ollamaWebSearchUrl,
|
|
9
|
+
OllamaCloudWebSearchClient,
|
|
10
|
+
} from "./web-search.ts";
|
|
11
|
+
|
|
12
|
+
const CONNECTION_ID = "connection-1";
|
|
13
|
+
const GENERATION = "generation-1";
|
|
14
|
+
const API_KEY = "ollama-test-key";
|
|
15
|
+
|
|
16
|
+
const ASSIGNMENT = {
|
|
17
|
+
packageId: "provider-ollama-cloud",
|
|
18
|
+
capabilityId: "ollama-cloud-web-search",
|
|
19
|
+
connectionId: CONNECTION_ID,
|
|
20
|
+
state: "enabled",
|
|
21
|
+
} as const;
|
|
22
|
+
|
|
23
|
+
interface Recorded {
|
|
24
|
+
url: string;
|
|
25
|
+
init: RequestInit | undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function lease(effectId: string): CredentialLeaseV1 {
|
|
29
|
+
return {
|
|
30
|
+
schemaVersion: 1,
|
|
31
|
+
leaseId: `lease-${effectId}`,
|
|
32
|
+
connectionId: CONNECTION_ID,
|
|
33
|
+
effectId,
|
|
34
|
+
credentialGeneration: GENERATION,
|
|
35
|
+
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
36
|
+
// The lease carries the sealed credential; only the Credential Store may
|
|
37
|
+
// open it, and this test's opener never looks inside.
|
|
38
|
+
envelope: {
|
|
39
|
+
schemaVersion: 1,
|
|
40
|
+
algorithm: "AES-GCM",
|
|
41
|
+
keyId: "primary",
|
|
42
|
+
credentialGeneration: GENERATION,
|
|
43
|
+
nonce: "bm9uY2U",
|
|
44
|
+
ciphertext: "Y2lwaGVy",
|
|
45
|
+
createdAt: new Date(0).toISOString(),
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The credential-store service the Bot Durable Object mounts for real. */
|
|
51
|
+
class FakeCredentialLease extends Service {
|
|
52
|
+
opened: string[] = [];
|
|
53
|
+
constructor(ctx: Context) {
|
|
54
|
+
super(ctx, "credentialLease");
|
|
55
|
+
}
|
|
56
|
+
open(input: {
|
|
57
|
+
packageId: string;
|
|
58
|
+
lease: CredentialLeaseV1;
|
|
59
|
+
}): Promise<string> {
|
|
60
|
+
this.opened.push(input.packageId);
|
|
61
|
+
return Promise.resolve(API_KEY);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function toolContext(effectId = "effect-1"): ToolExecutionContext {
|
|
66
|
+
return {
|
|
67
|
+
botId: "bot",
|
|
68
|
+
agentId: "bot",
|
|
69
|
+
sessionId: "session",
|
|
70
|
+
compositionGenerationId: "generation",
|
|
71
|
+
effectId,
|
|
72
|
+
turnType: "chat",
|
|
73
|
+
signal: new AbortController().signal,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function mount(options: {
|
|
78
|
+
respond: (recorded: Recorded) => Response;
|
|
79
|
+
apiBaseUrl?: string;
|
|
80
|
+
maxResults?: number;
|
|
81
|
+
}) {
|
|
82
|
+
const recorded: Recorded[] = [];
|
|
83
|
+
const leased: string[] = [];
|
|
84
|
+
const settled: string[] = [];
|
|
85
|
+
const root = new Context();
|
|
86
|
+
await root.plugin(ToolRegistry);
|
|
87
|
+
await root.plugin(FakeCredentialLease);
|
|
88
|
+
const plugin = createConfiguredOllamaWebSearchRuntimeContribution({
|
|
89
|
+
assignment: ASSIGNMENT,
|
|
90
|
+
accountId: "user-1",
|
|
91
|
+
connectionId: CONNECTION_ID,
|
|
92
|
+
connectionGeneration: GENERATION,
|
|
93
|
+
...(options.apiBaseUrl === undefined
|
|
94
|
+
? {}
|
|
95
|
+
: { apiBaseUrl: options.apiBaseUrl }),
|
|
96
|
+
...(options.maxResults === undefined
|
|
97
|
+
? {}
|
|
98
|
+
: { maxResults: options.maxResults }),
|
|
99
|
+
leaseCredential: (effectId) => {
|
|
100
|
+
leased.push(effectId);
|
|
101
|
+
return Promise.resolve(lease(effectId));
|
|
102
|
+
},
|
|
103
|
+
settleCredential: (effectId) => {
|
|
104
|
+
settled.push(effectId);
|
|
105
|
+
return Promise.resolve();
|
|
106
|
+
},
|
|
107
|
+
fetch: (input, init) => {
|
|
108
|
+
const entry = { url: String(input), init };
|
|
109
|
+
recorded.push(entry);
|
|
110
|
+
return Promise.resolve(options.respond(entry));
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
expect(plugin).toBeDefined();
|
|
114
|
+
await root.plugin(plugin!);
|
|
115
|
+
return { root, recorded, leased, settled };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
describe("the Ollama Cloud web_search Capability", () => {
|
|
119
|
+
test("composes the endpoint from the Connection's own base URL", () => {
|
|
120
|
+
expect(ollamaWebSearchUrl()).toBe("https://ollama.com/api/web_search");
|
|
121
|
+
expect(ollamaWebSearchUrl("http://127.0.0.1:11434")).toBe(
|
|
122
|
+
"http://127.0.0.1:11434/api/web_search",
|
|
123
|
+
);
|
|
124
|
+
expect(ollamaWebSearchUrl("https://proxy.example/")).toBe(
|
|
125
|
+
"https://proxy.example/api/web_search",
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("posts the bounded request shape with the leased key", async () => {
|
|
130
|
+
const { root, recorded, leased, settled } = await mount({
|
|
131
|
+
respond: () =>
|
|
132
|
+
Response.json({
|
|
133
|
+
results: [
|
|
134
|
+
{
|
|
135
|
+
title: "A result",
|
|
136
|
+
url: "https://example.test/a",
|
|
137
|
+
content: " a snippet\n over lines ",
|
|
138
|
+
},
|
|
139
|
+
{ title: "no url", content: "dropped" },
|
|
140
|
+
],
|
|
141
|
+
}),
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const prepared = await root.tools.prepare(
|
|
145
|
+
{ id: "call-1", name: "web_search", input: { query: "frockbot" } },
|
|
146
|
+
toolContext(),
|
|
147
|
+
);
|
|
148
|
+
expect(prepared.kind).toBe("ready");
|
|
149
|
+
if (prepared.kind !== "ready") return;
|
|
150
|
+
const result = await root.tools.executePrepared(prepared, toolContext());
|
|
151
|
+
|
|
152
|
+
expect(result.isError).toBe(false);
|
|
153
|
+
expect(JSON.parse(result.content)).toEqual({
|
|
154
|
+
query: "frockbot",
|
|
155
|
+
results: [
|
|
156
|
+
{
|
|
157
|
+
title: "A result",
|
|
158
|
+
url: "https://example.test/a",
|
|
159
|
+
snippet: "a snippet over lines",
|
|
160
|
+
},
|
|
161
|
+
],
|
|
162
|
+
});
|
|
163
|
+
const call = recorded[0]!;
|
|
164
|
+
expect(call.url).toBe("https://ollama.com/api/web_search");
|
|
165
|
+
expect(call.init?.method).toBe("POST");
|
|
166
|
+
expect(new Headers(call.init?.headers).get("authorization")).toBe(
|
|
167
|
+
`Bearer ${API_KEY}`,
|
|
168
|
+
);
|
|
169
|
+
expect(JSON.parse(String(call.init?.body))).toEqual({
|
|
170
|
+
query: "frockbot",
|
|
171
|
+
max_results: 5,
|
|
172
|
+
});
|
|
173
|
+
// The credential is leased per durable effect and settled afterwards, so
|
|
174
|
+
// no key outlives the tool call that opened it.
|
|
175
|
+
expect(leased).toEqual(["effect-1"]);
|
|
176
|
+
expect(settled).toEqual(["effect-1"]);
|
|
177
|
+
// And it never reaches the durable result.
|
|
178
|
+
expect(result.content).not.toContain(API_KEY);
|
|
179
|
+
await root.fiber.dispose();
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("carries max_results through and trims the answer to it", async () => {
|
|
183
|
+
const { root, recorded } = await mount({
|
|
184
|
+
respond: () =>
|
|
185
|
+
Response.json({
|
|
186
|
+
results: Array.from({ length: 8 }, (_value, index) => ({
|
|
187
|
+
title: `r${index}`,
|
|
188
|
+
url: `https://example.test/${index}`,
|
|
189
|
+
content: "x",
|
|
190
|
+
})),
|
|
191
|
+
}),
|
|
192
|
+
});
|
|
193
|
+
const call = {
|
|
194
|
+
id: "c",
|
|
195
|
+
name: "web_search",
|
|
196
|
+
input: { query: "q", max_results: 2 },
|
|
197
|
+
};
|
|
198
|
+
const prepared = await root.tools.prepare(call, toolContext());
|
|
199
|
+
if (prepared.kind !== "ready") throw new Error("not ready");
|
|
200
|
+
const result = await root.tools.executePrepared(prepared, toolContext());
|
|
201
|
+
expect(JSON.parse(String(recorded[0]?.init?.body)).max_results).toBe(2);
|
|
202
|
+
expect(
|
|
203
|
+
(JSON.parse(result.content) as { results: unknown[] }).results.length,
|
|
204
|
+
).toBe(2);
|
|
205
|
+
await root.fiber.dispose();
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("caps the request at the Package-level setting the User chose", async () => {
|
|
209
|
+
const { root, recorded } = await mount({
|
|
210
|
+
maxResults: 2,
|
|
211
|
+
respond: () =>
|
|
212
|
+
Response.json({
|
|
213
|
+
results: Array.from({ length: 8 }, (_value, index) => ({
|
|
214
|
+
title: `r${index}`,
|
|
215
|
+
url: `https://example.test/${index}`,
|
|
216
|
+
content: "x",
|
|
217
|
+
})),
|
|
218
|
+
}),
|
|
219
|
+
});
|
|
220
|
+
const prepared = await root.tools.prepare(
|
|
221
|
+
{
|
|
222
|
+
id: "c",
|
|
223
|
+
name: "web_search",
|
|
224
|
+
// The model asks for the contract's maximum; the User's ceiling wins.
|
|
225
|
+
input: { query: "q", max_results: 10 },
|
|
226
|
+
},
|
|
227
|
+
toolContext(),
|
|
228
|
+
);
|
|
229
|
+
if (prepared.kind !== "ready") throw new Error("not ready");
|
|
230
|
+
const result = await root.tools.executePrepared(prepared, toolContext());
|
|
231
|
+
// The ceiling is applied before the provider is asked, so the extra
|
|
232
|
+
// results are never fetched, let alone recorded on the Turn.
|
|
233
|
+
expect(JSON.parse(String(recorded[0]?.init?.body)).max_results).toBe(2);
|
|
234
|
+
expect(
|
|
235
|
+
(JSON.parse(result.content) as { results: unknown[] }).results.length,
|
|
236
|
+
).toBe(2);
|
|
237
|
+
await root.fiber.dispose();
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("leaves a request already under the ceiling alone", async () => {
|
|
241
|
+
const { root, recorded } = await mount({
|
|
242
|
+
maxResults: 8,
|
|
243
|
+
respond: () => Response.json({ results: [] }),
|
|
244
|
+
});
|
|
245
|
+
const prepared = await root.tools.prepare(
|
|
246
|
+
{ id: "c", name: "web_search", input: { query: "q", max_results: 3 } },
|
|
247
|
+
toolContext(),
|
|
248
|
+
);
|
|
249
|
+
if (prepared.kind !== "ready") throw new Error("not ready");
|
|
250
|
+
await root.tools.executePrepared(prepared, toolContext());
|
|
251
|
+
expect(JSON.parse(String(recorded[0]?.init?.body)).max_results).toBe(3);
|
|
252
|
+
await root.fiber.dispose();
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("refuses arguments outside the contract's bounds", async () => {
|
|
256
|
+
const { root } = await mount({
|
|
257
|
+
respond: () => Response.json({ results: [] }),
|
|
258
|
+
});
|
|
259
|
+
for (const input of [
|
|
260
|
+
{},
|
|
261
|
+
{ query: "" },
|
|
262
|
+
{ query: "q", max_results: 0 },
|
|
263
|
+
{ query: "q", max_results: 11 },
|
|
264
|
+
{ query: "q", max_results: 1.5 },
|
|
265
|
+
{ query: "x".repeat(401) },
|
|
266
|
+
]) {
|
|
267
|
+
const prepared = await root.tools.prepare(
|
|
268
|
+
{ id: "c", name: "web_search", input },
|
|
269
|
+
toolContext(),
|
|
270
|
+
);
|
|
271
|
+
expect({ input, kind: prepared.kind }).toEqual({ input, kind: "denied" });
|
|
272
|
+
}
|
|
273
|
+
await root.fiber.dispose();
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test("reports a revoked key as a visible tool error", async () => {
|
|
277
|
+
const { root, settled } = await mount({
|
|
278
|
+
respond: () =>
|
|
279
|
+
new Response(JSON.stringify({ error: "Unauthorized" }), {
|
|
280
|
+
status: 401,
|
|
281
|
+
headers: { "content-type": "application/json" },
|
|
282
|
+
}),
|
|
283
|
+
});
|
|
284
|
+
const prepared = await root.tools.prepare(
|
|
285
|
+
{ id: "c", name: "web_search", input: { query: "q" } },
|
|
286
|
+
toolContext(),
|
|
287
|
+
);
|
|
288
|
+
if (prepared.kind !== "ready") throw new Error("not ready");
|
|
289
|
+
const result = await root.tools.executePrepared(prepared, toolContext());
|
|
290
|
+
expect(result.isError).toBe(true);
|
|
291
|
+
const body = JSON.parse(result.content) as {
|
|
292
|
+
error: string;
|
|
293
|
+
message: string;
|
|
294
|
+
};
|
|
295
|
+
expect(body.error).toBe("web-search-failed");
|
|
296
|
+
expect(body.message).toContain("401");
|
|
297
|
+
// The lease is settled even when the call fails.
|
|
298
|
+
expect(settled).toEqual(["effect-1"]);
|
|
299
|
+
await root.fiber.dispose();
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test("refuses a provider answer larger than the response bound", async () => {
|
|
303
|
+
const { root } = await mount({
|
|
304
|
+
respond: () =>
|
|
305
|
+
Response.json({
|
|
306
|
+
results: [
|
|
307
|
+
{
|
|
308
|
+
title: "big",
|
|
309
|
+
url: "https://example.test/big",
|
|
310
|
+
content: "x".repeat(300 * 1024),
|
|
311
|
+
},
|
|
312
|
+
],
|
|
313
|
+
}),
|
|
314
|
+
});
|
|
315
|
+
const prepared = await root.tools.prepare(
|
|
316
|
+
{ id: "c", name: "web_search", input: { query: "q" } },
|
|
317
|
+
toolContext(),
|
|
318
|
+
);
|
|
319
|
+
if (prepared.kind !== "ready") throw new Error("not ready");
|
|
320
|
+
const result = await root.tools.executePrepared(prepared, toolContext());
|
|
321
|
+
expect(result.isError).toBe(true);
|
|
322
|
+
expect(result.content).toContain("too large");
|
|
323
|
+
await root.fiber.dispose();
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
test("is offered on every turn type the manifest admits", async () => {
|
|
327
|
+
const { root } = await mount({
|
|
328
|
+
respond: () => Response.json({ results: [] }),
|
|
329
|
+
});
|
|
330
|
+
for (const turnType of ["chat", "automation", "subagent"] as const) {
|
|
331
|
+
expect({
|
|
332
|
+
turnType,
|
|
333
|
+
names: root.tools.schemas({ turnType }).map((schema) => schema.name),
|
|
334
|
+
}).toEqual({ turnType, names: ["web_search"] });
|
|
335
|
+
}
|
|
336
|
+
await root.fiber.dispose();
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test("mounts nothing without an enabled Assignment bound to the Connection", () => {
|
|
340
|
+
const base = {
|
|
341
|
+
accountId: "user-1",
|
|
342
|
+
connectionId: CONNECTION_ID,
|
|
343
|
+
connectionGeneration: GENERATION,
|
|
344
|
+
leaseCredential: () => Promise.resolve(lease("e")),
|
|
345
|
+
settleCredential: () => Promise.resolve(),
|
|
346
|
+
};
|
|
347
|
+
expect(
|
|
348
|
+
createConfiguredOllamaWebSearchRuntimeContribution({
|
|
349
|
+
...base,
|
|
350
|
+
assignment: { ...ASSIGNMENT, state: "disabled" },
|
|
351
|
+
}),
|
|
352
|
+
).toBeUndefined();
|
|
353
|
+
expect(
|
|
354
|
+
createConfiguredOllamaWebSearchRuntimeContribution({
|
|
355
|
+
...base,
|
|
356
|
+
assignment: { ...ASSIGNMENT, capabilityId: "ollama-cloud-models" },
|
|
357
|
+
}),
|
|
358
|
+
).toBeUndefined();
|
|
359
|
+
expect(
|
|
360
|
+
createConfiguredOllamaWebSearchRuntimeContribution({
|
|
361
|
+
...base,
|
|
362
|
+
assignment: { ...ASSIGNMENT, connectionId: "other" },
|
|
363
|
+
}),
|
|
364
|
+
).toBeUndefined();
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test("bounds the provider response before it is parsed", async () => {
|
|
368
|
+
const client = new OllamaCloudWebSearchClient({
|
|
369
|
+
fetch: () =>
|
|
370
|
+
Promise.resolve(
|
|
371
|
+
new Response("{}", {
|
|
372
|
+
headers: {
|
|
373
|
+
"content-type": "application/json",
|
|
374
|
+
"content-length": String(512 * 1024),
|
|
375
|
+
},
|
|
376
|
+
}),
|
|
377
|
+
),
|
|
378
|
+
});
|
|
379
|
+
await expect(
|
|
380
|
+
client.search(API_KEY, { query: "q", maxResults: 5 }),
|
|
381
|
+
).rejects.toThrow("too large");
|
|
382
|
+
});
|
|
383
|
+
});
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
// Ollama Cloud's implementation of the provider-neutral `WebSearchV1`.
|
|
2
|
+
//
|
|
3
|
+
// WHY A NON-MODEL TOOL LIVES IN A MODEL PROVIDER PACKAGE. `/api/web_search`
|
|
4
|
+
// authenticates with the *same* key as `/api/chat` (measured, and recorded in
|
|
5
|
+
// `docs/research/ollama-cloud-auth.md`), and a credential is openable only by
|
|
6
|
+
// the Package that owns its Connection: `credentialLease.open` is called with
|
|
7
|
+
// this Package's id and the User Durable Object refuses any other. Putting the
|
|
8
|
+
// search transport anywhere else would mean either a second credential for the
|
|
9
|
+
// same account or a Package opening a Connection it does not own. The
|
|
10
|
+
// precedent is `plugin-composio`, whose tools are likewise a Connection-backed
|
|
11
|
+
// Capability. The tool's *contract* is not provider-specific: it lives in
|
|
12
|
+
// `@frockbot/plugin-web/contract`, and a second provider satisfies it with no
|
|
13
|
+
// change here and none in the kernel.
|
|
14
|
+
//
|
|
15
|
+
// AUTHORITY. `web_search` needs an enabled Assignment of the
|
|
16
|
+
// `ollama-cloud-web-search` Capability bound to a ready `ollama-cloud-account`
|
|
17
|
+
// Connection. Without one this module mounts nothing and the tool is simply
|
|
18
|
+
// absent from the catalog, which is what "fail visibly" means for a tool: the
|
|
19
|
+
// model is never offered a capability the Bot does not hold.
|
|
20
|
+
//
|
|
21
|
+
// EFFECT CLASS. Read-only, `idempotent: true`. A search records no intent and
|
|
22
|
+
// recovers by re-running.
|
|
23
|
+
import {
|
|
24
|
+
decodeWebSearchResponseV1,
|
|
25
|
+
createWebSearchToolDefinitionV1,
|
|
26
|
+
type WebSearchExecutionV1,
|
|
27
|
+
type WebSearchRequestV1,
|
|
28
|
+
type WebSearchResponseV1,
|
|
29
|
+
type WebSearchV1,
|
|
30
|
+
} from "@frockbot/plugin-web/contract";
|
|
31
|
+
import type { CredentialLeaseV1 } from "@frockbot/connection-core";
|
|
32
|
+
// The `credentialLease` service is declared once, by `./runtime.ts`; this
|
|
33
|
+
// module consumes that augmentation rather than restating it.
|
|
34
|
+
import type {} from "./runtime.js";
|
|
35
|
+
import type { Plugin } from "cordis";
|
|
36
|
+
import {
|
|
37
|
+
DEFAULT_OLLAMA_API_BASE_URL,
|
|
38
|
+
decodeOllamaApiBaseUrl,
|
|
39
|
+
type OllamaFetch,
|
|
40
|
+
} from "./client.js";
|
|
41
|
+
|
|
42
|
+
/** The provider answer is bounded before it is parsed, as chat's is. */
|
|
43
|
+
const MAX_SEARCH_RESPONSE_BYTES = 256 * 1024;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Compose the web-search endpoint from a Connection's endpoint root — the
|
|
47
|
+
* *same* resolved base chat uses, so a Connection pointed at a local Ollama or
|
|
48
|
+
* an Ollama-compatible host searches there too rather than silently reaching
|
|
49
|
+
* `https://ollama.com` with that host's key.
|
|
50
|
+
*/
|
|
51
|
+
export function ollamaWebSearchUrl(apiBaseUrl?: string): string {
|
|
52
|
+
return `${decodeOllamaApiBaseUrl(apiBaseUrl ?? DEFAULT_OLLAMA_API_BASE_URL)}/api/web_search`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function boundedJson(
|
|
56
|
+
response: Response,
|
|
57
|
+
maximum: number,
|
|
58
|
+
): Promise<unknown> {
|
|
59
|
+
const declaredLength = Number(response.headers.get("content-length"));
|
|
60
|
+
if (Number.isFinite(declaredLength) && declaredLength > maximum) {
|
|
61
|
+
throw new Error("Ollama Cloud web search response is too large");
|
|
62
|
+
}
|
|
63
|
+
const chunks: Uint8Array[] = [];
|
|
64
|
+
let length = 0;
|
|
65
|
+
const reader = response.body?.getReader();
|
|
66
|
+
if (reader) {
|
|
67
|
+
while (true) {
|
|
68
|
+
const chunk = await reader.read();
|
|
69
|
+
if (chunk.done) break;
|
|
70
|
+
length += chunk.value.byteLength;
|
|
71
|
+
if (length > maximum) {
|
|
72
|
+
await reader.cancel().catch(() => undefined);
|
|
73
|
+
throw new Error("Ollama Cloud web search response is too large");
|
|
74
|
+
}
|
|
75
|
+
chunks.push(chunk.value);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const bytes = new Uint8Array(length);
|
|
79
|
+
let offset = 0;
|
|
80
|
+
for (const chunk of chunks) {
|
|
81
|
+
bytes.set(chunk, offset);
|
|
82
|
+
offset += chunk.byteLength;
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
return JSON.parse(new TextDecoder().decode(bytes)) as unknown;
|
|
86
|
+
} catch {
|
|
87
|
+
throw new Error("Ollama Cloud web search returned invalid JSON");
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface OllamaWebSearchClientConfig {
|
|
92
|
+
apiBaseUrl?: string;
|
|
93
|
+
fetch?: OllamaFetch;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The transport, and nothing else: no authority, no credential of its own. */
|
|
97
|
+
export class OllamaCloudWebSearchClient {
|
|
98
|
+
private readonly endpoint: string;
|
|
99
|
+
private readonly fetcher: OllamaFetch;
|
|
100
|
+
|
|
101
|
+
constructor(config: OllamaWebSearchClientConfig = {}) {
|
|
102
|
+
this.endpoint = ollamaWebSearchUrl(config.apiBaseUrl);
|
|
103
|
+
// Workerd rejects a detached global `fetch`, so the default forwards.
|
|
104
|
+
this.fetcher =
|
|
105
|
+
config.fetch ?? ((input, init) => globalThis.fetch(input, init));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async search(
|
|
109
|
+
apiKey: string,
|
|
110
|
+
request: WebSearchRequestV1,
|
|
111
|
+
signal?: AbortSignal,
|
|
112
|
+
): Promise<WebSearchResponseV1> {
|
|
113
|
+
const response = await this.fetcher(this.endpoint, {
|
|
114
|
+
method: "POST",
|
|
115
|
+
headers: {
|
|
116
|
+
authorization: `Bearer ${apiKey}`,
|
|
117
|
+
"content-type": "application/json",
|
|
118
|
+
},
|
|
119
|
+
body: JSON.stringify({
|
|
120
|
+
query: request.query,
|
|
121
|
+
max_results: request.maxResults,
|
|
122
|
+
}),
|
|
123
|
+
...(signal ? { signal } : {}),
|
|
124
|
+
});
|
|
125
|
+
if (!response.ok) {
|
|
126
|
+
await response.body?.cancel().catch(() => undefined);
|
|
127
|
+
if (response.status === 401 || response.status === 403) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
"Ollama Cloud rejected the key for web search (HTTP " +
|
|
130
|
+
`${response.status})`,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
throw new Error(`Ollama Cloud web search failed (${response.status})`);
|
|
134
|
+
}
|
|
135
|
+
return decodeWebSearchResponseV1(
|
|
136
|
+
await boundedJson(response, MAX_SEARCH_RESPONSE_BYTES),
|
|
137
|
+
request,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
interface CredentialLeaseOpener {
|
|
143
|
+
open(input: {
|
|
144
|
+
accountId: string;
|
|
145
|
+
connectionId: string;
|
|
146
|
+
packageId: string;
|
|
147
|
+
lease: CredentialLeaseV1;
|
|
148
|
+
}): Promise<string>;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export interface OllamaWebSearchRuntimeConfig {
|
|
152
|
+
accountId: string;
|
|
153
|
+
connectionId: string;
|
|
154
|
+
connectionGeneration: string;
|
|
155
|
+
packageId: "provider-ollama-cloud";
|
|
156
|
+
/** The endpoint root carried on the Connection's own settings bag. */
|
|
157
|
+
apiBaseUrl?: string;
|
|
158
|
+
/**
|
|
159
|
+
* The Package-level `web-search-max-results` setting: the User's ceiling on
|
|
160
|
+
* how many results one search returns, whatever the model asked for. Absent
|
|
161
|
+
* leaves the model's own request — bounded by the contract — untouched.
|
|
162
|
+
*/
|
|
163
|
+
maxResults?: number;
|
|
164
|
+
leaseCredential(
|
|
165
|
+
effectId: string,
|
|
166
|
+
expectedGeneration?: string,
|
|
167
|
+
): Promise<CredentialLeaseV1>;
|
|
168
|
+
settleCredential(effectId: string): Promise<void>;
|
|
169
|
+
fetch?: OllamaFetch;
|
|
170
|
+
now?: () => number;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The credential is leased per tool call, keyed by the call's durable
|
|
175
|
+
* `effectId`, opened inside this Package, used, and settled — the same shape
|
|
176
|
+
* the model path uses. The key never reaches a tool argument, a tool result,
|
|
177
|
+
* or the event log.
|
|
178
|
+
*/
|
|
179
|
+
class ConnectionBackedWebSearch implements WebSearchV1 {
|
|
180
|
+
constructor(
|
|
181
|
+
private readonly config: OllamaWebSearchRuntimeConfig,
|
|
182
|
+
private readonly credentialLease: CredentialLeaseOpener,
|
|
183
|
+
private readonly client: OllamaCloudWebSearchClient,
|
|
184
|
+
) {}
|
|
185
|
+
|
|
186
|
+
/** The request, capped by the Package-level setting when the User set one. */
|
|
187
|
+
private bound(request: WebSearchRequestV1): WebSearchRequestV1 {
|
|
188
|
+
const ceiling = this.config.maxResults;
|
|
189
|
+
if (ceiling === undefined || request.maxResults <= ceiling) return request;
|
|
190
|
+
return { ...request, maxResults: ceiling };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async search(
|
|
194
|
+
request: WebSearchRequestV1,
|
|
195
|
+
execution: WebSearchExecutionV1,
|
|
196
|
+
): Promise<WebSearchResponseV1> {
|
|
197
|
+
const effectId = execution.effectId;
|
|
198
|
+
// The User's ceiling is applied before the provider is asked, so a model
|
|
199
|
+
// that requests more than the User allows never causes the extra results
|
|
200
|
+
// to be fetched, let alone recorded on the Turn.
|
|
201
|
+
const bounded = this.bound(request);
|
|
202
|
+
const lease = await this.config.leaseCredential(
|
|
203
|
+
effectId,
|
|
204
|
+
this.config.connectionGeneration,
|
|
205
|
+
);
|
|
206
|
+
try {
|
|
207
|
+
if (
|
|
208
|
+
lease.effectId !== effectId ||
|
|
209
|
+
lease.connectionId !== this.config.connectionId ||
|
|
210
|
+
lease.credentialGeneration !== this.config.connectionGeneration ||
|
|
211
|
+
Date.parse(lease.expiresAt) <= (this.config.now ?? Date.now)()
|
|
212
|
+
) {
|
|
213
|
+
throw new Error("Ollama Cloud credential lease is invalid");
|
|
214
|
+
}
|
|
215
|
+
const apiKey = await this.credentialLease.open({
|
|
216
|
+
accountId: this.config.accountId,
|
|
217
|
+
connectionId: this.config.connectionId,
|
|
218
|
+
packageId: this.config.packageId,
|
|
219
|
+
lease,
|
|
220
|
+
});
|
|
221
|
+
return await this.client.search(apiKey, bounded, execution.signal);
|
|
222
|
+
} finally {
|
|
223
|
+
await this.config.settleCredential(effectId).catch(() => undefined);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Mount `web_search` for one authorized Connection. */
|
|
229
|
+
export function createOllamaWebSearchRuntimePlugin(
|
|
230
|
+
config: OllamaWebSearchRuntimeConfig,
|
|
231
|
+
): Plugin.Function {
|
|
232
|
+
const plugin: Plugin.Function = (ctx) => {
|
|
233
|
+
const client = new OllamaCloudWebSearchClient({
|
|
234
|
+
...(config.apiBaseUrl === undefined
|
|
235
|
+
? {}
|
|
236
|
+
: { apiBaseUrl: config.apiBaseUrl }),
|
|
237
|
+
...(config.fetch ? { fetch: config.fetch } : {}),
|
|
238
|
+
});
|
|
239
|
+
const definition = createWebSearchToolDefinitionV1(
|
|
240
|
+
new ConnectionBackedWebSearch(config, ctx.credentialLease, client),
|
|
241
|
+
);
|
|
242
|
+
return ctx.tools.register(definition, {
|
|
243
|
+
admissionCeiling: ["chat", "automation", "subagent"],
|
|
244
|
+
subagentRoleCeiling: ["executor"],
|
|
245
|
+
});
|
|
246
|
+
};
|
|
247
|
+
plugin.inject = ["tools", "credentialLease"];
|
|
248
|
+
return plugin;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* The Assignment fence (D3): `web_search` exists for a Bot only through an
|
|
253
|
+
* enabled Assignment of `ollama-cloud-web-search` bound to a Connection. There
|
|
254
|
+
* is no unauthenticated fallback provider.
|
|
255
|
+
*/
|
|
256
|
+
export function createConfiguredOllamaWebSearchRuntimeContribution(config: {
|
|
257
|
+
assignment: {
|
|
258
|
+
packageId: string;
|
|
259
|
+
capabilityId: string;
|
|
260
|
+
connectionId?: string;
|
|
261
|
+
state: string;
|
|
262
|
+
};
|
|
263
|
+
accountId: string;
|
|
264
|
+
connectionId: string;
|
|
265
|
+
connectionGeneration: string;
|
|
266
|
+
apiBaseUrl?: string;
|
|
267
|
+
/** `web-search-max-results`, when this User set it on the Package. */
|
|
268
|
+
maxResults?: number;
|
|
269
|
+
leaseCredential(
|
|
270
|
+
effectId: string,
|
|
271
|
+
expectedGeneration?: string,
|
|
272
|
+
): Promise<CredentialLeaseV1>;
|
|
273
|
+
settleCredential(effectId: string): Promise<void>;
|
|
274
|
+
fetch?: OllamaFetch;
|
|
275
|
+
}): Plugin.Function | undefined {
|
|
276
|
+
if (
|
|
277
|
+
config.assignment.packageId !== "provider-ollama-cloud" ||
|
|
278
|
+
config.assignment.capabilityId !== "ollama-cloud-web-search" ||
|
|
279
|
+
config.assignment.state !== "enabled" ||
|
|
280
|
+
config.assignment.connectionId !== config.connectionId
|
|
281
|
+
) {
|
|
282
|
+
return undefined;
|
|
283
|
+
}
|
|
284
|
+
return createOllamaWebSearchRuntimePlugin({
|
|
285
|
+
accountId: config.accountId,
|
|
286
|
+
connectionId: config.connectionId,
|
|
287
|
+
connectionGeneration: config.connectionGeneration,
|
|
288
|
+
packageId: "provider-ollama-cloud",
|
|
289
|
+
...(config.apiBaseUrl === undefined
|
|
290
|
+
? {}
|
|
291
|
+
: { apiBaseUrl: config.apiBaseUrl }),
|
|
292
|
+
...(config.maxResults === undefined
|
|
293
|
+
? {}
|
|
294
|
+
: { maxResults: config.maxResults }),
|
|
295
|
+
leaseCredential: config.leaseCredential,
|
|
296
|
+
settleCredential: config.settleCredential,
|
|
297
|
+
...(config.fetch ? { fetch: config.fetch } : {}),
|
|
298
|
+
});
|
|
299
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2023",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "Bundler",
|
|
6
|
+
"allowImportingTsExtensions": true,
|
|
7
|
+
"resolveJsonModule": true,
|
|
8
|
+
"strict": true,
|
|
9
|
+
"noEmit": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"lib": ["ES2023", "DOM"],
|
|
12
|
+
"types": ["bun"]
|
|
13
|
+
},
|
|
14
|
+
"include": ["src/**/*.ts"]
|
|
15
|
+
}
|