@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 +11 -11
- package/src/runtime.test.ts +274 -0
- package/src/runtime.ts +13 -0
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/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";
|
|
@@ -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
|