@frockbot/plugin-provider-ollama-cloud 0.0.0 → 0.1.1

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/src/client.ts ADDED
@@ -0,0 +1,357 @@
1
+ // Provider metadata is normalized at the shared Connection seam.
2
+ import {
3
+ decodeConnectionModelCatalogV1,
4
+ type ConnectionModelV1,
5
+ } from "@frockbot/connection-core";
6
+
7
+ export type OllamaFetch = (
8
+ input: string | URL | Request,
9
+ init?: RequestInit,
10
+ ) => Promise<Response>;
11
+
12
+ export interface OllamaCloudClientConfig {
13
+ /**
14
+ * Endpoint root, without the `/api` or `/v1` path segment: the Package
15
+ * default `https://ollama.com`, an Ollama-compatible host, or a local
16
+ * Ollama server such as `http://127.0.0.1:11434`.
17
+ */
18
+ apiBaseUrl?: string;
19
+ fetch?: OllamaFetch;
20
+ }
21
+
22
+ /** The endpoint every Connection uses until its User points it elsewhere. */
23
+ export const DEFAULT_OLLAMA_API_BASE_URL = "https://ollama.com";
24
+
25
+ const MAX_API_BASE_URL_LENGTH = 2048;
26
+
27
+ /**
28
+ * Decode a User-supplied Ollama endpoint root at its seam.
29
+ *
30
+ * An endpoint is an absolute `http:` or `https:` URL with no credentials, no
31
+ * query, and no fragment. The trailing slash is stripped so `${root}/api/tags`
32
+ * and `${root}/v1` compose without a doubled separator.
33
+ */
34
+ export function decodeOllamaApiBaseUrl(value: unknown): string {
35
+ if (
36
+ typeof value !== "string" ||
37
+ value.trim().length === 0 ||
38
+ value.length > MAX_API_BASE_URL_LENGTH
39
+ ) {
40
+ throw new Error(
41
+ "Ollama endpoint must be an absolute http or https URL, for example https://ollama.com",
42
+ );
43
+ }
44
+ const candidate = value.trim();
45
+ let parsed: URL;
46
+ try {
47
+ parsed = new URL(candidate);
48
+ } catch {
49
+ throw new Error(
50
+ `Ollama endpoint "${candidate}" is not an absolute http or https URL`,
51
+ );
52
+ }
53
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
54
+ throw new Error(
55
+ `Ollama endpoint "${candidate}" must use http or https, not ${parsed.protocol.replace(":", "")}`,
56
+ );
57
+ }
58
+ if (parsed.username || parsed.password) {
59
+ throw new Error("Ollama endpoint must not carry credentials");
60
+ }
61
+ if (parsed.search || parsed.hash) {
62
+ throw new Error("Ollama endpoint must not carry a query or fragment");
63
+ }
64
+ return `${parsed.origin}${parsed.pathname}`.replace(/\/+$/, "");
65
+ }
66
+
67
+ const MAX_CATALOG_RESPONSE_BYTES = 512 * 1024;
68
+ const MAX_MODEL_RESPONSE_BYTES = 256 * 1024;
69
+ const MAX_PROBE_RESPONSE_BYTES = 64 * 1024;
70
+ const MAX_PROBE_FAILURE_TEXT = 200;
71
+ // The smallest completion Ollama Cloud will produce: one predicted token.
72
+ const PROBE_PREDICTED_TOKENS = 1;
73
+ const MAX_CONNECTION_MODELS = 100;
74
+ const MODEL_LOOKUP_CONCURRENCY = 4;
75
+
76
+ function object(value: unknown, label: string): Record<string, unknown> {
77
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
78
+ throw new Error(`${label} is invalid`);
79
+ }
80
+ return value as Record<string, unknown>;
81
+ }
82
+
83
+ function positiveInteger(value: unknown): number | undefined {
84
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0
85
+ ? value
86
+ : undefined;
87
+ }
88
+
89
+ function modelId(value: unknown): string {
90
+ if (typeof value !== "string") {
91
+ throw new Error("Ollama Cloud model id is invalid");
92
+ }
93
+ const normalized = value.trim();
94
+ if (normalized.length === 0 || normalized.length > 256) {
95
+ throw new Error("Ollama Cloud model id is invalid");
96
+ }
97
+ return normalized;
98
+ }
99
+
100
+ async function boundedJson(
101
+ response: Response,
102
+ maximum: number,
103
+ ): Promise<unknown> {
104
+ const declaredLength = Number(response.headers.get("content-length"));
105
+ if (Number.isFinite(declaredLength) && declaredLength > maximum) {
106
+ throw new Error("Ollama Cloud response is too large");
107
+ }
108
+ const chunks: Uint8Array[] = [];
109
+ let length = 0;
110
+ const reader = response.body?.getReader();
111
+ if (reader) {
112
+ while (true) {
113
+ const chunk = await reader.read();
114
+ if (chunk.done) break;
115
+ length += chunk.value.byteLength;
116
+ if (length > maximum) {
117
+ await reader.cancel();
118
+ throw new Error("Ollama Cloud response is too large");
119
+ }
120
+ chunks.push(chunk.value);
121
+ }
122
+ }
123
+ const bytes = new Uint8Array(length);
124
+ let offset = 0;
125
+ for (const chunk of chunks) {
126
+ bytes.set(chunk, offset);
127
+ offset += chunk.byteLength;
128
+ }
129
+ try {
130
+ return JSON.parse(new TextDecoder().decode(bytes)) as unknown;
131
+ } catch {
132
+ throw new Error("Ollama Cloud returned invalid JSON");
133
+ }
134
+ }
135
+
136
+ async function boundedText(response: Response): Promise<string> {
137
+ const chunks: Uint8Array[] = [];
138
+ let length = 0;
139
+ const reader = response.body?.getReader();
140
+ if (reader) {
141
+ while (length <= MAX_PROBE_FAILURE_TEXT) {
142
+ const chunk = await reader.read();
143
+ if (chunk.done) break;
144
+ length += chunk.value.byteLength;
145
+ chunks.push(chunk.value);
146
+ }
147
+ await reader.cancel().catch(() => undefined);
148
+ }
149
+ const bytes = new Uint8Array(
150
+ chunks.reduce((total, c) => total + c.byteLength, 0),
151
+ );
152
+ let offset = 0;
153
+ for (const chunk of chunks) {
154
+ bytes.set(chunk, offset);
155
+ offset += chunk.byteLength;
156
+ }
157
+ const text = new TextDecoder().decode(bytes).slice(0, MAX_PROBE_FAILURE_TEXT);
158
+ try {
159
+ const payload = JSON.parse(text) as unknown;
160
+ if (payload && typeof payload === "object" && !Array.isArray(payload)) {
161
+ const reported = (payload as Record<string, unknown>).error;
162
+ if (typeof reported === "string" && reported.trim())
163
+ return reported.trim();
164
+ }
165
+ } catch {
166
+ // A non-JSON body is reported verbatim; the provider owes us no shape here.
167
+ }
168
+ return text.trim();
169
+ }
170
+
171
+ async function mapConcurrent<T, R>(
172
+ values: readonly T[],
173
+ concurrency: number,
174
+ transform: (value: T) => Promise<R>,
175
+ ): Promise<R[]> {
176
+ const results = new Array<R>(values.length);
177
+ let next = 0;
178
+ const worker = async () => {
179
+ while (next < values.length) {
180
+ const index = next;
181
+ next += 1;
182
+ results[index] = await transform(values[index] as T);
183
+ }
184
+ };
185
+ await Promise.all(
186
+ Array.from({ length: Math.min(concurrency, values.length) }, worker),
187
+ );
188
+ return results;
189
+ }
190
+
191
+ export class OllamaCloudClient {
192
+ private readonly apiBaseUrl: string;
193
+ private readonly fetcher: OllamaFetch;
194
+
195
+ constructor(config: OllamaCloudClientConfig = {}) {
196
+ this.apiBaseUrl = `${decodeOllamaApiBaseUrl(
197
+ config.apiBaseUrl ?? DEFAULT_OLLAMA_API_BASE_URL,
198
+ )}/api`;
199
+ // Workerd rejects a detached global `fetch` ("Illegal invocation"), so the
200
+ // default fetcher forwards through a closure rather than aliasing it.
201
+ this.fetcher =
202
+ config.fetch ?? ((input, init) => globalThis.fetch(input, init));
203
+ }
204
+
205
+ private async request(
206
+ path: string,
207
+ apiKey: string,
208
+ init: RequestInit,
209
+ ): Promise<unknown> {
210
+ const response = await this.fetcher(`${this.apiBaseUrl}${path}`, {
211
+ ...init,
212
+ headers: {
213
+ authorization: `Bearer ${apiKey}`,
214
+ "content-type": "application/json",
215
+ ...init.headers,
216
+ },
217
+ });
218
+ if (!response.ok) {
219
+ throw new Error(`Ollama Cloud request failed (${response.status})`);
220
+ }
221
+ return boundedJson(
222
+ response,
223
+ path === "/tags" ? MAX_CATALOG_RESPONSE_BYTES : MAX_MODEL_RESPONSE_BYTES,
224
+ );
225
+ }
226
+
227
+ async listModels(
228
+ apiKey: string,
229
+ signal?: AbortSignal,
230
+ ): Promise<ConnectionModelV1[]> {
231
+ const payload = object(
232
+ await this.request("/tags", apiKey, { method: "GET", signal }),
233
+ "Ollama Cloud model catalog",
234
+ );
235
+ if (!Array.isArray(payload.models)) {
236
+ throw new Error("Ollama Cloud model catalog is invalid");
237
+ }
238
+ const modelIds = new Set<string>();
239
+ for (const candidate of payload.models) {
240
+ const model = object(candidate, "Ollama Cloud model");
241
+ modelIds.add(modelId(model.model ?? model.name));
242
+ if (modelIds.size >= MAX_CONNECTION_MODELS) break;
243
+ }
244
+ return mapConcurrent([...modelIds], MODEL_LOOKUP_CONCURRENCY, (id) =>
245
+ this.resolveModel(apiKey, id, signal, "discovered"),
246
+ );
247
+ }
248
+
249
+ async resolveModel(
250
+ apiKey: string,
251
+ providerModelId: string,
252
+ signal?: AbortSignal,
253
+ source: ConnectionModelV1["source"] = "exact-resolution",
254
+ ): Promise<ConnectionModelV1> {
255
+ const normalizedProviderModelId = modelId(providerModelId);
256
+ const payload = object(
257
+ await this.request("/show", apiKey, {
258
+ method: "POST",
259
+ body: JSON.stringify({ model: normalizedProviderModelId }),
260
+ signal,
261
+ }),
262
+ "Ollama Cloud model details",
263
+ );
264
+ const capabilities = Array.isArray(payload.capabilities)
265
+ ? payload.capabilities.filter(
266
+ (candidate): candidate is string => typeof candidate === "string",
267
+ )
268
+ : [];
269
+ const modelInfo =
270
+ payload.model_info &&
271
+ typeof payload.model_info === "object" &&
272
+ !Array.isArray(payload.model_info)
273
+ ? (payload.model_info as Record<string, unknown>)
274
+ : {};
275
+ const contextWindow = Object.entries(modelInfo)
276
+ .filter(([key]) => key.endsWith(".context_length"))
277
+ .map(([, value]) => positiveInteger(value))
278
+ .find((value): value is number => value !== undefined);
279
+ const normalized = {
280
+ providerModelId: normalizedProviderModelId,
281
+ displayName: normalizedProviderModelId.replace(/:cloud$/, ""),
282
+ ...(contextWindow === undefined ? {} : { contextWindow }),
283
+ capabilities: {
284
+ tools: capabilities.includes("tools"),
285
+ vision: capabilities.includes("vision"),
286
+ reasoning:
287
+ capabilities.includes("thinking") ||
288
+ capabilities.includes("reasoning"),
289
+ },
290
+ source,
291
+ };
292
+ const validated = decodeConnectionModelCatalogV1({
293
+ schemaVersion: 1,
294
+ generation: "validation",
295
+ state: "fresh",
296
+ models: [normalized],
297
+ }).models[0];
298
+ if (!validated) throw new Error("Ollama Cloud model is invalid");
299
+ return validated;
300
+ }
301
+
302
+ /**
303
+ * Prove an API key is authorized for inference.
304
+ *
305
+ * Measured against https://ollama.com on 2026-08-31 and recorded in
306
+ * `docs/research/ollama-cloud-auth.md`: `GET /api/tags`, `GET /v1/models`,
307
+ * and `POST /api/show` answer 200 for a valid key, a garbage key, and no key
308
+ * at all, so no catalog read can validate a key. `POST /api/chat` does
309
+ * authenticate: 401 `{"error":"Unauthorized"}` for a bad or absent key, 200
310
+ * for a valid one. A one-token completion is the cheapest authenticated call
311
+ * (~70 tokens of usage, `done_reason: "length"`).
312
+ *
313
+ * The assistant content is never parsed or retained; only the shape of the
314
+ * response is confirmed.
315
+ */
316
+ async probeInference(
317
+ apiKey: string,
318
+ providerModelId: string,
319
+ signal?: AbortSignal,
320
+ ): Promise<void> {
321
+ const model = modelId(providerModelId);
322
+ const response = await this.fetcher(`${this.apiBaseUrl}/chat`, {
323
+ method: "POST",
324
+ signal,
325
+ headers: {
326
+ authorization: `Bearer ${apiKey}`,
327
+ "content-type": "application/json",
328
+ },
329
+ body: JSON.stringify({
330
+ model,
331
+ messages: [{ role: "user", content: "hi" }],
332
+ stream: false,
333
+ options: { num_predict: PROBE_PREDICTED_TOKENS },
334
+ }),
335
+ });
336
+ if (!response.ok) {
337
+ const reported = await boundedText(response);
338
+ if (response.status === 401 || response.status === 403) {
339
+ throw new Error(
340
+ `Ollama Cloud rejected the key for inference: ${reported || `HTTP ${response.status}`}`,
341
+ );
342
+ }
343
+ throw new Error(
344
+ `Ollama Cloud inference probe failed (${response.status})${reported ? `: ${reported}` : ""}`,
345
+ );
346
+ }
347
+ const payload = object(
348
+ await boundedJson(response, MAX_PROBE_RESPONSE_BYTES),
349
+ "Ollama Cloud inference probe",
350
+ );
351
+ if (typeof payload.error === "string") {
352
+ throw new Error(
353
+ `Ollama Cloud rejected the key for inference: ${payload.error.slice(0, MAX_PROBE_FAILURE_TEXT)}`,
354
+ );
355
+ }
356
+ }
357
+ }