@frockbot/plugin-provider-ollama-cloud 0.3.7 → 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.7",
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.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",
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.7",
35
+ "@frockbot/plugin-tools": "0.3.8",
36
36
  "@types/bun": "1.3.6",
37
37
  "typescript": "^7.0.2"
38
38
  },
@@ -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";
@@ -542,3 +547,272 @@ describe("Ollama Cloud runtime Contribution", () => {
542
547
  await root.fiber.dispose();
543
548
  });
544
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(
715
+ request,
716
+ new AbortController().signal,
717
+ )) {
718
+ void event;
719
+ }
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();
800
+ }
801
+ push(
802
+ 'data: {"choices":[{"delta":{},"finish_reason":"stop"}]}\n\ndata: [DONE]\n\n',
803
+ );
804
+ await settle();
805
+ await consume;
806
+
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);
816
+ await root.fiber.dispose();
817
+ });
818
+ });
package/src/runtime.ts CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  import { type Agent } from "@frockbot/kernel-agent-loop/agent";
8
8
  import type { CredentialLeaseV1 } from "@frockbot/connection-core";
9
9
  import {
10
+ type ModelRequestDeadlineOptionsV1,
10
11
  OpenAICompatibleHttpError,
11
12
  OpenAICompatibleProvider,
12
13
  } from "@frockbot/provider-openai-compatible";
@@ -59,6 +60,12 @@ export interface OllamaCloudRuntimeConfig {
59
60
  chatBaseUrl?: string;
60
61
  fetch?: OllamaFetch;
61
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;
62
69
  }
63
70
 
64
71
  /** Compose the OpenAI-compatible chat root from a Connection endpoint root. */
@@ -170,6 +177,12 @@ class OllamaCloudProvider implements LlmProvider {
170
177
  apiKey: authorization.apiKey,
171
178
  providerId: this.id,
172
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
+ : {}),
173
186
  });
174
187
  // Every failure raised before the first stream event happened before a
175
188
  // provider effect existed, so it is definitive rather than uncertain. A 429