@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frockbot/plugin-provider-ollama-cloud",
3
- "version": "0.3.6",
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.6",
24
- "@frockbot/connection-core": "0.3.6",
25
- "@frockbot/kernel-agent-loop": "0.3.6",
26
- "@frockbot/kernel-contracts": "0.3.6",
27
- "@frockbot/plugin-credentials": "0.3.6",
28
- "@frockbot/plugin-models": "0.3.6",
29
- "@frockbot/plugin-settings": "0.3.6",
30
- "@frockbot/plugin-web": "0.3.6",
31
- "@frockbot/provider-openai-compatible": "0.3.6",
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.6",
35
+ "@frockbot/plugin-tools": "0.3.8",
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) {
@@ -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("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[] = [];
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, keyringText);
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: (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 })),
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
- let failure: unknown;
502
- try {
503
- for await (const _ of root.llm.stream(
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
- } catch (error) {
510
- failure = error;
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(failure).toBeInstanceOf(OpenAICompatibleHttpError);
514
- expect(failure).not.toBeInstanceOf(LlmEffectNotStartedError);
515
- expect(settled).toEqual([]);
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
- 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);
194
+ for await (const event of provider.stream(request, signal)) {
195
+ started = true;
196
+ yield event;
168
197
  }
169
- throw error;
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() + 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;