@frockbot/plugin-provider-ollama-cloud 0.3.6 → 0.3.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +11 -11
- package/src/client.test.ts +31 -0
- package/src/client.ts +95 -26
- package/src/runtime.test.ts +70 -44
- package/src/runtime.ts +30 -8
- package/src/user.test.ts +52 -0
- package/src/user.ts +112 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/plugin-provider-ollama-cloud",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.7",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -20,19 +20,19 @@
|
|
|
20
20
|
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@frockbot/configuration-core": "0.3.
|
|
24
|
-
"@frockbot/connection-core": "0.3.
|
|
25
|
-
"@frockbot/kernel-agent-loop": "0.3.
|
|
26
|
-
"@frockbot/kernel-contracts": "0.3.
|
|
27
|
-
"@frockbot/plugin-credentials": "0.3.
|
|
28
|
-
"@frockbot/plugin-models": "0.3.
|
|
29
|
-
"@frockbot/plugin-settings": "0.3.
|
|
30
|
-
"@frockbot/plugin-web": "0.3.
|
|
31
|
-
"@frockbot/provider-openai-compatible": "0.3.
|
|
23
|
+
"@frockbot/configuration-core": "0.3.7",
|
|
24
|
+
"@frockbot/connection-core": "0.3.7",
|
|
25
|
+
"@frockbot/kernel-agent-loop": "0.3.7",
|
|
26
|
+
"@frockbot/kernel-contracts": "0.3.7",
|
|
27
|
+
"@frockbot/plugin-credentials": "0.3.7",
|
|
28
|
+
"@frockbot/plugin-models": "0.3.7",
|
|
29
|
+
"@frockbot/plugin-settings": "0.3.7",
|
|
30
|
+
"@frockbot/plugin-web": "0.3.7",
|
|
31
|
+
"@frockbot/provider-openai-compatible": "0.3.7",
|
|
32
32
|
"cordis": "4.0.0-rc.8"
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
|
35
|
-
"@frockbot/plugin-tools": "0.3.
|
|
35
|
+
"@frockbot/plugin-tools": "0.3.7",
|
|
36
36
|
"@types/bun": "1.3.6",
|
|
37
37
|
"typescript": "^7.0.2"
|
|
38
38
|
},
|
package/src/client.test.ts
CHANGED
|
@@ -137,4 +137,35 @@ describe("Ollama Cloud client", () => {
|
|
|
137
137
|
"Ollama Cloud request failed (401)",
|
|
138
138
|
);
|
|
139
139
|
});
|
|
140
|
+
test("bounds a provider that accepts the request and never answers", async () => {
|
|
141
|
+
const client = new OllamaCloudClient({
|
|
142
|
+
requestTimeoutMs: 20,
|
|
143
|
+
fetch: (_input, init) =>
|
|
144
|
+
new Promise((_resolve, reject) => {
|
|
145
|
+
init?.signal?.addEventListener("abort", () =>
|
|
146
|
+
reject(init.signal?.reason ?? new Error("aborted")),
|
|
147
|
+
);
|
|
148
|
+
}),
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
await expect(client.listModels("account-key")).rejects.toThrow(
|
|
152
|
+
"Ollama Cloud did not answer within 20ms",
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("bounds an inference probe that never answers", async () => {
|
|
157
|
+
const client = new OllamaCloudClient({
|
|
158
|
+
probeTimeoutMs: 20,
|
|
159
|
+
fetch: (_input, init) =>
|
|
160
|
+
new Promise((_resolve, reject) => {
|
|
161
|
+
init?.signal?.addEventListener("abort", () =>
|
|
162
|
+
reject(init.signal?.reason ?? new Error("aborted")),
|
|
163
|
+
);
|
|
164
|
+
}),
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
await expect(
|
|
168
|
+
client.probeInference("account-key", "glm-5.3-flash:cloud"),
|
|
169
|
+
).rejects.toThrow("Ollama Cloud did not answer within 20ms");
|
|
170
|
+
});
|
|
140
171
|
});
|
package/src/client.ts
CHANGED
|
@@ -9,6 +9,16 @@ export type OllamaFetch = (
|
|
|
9
9
|
init?: RequestInit,
|
|
10
10
|
) => Promise<Response>;
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Every provider call is bounded. A provider that accepts the connection and
|
|
14
|
+
* then never answers is the failure this defends against: without a deadline
|
|
15
|
+
* the connect command's promise never settles and the Connection sits in
|
|
16
|
+
* `authorizing` forever.
|
|
17
|
+
*/
|
|
18
|
+
export const DEFAULT_OLLAMA_REQUEST_TIMEOUT_MS = 15_000;
|
|
19
|
+
/** Inference is slower than a catalog read, so the probe gets its own budget. */
|
|
20
|
+
export const DEFAULT_OLLAMA_PROBE_TIMEOUT_MS = 30_000;
|
|
21
|
+
|
|
12
22
|
export interface OllamaCloudClientConfig {
|
|
13
23
|
/**
|
|
14
24
|
* Endpoint root, without the `/api` or `/v1` path segment: the Package
|
|
@@ -17,6 +27,25 @@ export interface OllamaCloudClientConfig {
|
|
|
17
27
|
*/
|
|
18
28
|
apiBaseUrl?: string;
|
|
19
29
|
fetch?: OllamaFetch;
|
|
30
|
+
/** Deadline for a catalog or model-detail read. */
|
|
31
|
+
requestTimeoutMs?: number;
|
|
32
|
+
/** Deadline for the authenticated inference probe. */
|
|
33
|
+
probeTimeoutMs?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Bound one provider call: the caller's signal still cancels it, and a deadline
|
|
38
|
+
* of its own settles it when the provider simply never answers.
|
|
39
|
+
*/
|
|
40
|
+
export function withDeadlineV1(
|
|
41
|
+
timeoutMs: number,
|
|
42
|
+
signal?: AbortSignal,
|
|
43
|
+
): { signal: AbortSignal; timedOut: () => boolean } {
|
|
44
|
+
const deadline = AbortSignal.timeout(timeoutMs);
|
|
45
|
+
return {
|
|
46
|
+
signal: signal ? AbortSignal.any([signal, deadline]) : deadline,
|
|
47
|
+
timedOut: () => deadline.aborted,
|
|
48
|
+
};
|
|
20
49
|
}
|
|
21
50
|
|
|
22
51
|
/** The endpoint every Connection uses until its User points it elsewhere. */
|
|
@@ -191,8 +220,14 @@ async function mapConcurrent<T, R>(
|
|
|
191
220
|
export class OllamaCloudClient {
|
|
192
221
|
private readonly apiBaseUrl: string;
|
|
193
222
|
private readonly fetcher: OllamaFetch;
|
|
223
|
+
private readonly requestTimeoutMs: number;
|
|
224
|
+
private readonly probeTimeoutMs: number;
|
|
194
225
|
|
|
195
226
|
constructor(config: OllamaCloudClientConfig = {}) {
|
|
227
|
+
this.requestTimeoutMs =
|
|
228
|
+
config.requestTimeoutMs ?? DEFAULT_OLLAMA_REQUEST_TIMEOUT_MS;
|
|
229
|
+
this.probeTimeoutMs =
|
|
230
|
+
config.probeTimeoutMs ?? DEFAULT_OLLAMA_PROBE_TIMEOUT_MS;
|
|
196
231
|
this.apiBaseUrl = `${decodeOllamaApiBaseUrl(
|
|
197
232
|
config.apiBaseUrl ?? DEFAULT_OLLAMA_API_BASE_URL,
|
|
198
233
|
)}/api`;
|
|
@@ -207,21 +242,45 @@ export class OllamaCloudClient {
|
|
|
207
242
|
apiKey: string,
|
|
208
243
|
init: RequestInit,
|
|
209
244
|
): Promise<unknown> {
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
}
|
|
217
|
-
|
|
245
|
+
const deadline = withDeadlineV1(
|
|
246
|
+
this.requestTimeoutMs,
|
|
247
|
+
init.signal ?? undefined,
|
|
248
|
+
);
|
|
249
|
+
let response: Response;
|
|
250
|
+
try {
|
|
251
|
+
response = await this.fetcher(`${this.apiBaseUrl}${path}`, {
|
|
252
|
+
...init,
|
|
253
|
+
signal: deadline.signal,
|
|
254
|
+
headers: {
|
|
255
|
+
authorization: `Bearer ${apiKey}`,
|
|
256
|
+
"content-type": "application/json",
|
|
257
|
+
...init.headers,
|
|
258
|
+
},
|
|
259
|
+
});
|
|
260
|
+
} catch (error) {
|
|
261
|
+
throw deadline.timedOut()
|
|
262
|
+
? new Error(
|
|
263
|
+
`Ollama Cloud did not answer within ${this.requestTimeoutMs}ms`,
|
|
264
|
+
)
|
|
265
|
+
: error;
|
|
266
|
+
}
|
|
218
267
|
if (!response.ok) {
|
|
219
268
|
throw new Error(`Ollama Cloud request failed (${response.status})`);
|
|
220
269
|
}
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
270
|
+
try {
|
|
271
|
+
return await boundedJson(
|
|
272
|
+
response,
|
|
273
|
+
path === "/tags"
|
|
274
|
+
? MAX_CATALOG_RESPONSE_BYTES
|
|
275
|
+
: MAX_MODEL_RESPONSE_BYTES,
|
|
276
|
+
);
|
|
277
|
+
} catch (error) {
|
|
278
|
+
throw deadline.timedOut()
|
|
279
|
+
? new Error(
|
|
280
|
+
`Ollama Cloud did not answer within ${this.requestTimeoutMs}ms`,
|
|
281
|
+
)
|
|
282
|
+
: error;
|
|
283
|
+
}
|
|
225
284
|
}
|
|
226
285
|
|
|
227
286
|
async listModels(
|
|
@@ -319,20 +378,30 @@ export class OllamaCloudClient {
|
|
|
319
378
|
signal?: AbortSignal,
|
|
320
379
|
): Promise<void> {
|
|
321
380
|
const model = modelId(providerModelId);
|
|
322
|
-
const
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
381
|
+
const deadline = withDeadlineV1(this.probeTimeoutMs, signal);
|
|
382
|
+
let response: Response;
|
|
383
|
+
try {
|
|
384
|
+
response = await this.fetcher(`${this.apiBaseUrl}/chat`, {
|
|
385
|
+
method: "POST",
|
|
386
|
+
signal: deadline.signal,
|
|
387
|
+
headers: {
|
|
388
|
+
authorization: `Bearer ${apiKey}`,
|
|
389
|
+
"content-type": "application/json",
|
|
390
|
+
},
|
|
391
|
+
body: JSON.stringify({
|
|
392
|
+
model,
|
|
393
|
+
messages: [{ role: "user", content: "hi" }],
|
|
394
|
+
stream: false,
|
|
395
|
+
options: { num_predict: PROBE_PREDICTED_TOKENS },
|
|
396
|
+
}),
|
|
397
|
+
});
|
|
398
|
+
} catch (error) {
|
|
399
|
+
throw deadline.timedOut()
|
|
400
|
+
? new Error(
|
|
401
|
+
`Ollama Cloud did not answer within ${this.probeTimeoutMs}ms`,
|
|
402
|
+
)
|
|
403
|
+
: error;
|
|
404
|
+
}
|
|
336
405
|
if (!response.ok) {
|
|
337
406
|
const reported = await boundedText(response);
|
|
338
407
|
if (response.status === 401 || response.status === 403) {
|
package/src/runtime.test.ts
CHANGED
|
@@ -11,7 +11,6 @@ import {
|
|
|
11
11
|
sealCredentialV1,
|
|
12
12
|
type CredentialLeaseV1,
|
|
13
13
|
} from "@frockbot/connection-core";
|
|
14
|
-
import { OpenAICompatibleHttpError } from "@frockbot/provider-openai-compatible";
|
|
15
14
|
import { Context, Service } from "cordis";
|
|
16
15
|
import {
|
|
17
16
|
createOllamaCloudRuntimePlugin,
|
|
@@ -458,61 +457,88 @@ describe("Ollama Cloud runtime Contribution", () => {
|
|
|
458
457
|
},
|
|
459
458
|
);
|
|
460
459
|
|
|
461
|
-
test(
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
460
|
+
test.each([408, 429, 500, 502])(
|
|
461
|
+
"settles pre-stream HTTP %i as not started rather than parking the Turn",
|
|
462
|
+
async (status) => {
|
|
463
|
+
const keyringText = serializedKeyring();
|
|
464
|
+
const envelope = await sealCredentialV1({
|
|
465
|
+
keyring: parseCredentialKeyringV1(keyringText),
|
|
466
|
+
context: {
|
|
467
|
+
accountId: "account-1",
|
|
468
|
+
connectionId: "connection-1",
|
|
469
|
+
packageId: "provider-ollama-cloud",
|
|
470
|
+
credentialGeneration: "generation-1",
|
|
471
|
+
},
|
|
472
|
+
plaintext: "account-secret",
|
|
473
|
+
});
|
|
474
|
+
const settled: string[] = [];
|
|
475
|
+
const root = new Context();
|
|
476
|
+
await root.plugin(LlmRegistry);
|
|
477
|
+
await mountCredentialRuntime(root, keyringText);
|
|
478
|
+
await root.plugin(
|
|
479
|
+
createOllamaCloudRuntimePlugin({
|
|
480
|
+
accountId: "account-1",
|
|
481
|
+
connectionId: "connection-1",
|
|
482
|
+
packageId: "provider-ollama-cloud",
|
|
483
|
+
now: () => Date.parse("2026-08-30T00:00:00.000Z"),
|
|
484
|
+
leaseCredential: (effectId) =>
|
|
485
|
+
Promise.resolve({
|
|
486
|
+
schemaVersion: 1,
|
|
487
|
+
leaseId: "lease-1",
|
|
488
|
+
effectId,
|
|
489
|
+
connectionId: "connection-1",
|
|
490
|
+
credentialGeneration: "generation-1",
|
|
491
|
+
expiresAt: "2026-08-30T01:00:00.000Z",
|
|
492
|
+
envelope,
|
|
493
|
+
}),
|
|
494
|
+
settleCredential: (effectId) => {
|
|
495
|
+
settled.push(effectId);
|
|
496
|
+
return Promise.resolve();
|
|
497
|
+
},
|
|
498
|
+
fetch: () => Promise.resolve(new Response("transient", { status })),
|
|
499
|
+
}),
|
|
500
|
+
);
|
|
501
|
+
|
|
502
|
+
let failure: unknown;
|
|
503
|
+
try {
|
|
504
|
+
for await (const _ of root.llm.stream(
|
|
505
|
+
request,
|
|
506
|
+
new AbortController().signal,
|
|
507
|
+
)) {
|
|
508
|
+
void _;
|
|
509
|
+
}
|
|
510
|
+
} catch (error) {
|
|
511
|
+
failure = error;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
expect(failure).toBeInstanceOf(LlmEffectNotStartedError);
|
|
515
|
+
expect(settled).toEqual([]);
|
|
516
|
+
await root.fiber.dispose();
|
|
517
|
+
},
|
|
518
|
+
);
|
|
519
|
+
|
|
520
|
+
test("reports an interrupted response as not retrievable so the run settles", async () => {
|
|
474
521
|
const root = new Context();
|
|
475
522
|
await root.plugin(LlmRegistry);
|
|
476
|
-
await mountCredentialRuntime(root,
|
|
523
|
+
await mountCredentialRuntime(root, serializedKeyring());
|
|
477
524
|
await root.plugin(
|
|
478
525
|
createOllamaCloudRuntimePlugin({
|
|
479
526
|
accountId: "account-1",
|
|
480
527
|
connectionId: "connection-1",
|
|
481
528
|
packageId: "provider-ollama-cloud",
|
|
482
529
|
now: () => Date.parse("2026-08-30T00:00:00.000Z"),
|
|
483
|
-
leaseCredential: (
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
leaseId: "lease-1",
|
|
487
|
-
effectId,
|
|
488
|
-
connectionId: "connection-1",
|
|
489
|
-
credentialGeneration: "generation-1",
|
|
490
|
-
expiresAt: "2026-08-30T01:00:00.000Z",
|
|
491
|
-
envelope,
|
|
492
|
-
}),
|
|
493
|
-
settleCredential: (effectId) => {
|
|
494
|
-
settled.push(effectId);
|
|
495
|
-
return Promise.resolve();
|
|
496
|
-
},
|
|
497
|
-
fetch: () => Promise.resolve(new Response("timeout", { status: 408 })),
|
|
530
|
+
leaseCredential: () => Promise.reject(new Error("unused")),
|
|
531
|
+
settleCredential: () => Promise.resolve(),
|
|
532
|
+
fetch: () => Promise.reject(new Error("unused")),
|
|
498
533
|
}),
|
|
499
534
|
);
|
|
500
535
|
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
new AbortController().signal,
|
|
506
|
-
)) {
|
|
507
|
-
void _;
|
|
508
|
-
}
|
|
509
|
-
} catch (error) {
|
|
510
|
-
failure = error;
|
|
511
|
-
}
|
|
536
|
+
const outcome = await root.llm.reconcile(
|
|
537
|
+
request,
|
|
538
|
+
new AbortController().signal,
|
|
539
|
+
);
|
|
512
540
|
|
|
513
|
-
expect(
|
|
514
|
-
expect(failure).not.toBeInstanceOf(LlmEffectNotStartedError);
|
|
515
|
-
expect(settled).toEqual([]);
|
|
541
|
+
expect(outcome.status).toBe("not-retrievable");
|
|
516
542
|
await root.fiber.dispose();
|
|
517
543
|
});
|
|
518
544
|
});
|
package/src/runtime.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
LlmEffectNotStartedError,
|
|
3
3
|
type LlmProvider,
|
|
4
|
+
type LlmReconciliationCapability,
|
|
4
5
|
type NormalizedModelRequest,
|
|
5
6
|
} from "@frockbot/kernel-contracts";
|
|
6
7
|
import { type Agent } from "@frockbot/kernel-agent-loop/agent";
|
|
@@ -143,6 +144,19 @@ class OllamaCloudProvider implements LlmProvider {
|
|
|
143
144
|
}
|
|
144
145
|
}
|
|
145
146
|
|
|
147
|
+
/**
|
|
148
|
+
* Ollama keeps no addressable copy of a completion, so an interrupted stream
|
|
149
|
+
* can never be read back. Saying so settles the run as a failure with its
|
|
150
|
+
* partial text intact instead of parking it forever.
|
|
151
|
+
*/
|
|
152
|
+
readonly reconciliation: LlmReconciliationCapability = {
|
|
153
|
+
retrieve: async () => ({
|
|
154
|
+
status: "not-retrievable",
|
|
155
|
+
reason:
|
|
156
|
+
"Ollama keeps no durable copy of an interrupted response, so it cannot be recovered",
|
|
157
|
+
}),
|
|
158
|
+
};
|
|
159
|
+
|
|
146
160
|
async *stream(request: NormalizedModelRequest, signal: AbortSignal) {
|
|
147
161
|
await this.authorize(request);
|
|
148
162
|
const authorization = this.authorized.get(request.requestId);
|
|
@@ -157,16 +171,24 @@ class OllamaCloudProvider implements LlmProvider {
|
|
|
157
171
|
providerId: this.id,
|
|
158
172
|
fetch: this.config.fetch,
|
|
159
173
|
});
|
|
174
|
+
// Every failure raised before the first stream event happened before a
|
|
175
|
+
// provider effect existed, so it is definitive rather than uncertain. A 429
|
|
176
|
+
// or a 502 reported as a bare failure would park the run on a retrieval
|
|
177
|
+
// this Package cannot perform; reported as "not started" it fails cleanly
|
|
178
|
+
// and can be retried.
|
|
179
|
+
let started = false;
|
|
160
180
|
try {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
error instanceof OpenAICompatibleHttpError &&
|
|
165
|
-
(error.status === 401 || error.status === 403 || error.status === 404)
|
|
166
|
-
) {
|
|
167
|
-
throw new LlmEffectNotStartedError(error.message);
|
|
181
|
+
for await (const event of provider.stream(request, signal)) {
|
|
182
|
+
started = true;
|
|
183
|
+
yield event;
|
|
168
184
|
}
|
|
169
|
-
|
|
185
|
+
} catch (error) {
|
|
186
|
+
if (started || signal.aborted) throw error;
|
|
187
|
+
throw new LlmEffectNotStartedError(
|
|
188
|
+
error instanceof OpenAICompatibleHttpError || error instanceof Error
|
|
189
|
+
? error.message
|
|
190
|
+
: "Ollama Cloud request did not reach the provider",
|
|
191
|
+
);
|
|
170
192
|
}
|
|
171
193
|
}
|
|
172
194
|
}
|
package/src/user.test.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
} from "@frockbot/plugin-settings/user";
|
|
12
12
|
import { OllamaCloudClient, type OllamaFetch } from "./client.js";
|
|
13
13
|
import {
|
|
14
|
+
catalogRetryDelayMsV1,
|
|
14
15
|
createOllamaCloudUserBackendContribution,
|
|
15
16
|
type OllamaUserBackendHost,
|
|
16
17
|
} from "./user.js";
|
|
@@ -176,6 +177,18 @@ async function fixture(
|
|
|
176
177
|
};
|
|
177
178
|
}
|
|
178
179
|
|
|
180
|
+
describe("catalog retry backoff", () => {
|
|
181
|
+
test("retries a failed refresh soon and backs off, capped at the interval", () => {
|
|
182
|
+
// A refresh that failed used to wait the full hour, so a thirty-second
|
|
183
|
+
// outage cost an hour of stale models.
|
|
184
|
+
expect(catalogRetryDelayMsV1(1)).toBe(60_000);
|
|
185
|
+
expect(catalogRetryDelayMsV1(2)).toBe(120_000);
|
|
186
|
+
expect(catalogRetryDelayMsV1(3)).toBe(240_000);
|
|
187
|
+
expect(catalogRetryDelayMsV1(20)).toBe(60 * 60 * 1_000);
|
|
188
|
+
expect(catalogRetryDelayMsV1(0)).toBe(60_000);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
179
192
|
describe("Ollama Cloud User Contribution", () => {
|
|
180
193
|
test("rejects malformed durable account, command, and pending records", async () => {
|
|
181
194
|
const accountFixture = await fixture();
|
|
@@ -464,6 +477,45 @@ describe("Ollama Cloud User Contribution", () => {
|
|
|
464
477
|
).toMatchObject({ displayName: "Recovered 2" });
|
|
465
478
|
});
|
|
466
479
|
|
|
480
|
+
test("abandons a Connection whose provider never answers", async () => {
|
|
481
|
+
// A provider that accepts the request and never responds: the connect
|
|
482
|
+
// command must not re-drive forever, and the Connection must not sit in
|
|
483
|
+
// `authorizing` where the User is shown nothing at all.
|
|
484
|
+
const { settings, ollama } = await fixture((_input, init) =>
|
|
485
|
+
Promise.reject(
|
|
486
|
+
init?.signal?.reason ?? new Error("provider never answered"),
|
|
487
|
+
),
|
|
488
|
+
);
|
|
489
|
+
const created = await ollama.executeConnection("account-1", {
|
|
490
|
+
schemaVersion: 1,
|
|
491
|
+
type: "connection/create-api-key",
|
|
492
|
+
commandId: "connect-hang",
|
|
493
|
+
packageId: "provider-ollama-cloud",
|
|
494
|
+
connectionTypeId: "ollama-cloud-account",
|
|
495
|
+
label: "Hang",
|
|
496
|
+
apiKey: "valid-key",
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
500
|
+
await ollama.alarm().catch(() => undefined);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const connection = await settings.getConnection(
|
|
504
|
+
"account-1",
|
|
505
|
+
created.connectionId,
|
|
506
|
+
);
|
|
507
|
+
expect(connection).toMatchObject({
|
|
508
|
+
state: "failed",
|
|
509
|
+
failure: expect.any(String),
|
|
510
|
+
});
|
|
511
|
+
expect(connection?.authorization).toMatchObject({
|
|
512
|
+
credential: { configured: false },
|
|
513
|
+
});
|
|
514
|
+
await expect(
|
|
515
|
+
ollama.lookupConnectionCommand("account-1", "connect-hang"),
|
|
516
|
+
).resolves.toMatchObject({ status: "failed" });
|
|
517
|
+
});
|
|
518
|
+
|
|
467
519
|
test("keeps the active generation when rotation validation fails", async () => {
|
|
468
520
|
const { settings, ollama, rejectCatalog } = await fixture();
|
|
469
521
|
const created = await ollama.executeConnection("account-1", {
|
package/src/user.ts
CHANGED
|
@@ -41,6 +41,13 @@ const MAX_COMMAND_TOMBSTONES = 128;
|
|
|
41
41
|
const MAX_MANUAL_COMMANDS = MAX_MANUAL_RECEIPTS + MAX_COMMAND_TOMBSTONES;
|
|
42
42
|
const MAX_PENDING_COMMANDS = 64;
|
|
43
43
|
const MAX_PENDING_RECOVERIES_PER_ALARM = 1;
|
|
44
|
+
/**
|
|
45
|
+
* How many alarm-driven attempts a pending Connection command gets before it is
|
|
46
|
+
* abandoned. Without a cap an endpoint that accepts the connection and never
|
|
47
|
+
* answers is re-driven once a minute forever and its Connection sits in
|
|
48
|
+
* `authorizing` with nothing to show the User.
|
|
49
|
+
*/
|
|
50
|
+
const MAX_PENDING_RECOVERY_ATTEMPTS = 3;
|
|
44
51
|
const MAX_CATALOG_REFRESHES_PER_ALARM = 1;
|
|
45
52
|
const ACCOUNT_KEY = "ollama-connection-account";
|
|
46
53
|
const AUTOMATIC_REFRESH_RECEIPT_PREFIX = "ollama-refresh-receipt:";
|
|
@@ -48,6 +55,36 @@ const AUTOMATIC_REFRESH_RECEIPT_PREFIX = "ollama-refresh-receipt:";
|
|
|
48
55
|
const MUTATION_SEQUENCE_PREFIX = "ollama-mutation-sequence:";
|
|
49
56
|
const MODEL_RESOLUTION_PREFIX = "ollama-model-resolution:";
|
|
50
57
|
const REFRESH_INTERVAL_MS = 60 * 60 * 1_000;
|
|
58
|
+
/**
|
|
59
|
+
* How soon a catalog refresh that failed is tried again.
|
|
60
|
+
*
|
|
61
|
+
* A refresh that failed used to wait the full hour, so an endpoint that was
|
|
62
|
+
* down for thirty seconds stayed stale for an hour. The delay doubles per
|
|
63
|
+
* consecutive failure up to the ordinary interval, so a transient outage
|
|
64
|
+
* recovers quickly and a lasting one does not hammer the provider.
|
|
65
|
+
*/
|
|
66
|
+
const CATALOG_RETRY_BASE_MS = 60_000;
|
|
67
|
+
const MAX_CATALOG_RETRY_ATTEMPTS = 8;
|
|
68
|
+
const CATALOG_RETRY_PREFIX = "ollama-catalog-retry:";
|
|
69
|
+
|
|
70
|
+
function catalogRetryKey(connectionId: string): string {
|
|
71
|
+
return `${CATALOG_RETRY_PREFIX}${connectionId}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Backoff for the nth consecutive catalog failure, capped at the interval. */
|
|
75
|
+
export function catalogRetryDelayMsV1(attempt: number): number {
|
|
76
|
+
const bounded = Math.min(Math.max(attempt, 1), MAX_CATALOG_RETRY_ATTEMPTS);
|
|
77
|
+
return Math.min(
|
|
78
|
+
CATALOG_RETRY_BASE_MS * 2 ** (bounded - 1),
|
|
79
|
+
REFRESH_INTERVAL_MS,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function catalogRetryAttempt(value: unknown): number {
|
|
84
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value > 0
|
|
85
|
+
? value
|
|
86
|
+
: 0;
|
|
87
|
+
}
|
|
51
88
|
const RECOVERY_DELAY_MS = 60_000;
|
|
52
89
|
const MODEL_LEASE_MS = 30 * 60 * 1_000;
|
|
53
90
|
const MAX_CONNECTION_MODELS = 100;
|
|
@@ -81,6 +118,8 @@ interface StoredCommand {
|
|
|
81
118
|
validationFailure?: string;
|
|
82
119
|
validationStatus?: "applied" | "failed";
|
|
83
120
|
providerRetryPolicy?: "safe-metadata-read";
|
|
121
|
+
/** Alarm-driven resume attempts so far; capped, so a command always settles. */
|
|
122
|
+
recoveryAttempts?: number;
|
|
84
123
|
}
|
|
85
124
|
|
|
86
125
|
interface StoredModelResolution {
|
|
@@ -379,6 +418,7 @@ function decodeStoredCommand(input: unknown): StoredCommand {
|
|
|
379
418
|
"validationFailure",
|
|
380
419
|
"validationStatus",
|
|
381
420
|
"providerRetryPolicy",
|
|
421
|
+
"recoveryAttempts",
|
|
382
422
|
],
|
|
383
423
|
);
|
|
384
424
|
const operations: StoredCommand["operation"][] = [
|
|
@@ -1566,6 +1606,7 @@ export class OllamaCloudUserBackendContribution {
|
|
|
1566
1606
|
},
|
|
1567
1607
|
storage,
|
|
1568
1608
|
);
|
|
1609
|
+
await storage.delete(catalogRetryKey(record.connectionId));
|
|
1569
1610
|
} else if (appliesProjection) {
|
|
1570
1611
|
const settings = await this.host.settings.readSnapshot(storage);
|
|
1571
1612
|
const current = settings.connections.find(
|
|
@@ -1581,6 +1622,15 @@ export class OllamaCloudUserBackendContribution {
|
|
|
1581
1622
|
current.state === "ready" &&
|
|
1582
1623
|
current.modelCatalog
|
|
1583
1624
|
) {
|
|
1625
|
+
const attempt =
|
|
1626
|
+
catalogRetryAttempt(
|
|
1627
|
+
await storage.get<unknown>(
|
|
1628
|
+
catalogRetryKey(record.connectionId),
|
|
1629
|
+
),
|
|
1630
|
+
) + 1;
|
|
1631
|
+
await storage.put({
|
|
1632
|
+
[catalogRetryKey(record.connectionId)]: attempt,
|
|
1633
|
+
});
|
|
1584
1634
|
await this.host.settings.replaceConnection(
|
|
1585
1635
|
record.accountId,
|
|
1586
1636
|
record.connectionId,
|
|
@@ -1591,7 +1641,7 @@ export class OllamaCloudUserBackendContribution {
|
|
|
1591
1641
|
...current.modelCatalog,
|
|
1592
1642
|
state: "stale",
|
|
1593
1643
|
refreshAfter: new Date(
|
|
1594
|
-
this.now() +
|
|
1644
|
+
this.now() + catalogRetryDelayMsV1(attempt),
|
|
1595
1645
|
).toISOString(),
|
|
1596
1646
|
failure:
|
|
1597
1647
|
outcomeFailure instanceof Error
|
|
@@ -1895,6 +1945,56 @@ export class OllamaCloudUserBackendContribution {
|
|
|
1895
1945
|
return this.finishRecord(record, terminalStatus);
|
|
1896
1946
|
}
|
|
1897
1947
|
|
|
1948
|
+
/**
|
|
1949
|
+
* Settle a command that has exhausted its attempts. The Connection it created
|
|
1950
|
+
* leaves `authorizing` for `failed` with a reason the User can act on, rather
|
|
1951
|
+
* than staying in a state that only a page reload even renders.
|
|
1952
|
+
*/
|
|
1953
|
+
private async abandonPendingCommand(record: StoredCommand): Promise<void> {
|
|
1954
|
+
const failure =
|
|
1955
|
+
"The provider did not respond. The connection attempt was abandoned.";
|
|
1956
|
+
const generation = record.credentialGeneration;
|
|
1957
|
+
if (generation) {
|
|
1958
|
+
await this.host.credentials
|
|
1959
|
+
.discardPending(record.connectionId, generation)
|
|
1960
|
+
.catch(() => undefined);
|
|
1961
|
+
}
|
|
1962
|
+
const current = await this.host.settings.getConnection(
|
|
1963
|
+
record.accountId,
|
|
1964
|
+
record.connectionId,
|
|
1965
|
+
);
|
|
1966
|
+
if (
|
|
1967
|
+
current &&
|
|
1968
|
+
current.packageId === PACKAGE_ID &&
|
|
1969
|
+
current.state === "authorizing"
|
|
1970
|
+
) {
|
|
1971
|
+
await this.host.settings.replaceConnection(
|
|
1972
|
+
record.accountId,
|
|
1973
|
+
record.connectionId,
|
|
1974
|
+
current.generation,
|
|
1975
|
+
{
|
|
1976
|
+
...current,
|
|
1977
|
+
state: "failed",
|
|
1978
|
+
authorization: {
|
|
1979
|
+
schemaVersion: 1,
|
|
1980
|
+
kind: "api-key",
|
|
1981
|
+
credential: {
|
|
1982
|
+
schemaVersion: 1,
|
|
1983
|
+
configured: false,
|
|
1984
|
+
source: "api-key",
|
|
1985
|
+
writable: true,
|
|
1986
|
+
},
|
|
1987
|
+
},
|
|
1988
|
+
failure,
|
|
1989
|
+
},
|
|
1990
|
+
);
|
|
1991
|
+
}
|
|
1992
|
+
await this.finishRecord(
|
|
1993
|
+
{ ...record, validationFailure: failure },
|
|
1994
|
+
"failed",
|
|
1995
|
+
);
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1898
1998
|
private async finishRecord(
|
|
1899
1999
|
record: StoredCommand,
|
|
1900
2000
|
status: ConnectionCommandReceiptV1["status"],
|
|
@@ -2307,7 +2407,17 @@ export class OllamaCloudUserBackendContribution {
|
|
|
2307
2407
|
);
|
|
2308
2408
|
if (recordValue === undefined) continue;
|
|
2309
2409
|
const record = decodeStoredCommand(recordValue);
|
|
2310
|
-
if (
|
|
2410
|
+
if (record.receipt) continue;
|
|
2411
|
+
const attempts = (record.recoveryAttempts ?? 0) + 1;
|
|
2412
|
+
if (attempts > MAX_PENDING_RECOVERY_ATTEMPTS) {
|
|
2413
|
+
await this.abandonPendingCommand(record);
|
|
2414
|
+
continue;
|
|
2415
|
+
}
|
|
2416
|
+
// Counted before the attempt, so an attempt that throws still counts and
|
|
2417
|
+
// the command cannot be re-driven indefinitely.
|
|
2418
|
+
const attempted = { ...record, recoveryAttempts: attempts };
|
|
2419
|
+
await this.host.storage.put({ [commandKey(commandId)]: attempted });
|
|
2420
|
+
await this.resumeOnce(attempted);
|
|
2311
2421
|
}
|
|
2312
2422
|
const accountValue = await this.host.storage.get<unknown>(ACCOUNT_KEY);
|
|
2313
2423
|
if (accountValue === undefined) return;
|