@frockbot/plugin-provider-ollama-cloud 0.3.5 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frockbot/plugin-provider-ollama-cloud",
3
- "version": "0.3.5",
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.5",
24
- "@frockbot/connection-core": "0.3.5",
25
- "@frockbot/kernel-agent-loop": "0.3.5",
26
- "@frockbot/kernel-contracts": "0.3.5",
27
- "@frockbot/plugin-credentials": "0.3.5",
28
- "@frockbot/plugin-models": "0.3.5",
29
- "@frockbot/plugin-settings": "0.3.5",
30
- "@frockbot/plugin-web": "0.3.5",
31
- "@frockbot/provider-openai-compatible": "0.3.5",
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.5",
35
+ "@frockbot/plugin-tools": "0.3.7",
36
36
  "@types/bun": "1.3.6",
37
37
  "typescript": "^7.0.2"
38
38
  },
@@ -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 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
- });
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
- return boundedJson(
222
- response,
223
- path === "/tags" ? MAX_CATALOG_RESPONSE_BYTES : MAX_MODEL_RESPONSE_BYTES,
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 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
- });
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) {
@@ -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("requires reconciliation for ambiguous HTTP failures", async () => {
462
- const keyringText = serializedKeyring();
463
- const envelope = await sealCredentialV1({
464
- keyring: parseCredentialKeyringV1(keyringText),
465
- context: {
466
- accountId: "account-1",
467
- connectionId: "connection-1",
468
- packageId: "provider-ollama-cloud",
469
- credentialGeneration: "generation-1",
470
- },
471
- plaintext: "account-secret",
472
- });
473
- const settled: string[] = [];
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, keyringText);
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: (effectId) =>
484
- Promise.resolve({
485
- schemaVersion: 1,
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
- let failure: unknown;
502
- try {
503
- for await (const _ of root.llm.stream(
504
- request,
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(failure).toBeInstanceOf(OpenAICompatibleHttpError);
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
- yield* provider.stream(request, signal);
162
- } catch (error) {
163
- if (
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
- throw error;
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() + REFRESH_INTERVAL_MS,
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 (!record.receipt) await this.resumeOnce(record);
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;