@frockbot/plugin-provider-ollama-cloud 0.3.6 → 0.3.8
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 +339 -39
- package/src/runtime.ts +43 -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.8",
|
|
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.8",
|
|
24
|
+
"@frockbot/connection-core": "0.3.8",
|
|
25
|
+
"@frockbot/kernel-agent-loop": "0.3.8",
|
|
26
|
+
"@frockbot/kernel-contracts": "0.3.8",
|
|
27
|
+
"@frockbot/plugin-credentials": "0.3.8",
|
|
28
|
+
"@frockbot/plugin-models": "0.3.8",
|
|
29
|
+
"@frockbot/plugin-settings": "0.3.8",
|
|
30
|
+
"@frockbot/plugin-web": "0.3.8",
|
|
31
|
+
"@frockbot/provider-openai-compatible": "0.3.8",
|
|
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.8",
|
|
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
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import {
|
|
3
3
|
LlmEffectNotStartedError,
|
|
4
|
+
MODEL_FIRST_BYTE_DEADLINE_MS_V1,
|
|
5
|
+
MODEL_FIRST_BYTE_DEADLINE_REASON_V1,
|
|
6
|
+
MODEL_IDLE_DEADLINE_MS_V1,
|
|
7
|
+
MODEL_IDLE_DEADLINE_REASON_V1,
|
|
8
|
+
ModelRequestDeadlineError,
|
|
4
9
|
type NormalizedModelRequest,
|
|
5
10
|
} from "@frockbot/kernel-contracts";
|
|
6
11
|
import { type Agent } from "@frockbot/kernel-agent-loop/agent";
|
|
@@ -11,7 +16,6 @@ import {
|
|
|
11
16
|
sealCredentialV1,
|
|
12
17
|
type CredentialLeaseV1,
|
|
13
18
|
} from "@frockbot/connection-core";
|
|
14
|
-
import { OpenAICompatibleHttpError } from "@frockbot/provider-openai-compatible";
|
|
15
19
|
import { Context, Service } from "cordis";
|
|
16
20
|
import {
|
|
17
21
|
createOllamaCloudRuntimePlugin,
|
|
@@ -458,61 +462,357 @@ describe("Ollama Cloud runtime Contribution", () => {
|
|
|
458
462
|
},
|
|
459
463
|
);
|
|
460
464
|
|
|
461
|
-
test(
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
465
|
+
test.each([408, 429, 500, 502])(
|
|
466
|
+
"settles pre-stream HTTP %i as not started rather than parking the Turn",
|
|
467
|
+
async (status) => {
|
|
468
|
+
const keyringText = serializedKeyring();
|
|
469
|
+
const envelope = await sealCredentialV1({
|
|
470
|
+
keyring: parseCredentialKeyringV1(keyringText),
|
|
471
|
+
context: {
|
|
472
|
+
accountId: "account-1",
|
|
473
|
+
connectionId: "connection-1",
|
|
474
|
+
packageId: "provider-ollama-cloud",
|
|
475
|
+
credentialGeneration: "generation-1",
|
|
476
|
+
},
|
|
477
|
+
plaintext: "account-secret",
|
|
478
|
+
});
|
|
479
|
+
const settled: string[] = [];
|
|
480
|
+
const root = new Context();
|
|
481
|
+
await root.plugin(LlmRegistry);
|
|
482
|
+
await mountCredentialRuntime(root, keyringText);
|
|
483
|
+
await root.plugin(
|
|
484
|
+
createOllamaCloudRuntimePlugin({
|
|
485
|
+
accountId: "account-1",
|
|
486
|
+
connectionId: "connection-1",
|
|
487
|
+
packageId: "provider-ollama-cloud",
|
|
488
|
+
now: () => Date.parse("2026-08-30T00:00:00.000Z"),
|
|
489
|
+
leaseCredential: (effectId) =>
|
|
490
|
+
Promise.resolve({
|
|
491
|
+
schemaVersion: 1,
|
|
492
|
+
leaseId: "lease-1",
|
|
493
|
+
effectId,
|
|
494
|
+
connectionId: "connection-1",
|
|
495
|
+
credentialGeneration: "generation-1",
|
|
496
|
+
expiresAt: "2026-08-30T01:00:00.000Z",
|
|
497
|
+
envelope,
|
|
498
|
+
}),
|
|
499
|
+
settleCredential: (effectId) => {
|
|
500
|
+
settled.push(effectId);
|
|
501
|
+
return Promise.resolve();
|
|
502
|
+
},
|
|
503
|
+
fetch: () => Promise.resolve(new Response("transient", { status })),
|
|
504
|
+
}),
|
|
505
|
+
);
|
|
506
|
+
|
|
507
|
+
let failure: unknown;
|
|
508
|
+
try {
|
|
509
|
+
for await (const _ of root.llm.stream(
|
|
510
|
+
request,
|
|
511
|
+
new AbortController().signal,
|
|
512
|
+
)) {
|
|
513
|
+
void _;
|
|
514
|
+
}
|
|
515
|
+
} catch (error) {
|
|
516
|
+
failure = error;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
expect(failure).toBeInstanceOf(LlmEffectNotStartedError);
|
|
520
|
+
expect(settled).toEqual([]);
|
|
521
|
+
await root.fiber.dispose();
|
|
522
|
+
},
|
|
523
|
+
);
|
|
524
|
+
|
|
525
|
+
test("reports an interrupted response as not retrievable so the run settles", async () => {
|
|
474
526
|
const root = new Context();
|
|
475
527
|
await root.plugin(LlmRegistry);
|
|
476
|
-
await mountCredentialRuntime(root,
|
|
528
|
+
await mountCredentialRuntime(root, serializedKeyring());
|
|
477
529
|
await root.plugin(
|
|
478
530
|
createOllamaCloudRuntimePlugin({
|
|
479
531
|
accountId: "account-1",
|
|
480
532
|
connectionId: "connection-1",
|
|
481
533
|
packageId: "provider-ollama-cloud",
|
|
482
534
|
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 })),
|
|
535
|
+
leaseCredential: () => Promise.reject(new Error("unused")),
|
|
536
|
+
settleCredential: () => Promise.resolve(),
|
|
537
|
+
fetch: () => Promise.reject(new Error("unused")),
|
|
498
538
|
}),
|
|
499
539
|
);
|
|
500
540
|
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
541
|
+
const outcome = await root.llm.reconcile(
|
|
542
|
+
request,
|
|
543
|
+
new AbortController().signal,
|
|
544
|
+
);
|
|
545
|
+
|
|
546
|
+
expect(outcome.status).toBe("not-retrievable");
|
|
547
|
+
await root.fiber.dispose();
|
|
548
|
+
});
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
/** A clock the test advances by hand, so a deadline costs no real seconds. */
|
|
552
|
+
function manualClock() {
|
|
553
|
+
const pending = new Map<number, { run: () => void; due: number }>();
|
|
554
|
+
let next = 1;
|
|
555
|
+
let now = 0;
|
|
556
|
+
return {
|
|
557
|
+
schedule(run: () => void, milliseconds: number): () => void {
|
|
558
|
+
const id = next++;
|
|
559
|
+
pending.set(id, { run, due: now + milliseconds });
|
|
560
|
+
return () => pending.delete(id);
|
|
561
|
+
},
|
|
562
|
+
advance(milliseconds: number): void {
|
|
563
|
+
now += milliseconds;
|
|
564
|
+
for (const [id, timer] of [...pending]) {
|
|
565
|
+
if (timer.due <= now) {
|
|
566
|
+
pending.delete(id);
|
|
567
|
+
timer.run();
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
},
|
|
571
|
+
get armed(): number {
|
|
572
|
+
return pending.size;
|
|
573
|
+
},
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/** Let the stream's own pump run: the clock is manual, the event loop is not. */
|
|
578
|
+
async function settle(): Promise<void> {
|
|
579
|
+
for (let tick = 0; tick < 10; tick += 1) {
|
|
580
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/** An endpoint body the test feeds one chunk at a time. */
|
|
585
|
+
function pushableSse(): {
|
|
586
|
+
body: ReadableStream<Uint8Array>;
|
|
587
|
+
push: (text: string) => void;
|
|
588
|
+
} {
|
|
589
|
+
let enqueue: ((text: string) => void) | undefined;
|
|
590
|
+
const body = new ReadableStream<Uint8Array>({
|
|
591
|
+
start(controller) {
|
|
592
|
+
enqueue = (text) => controller.enqueue(new TextEncoder().encode(text));
|
|
593
|
+
},
|
|
594
|
+
});
|
|
595
|
+
return { body, push: (text) => enqueue?.(text) };
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/** Mount the Package against `fetch`, with its deadlines on a manual clock. */
|
|
599
|
+
async function mountWithClock(
|
|
600
|
+
root: Context,
|
|
601
|
+
clock: ReturnType<typeof manualClock>,
|
|
602
|
+
fetch: (
|
|
603
|
+
input: string | URL | Request,
|
|
604
|
+
init?: RequestInit,
|
|
605
|
+
) => Promise<Response>,
|
|
606
|
+
): Promise<void> {
|
|
607
|
+
const keyringText = serializedKeyring();
|
|
608
|
+
const envelope = await sealCredentialV1({
|
|
609
|
+
keyring: parseCredentialKeyringV1(keyringText),
|
|
610
|
+
context: {
|
|
611
|
+
accountId: "account-1",
|
|
612
|
+
connectionId: "connection-1",
|
|
613
|
+
packageId: "provider-ollama-cloud",
|
|
614
|
+
credentialGeneration: "generation-1",
|
|
615
|
+
},
|
|
616
|
+
plaintext: "account-secret",
|
|
617
|
+
});
|
|
618
|
+
await root.plugin(LlmRegistry);
|
|
619
|
+
await mountCredentialRuntime(root, keyringText);
|
|
620
|
+
await root.plugin(
|
|
621
|
+
createOllamaCloudRuntimePlugin({
|
|
622
|
+
accountId: "account-1",
|
|
623
|
+
connectionId: "connection-1",
|
|
624
|
+
packageId: "provider-ollama-cloud",
|
|
625
|
+
now: () => Date.parse("2026-08-30T00:00:00.000Z"),
|
|
626
|
+
leaseCredential: (effectId) =>
|
|
627
|
+
Promise.resolve({
|
|
628
|
+
schemaVersion: 1,
|
|
629
|
+
leaseId: "lease-1",
|
|
630
|
+
effectId,
|
|
631
|
+
connectionId: "connection-1",
|
|
632
|
+
credentialGeneration: "generation-1",
|
|
633
|
+
expiresAt: "2026-08-30T01:00:00.000Z",
|
|
634
|
+
envelope,
|
|
635
|
+
}),
|
|
636
|
+
settleCredential: () => Promise.resolve(),
|
|
637
|
+
fetch,
|
|
638
|
+
deadlines: { schedule: clock.schedule },
|
|
639
|
+
}),
|
|
640
|
+
);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// A Stop must end one request and nothing else. The provider is registered once
|
|
644
|
+
// and serves every Turn, and it keeps a per-request credential map, so a
|
|
645
|
+
// cancelled request that tore down anything shared would take the next Turn
|
|
646
|
+
// with it.
|
|
647
|
+
describe("Ollama Cloud request isolation", () => {
|
|
648
|
+
test("leaves the next request working after one is cancelled", async () => {
|
|
649
|
+
const clock = manualClock();
|
|
650
|
+
const { body } = pushableSse();
|
|
651
|
+
let calls = 0;
|
|
652
|
+
const root = new Context();
|
|
653
|
+
await mountWithClock(root, clock, () => {
|
|
654
|
+
calls += 1;
|
|
655
|
+
return Promise.resolve(
|
|
656
|
+
calls === 1
|
|
657
|
+
? new Response(body, { status: 200 })
|
|
658
|
+
: new Response(
|
|
659
|
+
'data: {"choices":[{"delta":{"content":"second"},"finish_reason":"stop"}]}\n\n' +
|
|
660
|
+
"data: [DONE]\n\n",
|
|
661
|
+
{ status: 200 },
|
|
662
|
+
),
|
|
663
|
+
);
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
const cancelled = new AbortController();
|
|
667
|
+
const abandoned = (async () => {
|
|
668
|
+
for await (const event of root.llm.stream(request, cancelled.signal)) {
|
|
669
|
+
void event;
|
|
670
|
+
}
|
|
671
|
+
})().then(
|
|
672
|
+
() => undefined,
|
|
673
|
+
(error: unknown) => error,
|
|
674
|
+
);
|
|
675
|
+
await settle();
|
|
676
|
+
cancelled.abort(new Error("Turn cancelled"));
|
|
677
|
+
expect((await abandoned) as Error).toBeInstanceOf(Error);
|
|
678
|
+
|
|
679
|
+
const events: unknown[] = [];
|
|
680
|
+
for await (const event of root.llm.stream(
|
|
681
|
+
{ ...request, requestId: "effect-2" },
|
|
682
|
+
new AbortController().signal,
|
|
683
|
+
)) {
|
|
684
|
+
events.push(event);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
expect(events).toEqual([
|
|
688
|
+
{ type: "text-delta", text: "second" },
|
|
689
|
+
{ type: "finish", reason: "completed" },
|
|
690
|
+
]);
|
|
691
|
+
await root.fiber.dispose();
|
|
692
|
+
});
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
// An endpoint that accepts the request and then says nothing used to be bounded
|
|
696
|
+
// only by the fifteen-minute Turn deadline: an empty bubble for a quarter of an
|
|
697
|
+
// hour, saying nothing about why.
|
|
698
|
+
describe("Ollama Cloud deadlines", () => {
|
|
699
|
+
test("fails the step when the endpoint produces no first byte", async () => {
|
|
700
|
+
const clock = manualClock();
|
|
701
|
+
const root = new Context();
|
|
702
|
+
await mountWithClock(
|
|
703
|
+
root,
|
|
704
|
+
clock,
|
|
705
|
+
(_input, init) =>
|
|
706
|
+
new Promise((_resolve, reject) => {
|
|
707
|
+
init?.signal?.addEventListener("abort", () =>
|
|
708
|
+
reject(new Error("aborted")),
|
|
709
|
+
);
|
|
710
|
+
}),
|
|
711
|
+
);
|
|
712
|
+
|
|
713
|
+
const outcome = (async () => {
|
|
714
|
+
for await (const event of root.llm.stream(
|
|
504
715
|
request,
|
|
505
716
|
new AbortController().signal,
|
|
506
717
|
)) {
|
|
507
|
-
void
|
|
718
|
+
void event;
|
|
508
719
|
}
|
|
509
|
-
}
|
|
510
|
-
|
|
720
|
+
})().then(
|
|
721
|
+
() => undefined,
|
|
722
|
+
(error: unknown) => error,
|
|
723
|
+
);
|
|
724
|
+
await settle();
|
|
725
|
+
clock.advance(MODEL_FIRST_BYTE_DEADLINE_MS_V1);
|
|
726
|
+
|
|
727
|
+
const failure = await outcome;
|
|
728
|
+
// Reported as "not started", not as a bare deadline: nothing was streamed,
|
|
729
|
+
// so no provider effect exists, and this Package classifies every failure
|
|
730
|
+
// before the first event as definitive. That matters more than the class
|
|
731
|
+
// name — a deadline reported as uncertain would park the run on a
|
|
732
|
+
// retrieval nobody can perform. The reason still reaches the person.
|
|
733
|
+
expect(failure).toBeInstanceOf(LlmEffectNotStartedError);
|
|
734
|
+
expect((failure as Error).message).toBe(
|
|
735
|
+
MODEL_FIRST_BYTE_DEADLINE_REASON_V1,
|
|
736
|
+
);
|
|
737
|
+
await root.fiber.dispose();
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
test("fails the step when the endpoint starts an answer and then stalls", async () => {
|
|
741
|
+
const clock = manualClock();
|
|
742
|
+
const { body, push } = pushableSse();
|
|
743
|
+
const root = new Context();
|
|
744
|
+
await mountWithClock(root, clock, () =>
|
|
745
|
+
Promise.resolve(new Response(body, { status: 200 })),
|
|
746
|
+
);
|
|
747
|
+
|
|
748
|
+
const events: unknown[] = [];
|
|
749
|
+
// The outcome is watched from the moment the stream starts: a failure this
|
|
750
|
+
// test only looked at later would be an unobserved rejection first.
|
|
751
|
+
const outcome = (async () => {
|
|
752
|
+
for await (const event of root.llm.stream(
|
|
753
|
+
request,
|
|
754
|
+
new AbortController().signal,
|
|
755
|
+
)) {
|
|
756
|
+
events.push(event);
|
|
757
|
+
}
|
|
758
|
+
})().then(
|
|
759
|
+
() => undefined,
|
|
760
|
+
(error: unknown) => error,
|
|
761
|
+
);
|
|
762
|
+
push('data: {"choices":[{"delta":{"content":"Half a "}}]}\n\n');
|
|
763
|
+
await settle();
|
|
764
|
+
// The answer has started, so the clock now running is the idle one — well
|
|
765
|
+
// short of the first-byte allowance this never reaches.
|
|
766
|
+
clock.advance(MODEL_IDLE_DEADLINE_MS_V1);
|
|
767
|
+
|
|
768
|
+
const failure = await outcome;
|
|
769
|
+
expect(failure).toBeInstanceOf(ModelRequestDeadlineError);
|
|
770
|
+
expect((failure as ModelRequestDeadlineError).phase).toBe("idle");
|
|
771
|
+
expect((failure as Error).message).toBe(MODEL_IDLE_DEADLINE_REASON_V1);
|
|
772
|
+
expect(events).toEqual([{ type: "text-delta", text: "Half a " }]);
|
|
773
|
+
await root.fiber.dispose();
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
test("lets a stream that keeps producing chunks finish, leaving no timer armed", async () => {
|
|
777
|
+
const clock = manualClock();
|
|
778
|
+
const { body, push } = pushableSse();
|
|
779
|
+
const root = new Context();
|
|
780
|
+
await mountWithClock(root, clock, () =>
|
|
781
|
+
Promise.resolve(new Response(body, { status: 200 })),
|
|
782
|
+
);
|
|
783
|
+
|
|
784
|
+
const events: unknown[] = [];
|
|
785
|
+
const consume = (async () => {
|
|
786
|
+
for await (const event of root.llm.stream(
|
|
787
|
+
request,
|
|
788
|
+
new AbortController().signal,
|
|
789
|
+
)) {
|
|
790
|
+
events.push(event);
|
|
791
|
+
}
|
|
792
|
+
})();
|
|
793
|
+
for (const chunk of ["one", "two", "three"]) {
|
|
794
|
+
push(`data: {"choices":[{"delta":{"content":"${chunk}"}}]}\n\n`);
|
|
795
|
+
await settle();
|
|
796
|
+
// Each chunk lands inside the idle allowance, so the clock rearms rather
|
|
797
|
+
// than firing.
|
|
798
|
+
clock.advance(MODEL_IDLE_DEADLINE_MS_V1 - 1);
|
|
799
|
+
await settle();
|
|
511
800
|
}
|
|
801
|
+
push(
|
|
802
|
+
'data: {"choices":[{"delta":{},"finish_reason":"stop"}]}\n\ndata: [DONE]\n\n',
|
|
803
|
+
);
|
|
804
|
+
await settle();
|
|
805
|
+
await consume;
|
|
512
806
|
|
|
513
|
-
expect(
|
|
514
|
-
|
|
515
|
-
|
|
807
|
+
expect(events).toEqual([
|
|
808
|
+
{ type: "text-delta", text: "one" },
|
|
809
|
+
{ type: "text-delta", text: "two" },
|
|
810
|
+
{ type: "text-delta", text: "three" },
|
|
811
|
+
{ type: "finish", reason: "completed" },
|
|
812
|
+
]);
|
|
813
|
+
// A live timer in a Worker isolate holds the request open long after
|
|
814
|
+
// anybody is listening for it.
|
|
815
|
+
expect(clock.armed).toBe(0);
|
|
516
816
|
await root.fiber.dispose();
|
|
517
817
|
});
|
|
518
818
|
});
|
package/src/runtime.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
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";
|
|
7
8
|
import type { CredentialLeaseV1 } from "@frockbot/connection-core";
|
|
8
9
|
import {
|
|
10
|
+
type ModelRequestDeadlineOptionsV1,
|
|
9
11
|
OpenAICompatibleHttpError,
|
|
10
12
|
OpenAICompatibleProvider,
|
|
11
13
|
} from "@frockbot/provider-openai-compatible";
|
|
@@ -58,6 +60,12 @@ export interface OllamaCloudRuntimeConfig {
|
|
|
58
60
|
chatBaseUrl?: string;
|
|
59
61
|
fetch?: OllamaFetch;
|
|
60
62
|
now?: () => number;
|
|
63
|
+
/**
|
|
64
|
+
* Deadline overrides and the timer seam behind them, forwarded to the shared
|
|
65
|
+
* OpenAI-compatible transport. Without forwarding, the deadlines are real but
|
|
66
|
+
* only reachable by waiting two minutes for one.
|
|
67
|
+
*/
|
|
68
|
+
deadlines?: ModelRequestDeadlineOptionsV1;
|
|
61
69
|
}
|
|
62
70
|
|
|
63
71
|
/** Compose the OpenAI-compatible chat root from a Connection endpoint root. */
|
|
@@ -143,6 +151,19 @@ class OllamaCloudProvider implements LlmProvider {
|
|
|
143
151
|
}
|
|
144
152
|
}
|
|
145
153
|
|
|
154
|
+
/**
|
|
155
|
+
* Ollama keeps no addressable copy of a completion, so an interrupted stream
|
|
156
|
+
* can never be read back. Saying so settles the run as a failure with its
|
|
157
|
+
* partial text intact instead of parking it forever.
|
|
158
|
+
*/
|
|
159
|
+
readonly reconciliation: LlmReconciliationCapability = {
|
|
160
|
+
retrieve: async () => ({
|
|
161
|
+
status: "not-retrievable",
|
|
162
|
+
reason:
|
|
163
|
+
"Ollama keeps no durable copy of an interrupted response, so it cannot be recovered",
|
|
164
|
+
}),
|
|
165
|
+
};
|
|
166
|
+
|
|
146
167
|
async *stream(request: NormalizedModelRequest, signal: AbortSignal) {
|
|
147
168
|
await this.authorize(request);
|
|
148
169
|
const authorization = this.authorized.get(request.requestId);
|
|
@@ -156,17 +177,31 @@ class OllamaCloudProvider implements LlmProvider {
|
|
|
156
177
|
apiKey: authorization.apiKey,
|
|
157
178
|
providerId: this.id,
|
|
158
179
|
fetch: this.config.fetch,
|
|
180
|
+
...(this.config.deadlines?.deadlines
|
|
181
|
+
? { deadlines: this.config.deadlines.deadlines }
|
|
182
|
+
: {}),
|
|
183
|
+
...(this.config.deadlines?.schedule
|
|
184
|
+
? { schedule: this.config.deadlines.schedule }
|
|
185
|
+
: {}),
|
|
159
186
|
});
|
|
187
|
+
// Every failure raised before the first stream event happened before a
|
|
188
|
+
// provider effect existed, so it is definitive rather than uncertain. A 429
|
|
189
|
+
// or a 502 reported as a bare failure would park the run on a retrieval
|
|
190
|
+
// this Package cannot perform; reported as "not started" it fails cleanly
|
|
191
|
+
// and can be retried.
|
|
192
|
+
let started = false;
|
|
160
193
|
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);
|
|
194
|
+
for await (const event of provider.stream(request, signal)) {
|
|
195
|
+
started = true;
|
|
196
|
+
yield event;
|
|
168
197
|
}
|
|
169
|
-
|
|
198
|
+
} catch (error) {
|
|
199
|
+
if (started || signal.aborted) throw error;
|
|
200
|
+
throw new LlmEffectNotStartedError(
|
|
201
|
+
error instanceof OpenAICompatibleHttpError || error instanceof Error
|
|
202
|
+
? error.message
|
|
203
|
+
: "Ollama Cloud request did not reach the provider",
|
|
204
|
+
);
|
|
170
205
|
}
|
|
171
206
|
}
|
|
172
207
|
}
|
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;
|