@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,412 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
createCredentialUserBackendContribution,
|
|
4
|
+
type CredentialStorage,
|
|
5
|
+
type CredentialTransaction,
|
|
6
|
+
} from "@frockbot/plugin-credentials/user";
|
|
7
|
+
import {
|
|
8
|
+
createUserSettingsBackendContribution,
|
|
9
|
+
type UserSettingsStorage,
|
|
10
|
+
type UserSettingsTransaction,
|
|
11
|
+
} from "@frockbot/plugin-settings/user";
|
|
12
|
+
import manifest from "../frockbot.json" with { type: "json" };
|
|
13
|
+
import {
|
|
14
|
+
decodeOllamaApiBaseUrl,
|
|
15
|
+
DEFAULT_OLLAMA_API_BASE_URL,
|
|
16
|
+
OllamaCloudClient,
|
|
17
|
+
type OllamaFetch,
|
|
18
|
+
} from "./client.js";
|
|
19
|
+
import { ollamaChatBaseUrl } from "./runtime.js";
|
|
20
|
+
import {
|
|
21
|
+
createOllamaCloudUserBackendContribution,
|
|
22
|
+
type OllamaUserBackendHost,
|
|
23
|
+
} from "./user.js";
|
|
24
|
+
|
|
25
|
+
class MemoryStorage implements UserSettingsStorage, CredentialStorage {
|
|
26
|
+
readonly values = new Map<string, unknown>();
|
|
27
|
+
alarm?: number;
|
|
28
|
+
|
|
29
|
+
get<T>(key: string): Promise<T | undefined> {
|
|
30
|
+
return Promise.resolve(this.values.get(key) as T | undefined);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
put<T>(key: string, value: T): Promise<void>;
|
|
34
|
+
put(entries: Record<string, unknown>): Promise<void>;
|
|
35
|
+
put<T>(
|
|
36
|
+
keyOrEntries: string | Record<string, unknown>,
|
|
37
|
+
value?: T,
|
|
38
|
+
): Promise<void> {
|
|
39
|
+
if (typeof keyOrEntries === "string") {
|
|
40
|
+
this.values.set(keyOrEntries, value);
|
|
41
|
+
} else {
|
|
42
|
+
for (const [key, entry] of Object.entries(keyOrEntries)) {
|
|
43
|
+
this.values.set(key, entry);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return Promise.resolve();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
delete(key: string): Promise<boolean> {
|
|
50
|
+
return Promise.resolve(this.values.delete(key));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async transaction<T>(
|
|
54
|
+
callback: (
|
|
55
|
+
storage: UserSettingsTransaction & CredentialTransaction,
|
|
56
|
+
) => Promise<T>,
|
|
57
|
+
): Promise<T> {
|
|
58
|
+
const before = new Map(this.values);
|
|
59
|
+
try {
|
|
60
|
+
return await callback(this);
|
|
61
|
+
} catch (error) {
|
|
62
|
+
this.values.clear();
|
|
63
|
+
for (const [key, entry] of before) this.values.set(key, entry);
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
getAlarm(): Promise<number | null> {
|
|
69
|
+
return Promise.resolve(this.alarm ?? null);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
setAlarm(scheduledTime: number | Date): Promise<void> {
|
|
73
|
+
this.alarm = Number(scheduledTime);
|
|
74
|
+
return Promise.resolve();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
deleteAlarm(): Promise<void> {
|
|
78
|
+
this.alarm = undefined;
|
|
79
|
+
return Promise.resolve();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function keyring(): string {
|
|
84
|
+
const bytes = Uint8Array.from({ length: 32 }, (_, index) => index + 3);
|
|
85
|
+
let binary = "";
|
|
86
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
87
|
+
const key = btoa(binary)
|
|
88
|
+
.replaceAll("+", "-")
|
|
89
|
+
.replaceAll("/", "_")
|
|
90
|
+
.replace(/=+$/, "");
|
|
91
|
+
return JSON.stringify({
|
|
92
|
+
schemaVersion: 1,
|
|
93
|
+
currentKeyId: "primary",
|
|
94
|
+
keys: { primary: key },
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* A Contribution that builds its own client per Connection, so the URLs under
|
|
100
|
+
* test are the ones the Package composes rather than a host-supplied stub's.
|
|
101
|
+
*/
|
|
102
|
+
async function fixture(options: { reject?: (url: string) => boolean } = {}) {
|
|
103
|
+
const now = () => Date.parse("2026-08-30T00:00:00.000Z");
|
|
104
|
+
const storage = new MemoryStorage();
|
|
105
|
+
const settings = createUserSettingsBackendContribution({
|
|
106
|
+
storage,
|
|
107
|
+
availablePackages: [
|
|
108
|
+
{ packageId: "provider-ollama-cloud", version: "0.0.1" },
|
|
109
|
+
],
|
|
110
|
+
});
|
|
111
|
+
await settings.executeConfiguration({
|
|
112
|
+
schemaVersion: 1,
|
|
113
|
+
userId: "account-1",
|
|
114
|
+
command: {
|
|
115
|
+
schemaVersion: 1,
|
|
116
|
+
type: "user/install-package",
|
|
117
|
+
commandId: "install-1",
|
|
118
|
+
expectedRevision: 0,
|
|
119
|
+
packageId: "provider-ollama-cloud",
|
|
120
|
+
version: "0.0.1",
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
const credentials = createCredentialUserBackendContribution({
|
|
124
|
+
storage,
|
|
125
|
+
keyring: keyring(),
|
|
126
|
+
now,
|
|
127
|
+
});
|
|
128
|
+
const requests: string[] = [];
|
|
129
|
+
const fetch: OllamaFetch = (input) => {
|
|
130
|
+
const url = String(input);
|
|
131
|
+
requests.push(url);
|
|
132
|
+
if (options.reject?.(url)) {
|
|
133
|
+
return Promise.resolve(
|
|
134
|
+
Response.json({ error: "Unauthorized" }, { status: 401 }),
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
if (url.endsWith("/api/tags")) {
|
|
138
|
+
return Promise.resolve(
|
|
139
|
+
Response.json({ models: [{ model: "glm-5.3-flash:cloud" }] }),
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
if (url.endsWith("/api/chat")) {
|
|
143
|
+
return Promise.resolve(Response.json({ done_reason: "length" }));
|
|
144
|
+
}
|
|
145
|
+
return Promise.resolve(Response.json({ capabilities: ["tools"] }));
|
|
146
|
+
};
|
|
147
|
+
const configs: Array<string | undefined> = [];
|
|
148
|
+
let id = 0;
|
|
149
|
+
const ollama = createOllamaCloudUserBackendContribution({
|
|
150
|
+
storage,
|
|
151
|
+
settings,
|
|
152
|
+
credentials: credentials as unknown as OllamaUserBackendHost["credentials"],
|
|
153
|
+
createClient: (config) => {
|
|
154
|
+
configs.push(config.apiBaseUrl);
|
|
155
|
+
return new OllamaCloudClient({ ...config, fetch });
|
|
156
|
+
},
|
|
157
|
+
now,
|
|
158
|
+
randomId: () => `id-${++id}`,
|
|
159
|
+
});
|
|
160
|
+
return { storage, settings, ollama, requests, configs };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const createCommand = (
|
|
164
|
+
commandId: string,
|
|
165
|
+
settings?: Record<string, string>,
|
|
166
|
+
) => ({
|
|
167
|
+
schemaVersion: 1 as const,
|
|
168
|
+
type: "connection/create-api-key" as const,
|
|
169
|
+
commandId,
|
|
170
|
+
packageId: "provider-ollama-cloud",
|
|
171
|
+
connectionTypeId: "ollama-cloud-account",
|
|
172
|
+
label: "Work",
|
|
173
|
+
apiKey: "secret",
|
|
174
|
+
...(settings === undefined ? {} : { settings }),
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe("Ollama endpoint contract", () => {
|
|
178
|
+
test("declares the Connection-scoped endpoint setting in its manifest", () => {
|
|
179
|
+
// The endpoint belongs to the Connection, so the Connection Type declares
|
|
180
|
+
// it (manifest v4) rather than the Package. The kernel's own suite proves
|
|
181
|
+
// every first-party manifest decodes; this asserts what this one declares.
|
|
182
|
+
expect(manifest.schemaVersion).toBe(4);
|
|
183
|
+
expect(manifest.configuration.connectionTypes[0]?.settings).toMatchObject([
|
|
184
|
+
{ id: "api-base-url", schemaVersion: 1, schema: { type: "string" } },
|
|
185
|
+
]);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("declares the search ceiling as a User-level Package setting", () => {
|
|
189
|
+
// The ceiling belongs to the User, not to one Connection: it is the same
|
|
190
|
+
// answer whichever account the search runs through, so it is Package-level
|
|
191
|
+
// and every Connection of this Package obeys it.
|
|
192
|
+
expect(manifest.configuration.settings).toMatchObject([
|
|
193
|
+
{
|
|
194
|
+
id: "web-search-max-results",
|
|
195
|
+
schemaVersion: 1,
|
|
196
|
+
scopes: ["user"],
|
|
197
|
+
schema: { type: "integer", minimum: 1, maximum: 10 },
|
|
198
|
+
},
|
|
199
|
+
]);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("decodes an endpoint root and refuses an unusable one", () => {
|
|
203
|
+
expect(decodeOllamaApiBaseUrl("https://ollama.com/")).toBe(
|
|
204
|
+
"https://ollama.com",
|
|
205
|
+
);
|
|
206
|
+
expect(decodeOllamaApiBaseUrl("http://127.0.0.1:11434")).toBe(
|
|
207
|
+
"http://127.0.0.1:11434",
|
|
208
|
+
);
|
|
209
|
+
expect(decodeOllamaApiBaseUrl(" https://gpu.example.com/ollama/ ")).toBe(
|
|
210
|
+
"https://gpu.example.com/ollama",
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
expect(() => decodeOllamaApiBaseUrl("ollama.com")).toThrow(
|
|
214
|
+
"is not an absolute http or https URL",
|
|
215
|
+
);
|
|
216
|
+
expect(() => decodeOllamaApiBaseUrl("ftp://ollama.com")).toThrow(
|
|
217
|
+
"must use http or https",
|
|
218
|
+
);
|
|
219
|
+
expect(() =>
|
|
220
|
+
decodeOllamaApiBaseUrl("https://user:pass@ollama.com"),
|
|
221
|
+
).toThrow("must not carry credentials");
|
|
222
|
+
expect(() => decodeOllamaApiBaseUrl("https://ollama.com/?key=1")).toThrow(
|
|
223
|
+
"must not carry a query or fragment",
|
|
224
|
+
);
|
|
225
|
+
expect(() => decodeOllamaApiBaseUrl(11_434)).toThrow(
|
|
226
|
+
"must be an absolute http or https URL",
|
|
227
|
+
);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("defaults every seam to https://ollama.com", async () => {
|
|
231
|
+
expect(DEFAULT_OLLAMA_API_BASE_URL).toBe("https://ollama.com");
|
|
232
|
+
expect(ollamaChatBaseUrl()).toBe("https://ollama.com/v1");
|
|
233
|
+
|
|
234
|
+
const { ollama, settings, requests, storage } = await fixture();
|
|
235
|
+
const receipt = await ollama.executeConnection(
|
|
236
|
+
"account-1",
|
|
237
|
+
createCommand("connect-default"),
|
|
238
|
+
);
|
|
239
|
+
expect(receipt.status).toBe("applied");
|
|
240
|
+
expect(
|
|
241
|
+
requests.every((url) => url.startsWith("https://ollama.com/api/")),
|
|
242
|
+
).toBe(true);
|
|
243
|
+
expect(requests).toContain("https://ollama.com/api/tags");
|
|
244
|
+
expect(requests).toContain("https://ollama.com/api/chat");
|
|
245
|
+
|
|
246
|
+
const connection = await settings.getConnection(
|
|
247
|
+
"account-1",
|
|
248
|
+
receipt.connectionId,
|
|
249
|
+
);
|
|
250
|
+
expect(connection?.settings).toEqual({});
|
|
251
|
+
expect(
|
|
252
|
+
storage.values.get("ollama-connection-command:connect-default"),
|
|
253
|
+
).not.toHaveProperty("settings");
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("routes every provider call for a Connection through its endpoint", async () => {
|
|
257
|
+
const { ollama, settings, requests, configs } = await fixture();
|
|
258
|
+
const receipt = await ollama.executeConnection(
|
|
259
|
+
"account-1",
|
|
260
|
+
createCommand("connect-local", {
|
|
261
|
+
"api-base-url": "http://127.0.0.1:11434/",
|
|
262
|
+
}),
|
|
263
|
+
);
|
|
264
|
+
expect(receipt.status).toBe("applied");
|
|
265
|
+
|
|
266
|
+
// Creation: the catalog read and the inference probe both go local.
|
|
267
|
+
expect(requests).toContain("http://127.0.0.1:11434/api/tags");
|
|
268
|
+
expect(requests).toContain("http://127.0.0.1:11434/api/chat");
|
|
269
|
+
|
|
270
|
+
// The endpoint survives on the durable Connection, trailing slash stripped.
|
|
271
|
+
const connection = await settings.getConnection(
|
|
272
|
+
"account-1",
|
|
273
|
+
receipt.connectionId,
|
|
274
|
+
);
|
|
275
|
+
expect(connection?.settings).toEqual({
|
|
276
|
+
"api-base-url": "http://127.0.0.1:11434",
|
|
277
|
+
});
|
|
278
|
+
expect(connection?.state).toBe("ready");
|
|
279
|
+
|
|
280
|
+
// The periodic catalog refresh reads the same endpoint.
|
|
281
|
+
requests.length = 0;
|
|
282
|
+
await ollama.executeConnection("account-1", {
|
|
283
|
+
schemaVersion: 1,
|
|
284
|
+
type: "connection/refresh-models",
|
|
285
|
+
commandId: "refresh-local",
|
|
286
|
+
connectionId: receipt.connectionId,
|
|
287
|
+
});
|
|
288
|
+
expect(requests).toContain("http://127.0.0.1:11434/api/tags");
|
|
289
|
+
|
|
290
|
+
// Exact model resolution reads the same endpoint.
|
|
291
|
+
requests.length = 0;
|
|
292
|
+
if (!connection?.generation) throw new Error("generation is missing");
|
|
293
|
+
await ollama.leaseModelCredential({
|
|
294
|
+
accountId: "account-1",
|
|
295
|
+
connectionId: receipt.connectionId,
|
|
296
|
+
providerModelId: "unlisted-model:cloud",
|
|
297
|
+
effectId: "effect-local",
|
|
298
|
+
connectionGeneration: connection.generation,
|
|
299
|
+
});
|
|
300
|
+
expect(requests).toContain("http://127.0.0.1:11434/api/show");
|
|
301
|
+
|
|
302
|
+
// The chat completion root is composed from the same value.
|
|
303
|
+
expect(
|
|
304
|
+
ollamaChatBaseUrl(connection.settings?.["api-base-url"] as string),
|
|
305
|
+
).toBe("http://127.0.0.1:11434/v1");
|
|
306
|
+
|
|
307
|
+
// Not one call reached the Package default.
|
|
308
|
+
expect(configs.every((value) => value === "http://127.0.0.1:11434")).toBe(
|
|
309
|
+
true,
|
|
310
|
+
);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test("refuses an unusable endpoint visibly and calls no provider", async () => {
|
|
314
|
+
for (const [commandId, bad, reason] of [
|
|
315
|
+
[
|
|
316
|
+
"connect-relative",
|
|
317
|
+
{ "api-base-url": "/api" },
|
|
318
|
+
"absolute http or https",
|
|
319
|
+
],
|
|
320
|
+
[
|
|
321
|
+
"connect-scheme",
|
|
322
|
+
{ "api-base-url": "ftp://ollama.com" },
|
|
323
|
+
"http or https",
|
|
324
|
+
],
|
|
325
|
+
["connect-key", { region: "au" }, 'setting "region" is not supported'],
|
|
326
|
+
] as const) {
|
|
327
|
+
const { ollama, settings, requests } = await fixture();
|
|
328
|
+
const receipt = await ollama.executeConnection(
|
|
329
|
+
"account-1",
|
|
330
|
+
createCommand(commandId, bad),
|
|
331
|
+
);
|
|
332
|
+
|
|
333
|
+
expect(receipt.status).toBe("failed");
|
|
334
|
+
expect(requests).toHaveLength(0);
|
|
335
|
+
const connection = await settings.getConnection(
|
|
336
|
+
"account-1",
|
|
337
|
+
receipt.connectionId,
|
|
338
|
+
);
|
|
339
|
+
expect(connection?.state).toBe("failed");
|
|
340
|
+
expect(connection?.failure).toContain(reason);
|
|
341
|
+
expect(connection?.authorization?.credential.configured).toBe(false);
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
test("keeps the key probe authoritative against a configured endpoint", async () => {
|
|
346
|
+
const { ollama, settings, requests } = await fixture({
|
|
347
|
+
reject: (url) => url.endsWith("/api/chat"),
|
|
348
|
+
});
|
|
349
|
+
const receipt = await ollama.executeConnection(
|
|
350
|
+
"account-1",
|
|
351
|
+
createCommand("connect-bad-key", {
|
|
352
|
+
"api-base-url": "http://127.0.0.1:11434",
|
|
353
|
+
}),
|
|
354
|
+
);
|
|
355
|
+
|
|
356
|
+
expect(receipt.status).toBe("failed");
|
|
357
|
+
expect(requests).toContain("http://127.0.0.1:11434/api/chat");
|
|
358
|
+
const connection = await settings.getConnection(
|
|
359
|
+
"account-1",
|
|
360
|
+
receipt.connectionId,
|
|
361
|
+
);
|
|
362
|
+
expect(connection?.state).toBe("failed");
|
|
363
|
+
expect(connection?.failure).toStartWith(
|
|
364
|
+
"Ollama Cloud rejected the key for inference:",
|
|
365
|
+
);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
test("lets a host-supplied client win over per-Connection construction", async () => {
|
|
369
|
+
const seen: string[] = [];
|
|
370
|
+
const base = await fixture();
|
|
371
|
+
const ollama = createOllamaCloudUserBackendContribution({
|
|
372
|
+
storage: base.storage,
|
|
373
|
+
settings: base.settings,
|
|
374
|
+
credentials: createCredentialUserBackendContribution({
|
|
375
|
+
storage: base.storage,
|
|
376
|
+
keyring: keyring(),
|
|
377
|
+
now: () => Date.parse("2026-08-30T00:00:00.000Z"),
|
|
378
|
+
}) as unknown as OllamaUserBackendHost["credentials"],
|
|
379
|
+
client: new OllamaCloudClient({
|
|
380
|
+
apiBaseUrl: "https://stub.example.com",
|
|
381
|
+
fetch: (input) => {
|
|
382
|
+
seen.push(String(input));
|
|
383
|
+
return Promise.resolve(
|
|
384
|
+
String(input).endsWith("/api/tags")
|
|
385
|
+
? Response.json({ models: [{ model: "glm-5.3-flash:cloud" }] })
|
|
386
|
+
: Response.json({ capabilities: ["tools"] }),
|
|
387
|
+
);
|
|
388
|
+
},
|
|
389
|
+
}),
|
|
390
|
+
createClient: () => {
|
|
391
|
+
throw new Error("createClient must not be consulted");
|
|
392
|
+
},
|
|
393
|
+
now: () => Date.parse("2026-08-30T00:00:00.000Z"),
|
|
394
|
+
randomId: (() => {
|
|
395
|
+
let id = 100;
|
|
396
|
+
return () => `id-${++id}`;
|
|
397
|
+
})(),
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
const receipt = await ollama.executeConnection(
|
|
401
|
+
"account-1",
|
|
402
|
+
createCommand("connect-stub", {
|
|
403
|
+
"api-base-url": "http://127.0.0.1:11434",
|
|
404
|
+
}),
|
|
405
|
+
);
|
|
406
|
+
|
|
407
|
+
expect(receipt.status).toBe("applied");
|
|
408
|
+
expect(
|
|
409
|
+
seen.every((url) => url.startsWith("https://stub.example.com/")),
|
|
410
|
+
).toBe(true);
|
|
411
|
+
});
|
|
412
|
+
});
|
package/src/index.ts
ADDED
package/src/manifest.ts
ADDED