@frockbot/plugin-provider-frock-ai 0.0.0 → 0.3.13

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.
@@ -0,0 +1,526 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
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,
9
+ type NormalizedModelRequest,
10
+ } from "@frockbot/kernel-contracts";
11
+ import { LlmRegistry } from "@frockbot/plugin-models";
12
+ import { Context } from "cordis";
13
+ import {
14
+ FROCK_AI_CONNECTION_GENERATION,
15
+ FROCK_AI_CONNECTION_ID,
16
+ FROCK_AI_DEFAULT_MODEL,
17
+ } from "./catalog.js";
18
+ import { createFrockAiRuntimePlugin } from "./runtime.js";
19
+
20
+ const request: NormalizedModelRequest = {
21
+ requestId: "effect-1",
22
+ provider: "flock-ai",
23
+ model: FROCK_AI_DEFAULT_MODEL,
24
+ system: "Be concise.",
25
+ messages: [{ role: "user", content: "hello" }],
26
+ tools: [
27
+ {
28
+ name: "weather",
29
+ description: "Read the weather",
30
+ inputSchema: { type: "object", properties: {} },
31
+ },
32
+ ],
33
+ modelBinding: {
34
+ connectionId: FROCK_AI_CONNECTION_ID,
35
+ connectionGeneration: FROCK_AI_CONNECTION_GENERATION,
36
+ },
37
+ };
38
+
39
+ function sse(text: string): ReadableStream<Uint8Array> {
40
+ const body = new Response(text).body;
41
+ if (!body) throw new Error("test response stream is unavailable");
42
+ return body;
43
+ }
44
+
45
+ function runtimeConfig(
46
+ runChatCompletion: Parameters<
47
+ typeof createFrockAiRuntimePlugin
48
+ >[0]["runChatCompletion"],
49
+ deadlines?: Parameters<typeof createFrockAiRuntimePlugin>[0]["deadlines"],
50
+ ) {
51
+ return {
52
+ connectionId: FROCK_AI_CONNECTION_ID,
53
+ connectionGeneration: FROCK_AI_CONNECTION_GENERATION,
54
+ autoRoute: "configured-auto",
55
+ runChatCompletion,
56
+ ...(deadlines ? { deadlines } : {}),
57
+ };
58
+ }
59
+
60
+ /** A clock the test advances by hand, so a deadline costs no real seconds. */
61
+ function manualClock() {
62
+ const pending = new Map<number, { run: () => void; due: number }>();
63
+ let next = 1;
64
+ let now = 0;
65
+ return {
66
+ schedule(run: () => void, milliseconds: number): () => void {
67
+ const id = next++;
68
+ pending.set(id, { run, due: now + milliseconds });
69
+ return () => pending.delete(id);
70
+ },
71
+ advance(milliseconds: number): void {
72
+ now += milliseconds;
73
+ for (const [id, timer] of [...pending]) {
74
+ if (timer.due <= now) {
75
+ pending.delete(id);
76
+ timer.run();
77
+ }
78
+ }
79
+ },
80
+ get armed(): number {
81
+ return pending.size;
82
+ },
83
+ };
84
+ }
85
+
86
+ /** Let the stream's own pump run: the clock is manual, the event loop is not. */
87
+ async function settle(): Promise<void> {
88
+ for (let tick = 0; tick < 10; tick += 1) {
89
+ await new Promise((resolve) => setTimeout(resolve, 0));
90
+ }
91
+ }
92
+
93
+ /** A gateway body the test feeds one chunk at a time. */
94
+ function pushableSse(): {
95
+ body: ReadableStream<Uint8Array>;
96
+ push: (text: string) => void;
97
+ } {
98
+ let enqueue: ((text: string) => void) | undefined;
99
+ const body = new ReadableStream<Uint8Array>({
100
+ start(controller) {
101
+ enqueue = (text) => controller.enqueue(new TextEncoder().encode(text));
102
+ },
103
+ });
104
+ return { body, push: (text) => enqueue?.(text) };
105
+ }
106
+
107
+ describe("Frock AI runtime Contribution", () => {
108
+ test.each([
109
+ [FROCK_AI_DEFAULT_MODEL, "dynamic/configured-auto"],
110
+ [
111
+ "@frock/deepseek-ai/deepseek-v4-flash-0731",
112
+ "workers-ai/@cf/deepseek-ai/deepseek-v4-flash-0731",
113
+ ],
114
+ ])("maps %s to gateway model %s", async (model, expectedGatewayModel) => {
115
+ const calls: Array<{
116
+ gatewayModel: string;
117
+ body: Record<string, unknown>;
118
+ }> = [];
119
+ const root = new Context();
120
+ await root.plugin(LlmRegistry);
121
+ await root.plugin(
122
+ createFrockAiRuntimePlugin(
123
+ runtimeConfig((gatewayModel, body) => {
124
+ calls.push({ gatewayModel, body });
125
+ return Promise.resolve(
126
+ sse(
127
+ 'data: {"choices":[{"delta":{"content":"ok"},"finish_reason":"stop"}]}\n\n' +
128
+ "data: [DONE]\n\n",
129
+ ),
130
+ );
131
+ }),
132
+ ),
133
+ );
134
+
135
+ for await (const event of root.llm.stream(
136
+ { ...request, model },
137
+ new AbortController().signal,
138
+ )) {
139
+ void event;
140
+ }
141
+
142
+ expect(calls).toHaveLength(1);
143
+ expect(calls[0]?.gatewayModel).toBe(expectedGatewayModel);
144
+ expect(calls[0]?.body).not.toHaveProperty("model");
145
+ await root.fiber.dispose();
146
+ });
147
+
148
+ test("normalizes gateway text and tool-call deltas", async () => {
149
+ const calls: Array<{
150
+ gatewayModel: string;
151
+ body: Record<string, unknown>;
152
+ }> = [];
153
+ const root = new Context();
154
+ await root.plugin(LlmRegistry);
155
+ await root.plugin(
156
+ createFrockAiRuntimePlugin(
157
+ runtimeConfig((gatewayModel, body) => {
158
+ calls.push({ gatewayModel, body });
159
+ return Promise.resolve(
160
+ sse(
161
+ 'data: {"choices":[{"delta":{"content":"Working"}}]}\n\n' +
162
+ 'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call-1","function":{"name":"weather","arguments":"{\\"city\\":\\"Sydney\\"}"}}]},"finish_reason":"tool_calls"}]}\n\n' +
163
+ "data: [DONE]\n\n",
164
+ ),
165
+ );
166
+ }),
167
+ ),
168
+ );
169
+
170
+ const events = [];
171
+ for await (const event of root.llm.stream(
172
+ request,
173
+ new AbortController().signal,
174
+ )) {
175
+ events.push(event);
176
+ }
177
+
178
+ expect(calls).toEqual([
179
+ {
180
+ gatewayModel: "dynamic/configured-auto",
181
+ body: {
182
+ stream: true,
183
+ messages: [
184
+ { role: "system", content: "Be concise." },
185
+ { role: "user", content: "hello" },
186
+ ],
187
+ tools: [
188
+ {
189
+ type: "function",
190
+ function: {
191
+ name: "weather",
192
+ description: "Read the weather",
193
+ parameters: { type: "object", properties: {} },
194
+ },
195
+ },
196
+ ],
197
+ },
198
+ },
199
+ ]);
200
+ expect(events).toEqual([
201
+ { type: "text-delta", text: "Working" },
202
+ {
203
+ type: "tool-call",
204
+ call: {
205
+ id: "call-1",
206
+ name: "weather",
207
+ input: { city: "Sydney" },
208
+ },
209
+ },
210
+ { type: "finish", reason: "tool-calls" },
211
+ ]);
212
+ await root.fiber.dispose();
213
+ });
214
+
215
+ test("refuses a request outside its pinned Connection generation", async () => {
216
+ let calls = 0;
217
+ const root = new Context();
218
+ await root.plugin(LlmRegistry);
219
+ await root.plugin(
220
+ createFrockAiRuntimePlugin(
221
+ runtimeConfig(() => {
222
+ calls += 1;
223
+ return Promise.resolve(sse(""));
224
+ }),
225
+ ),
226
+ );
227
+ const mismatched = {
228
+ ...request,
229
+ modelBinding: {
230
+ ...request.modelBinding!,
231
+ connectionGeneration: "different-generation",
232
+ },
233
+ };
234
+
235
+ await expect(
236
+ (async () => {
237
+ for await (const event of root.llm.stream(
238
+ mismatched,
239
+ new AbortController().signal,
240
+ )) {
241
+ void event;
242
+ }
243
+ })(),
244
+ ).rejects.toBeInstanceOf(LlmEffectNotStartedError);
245
+ expect(calls).toBe(0);
246
+ await root.fiber.dispose();
247
+ });
248
+
249
+ test("reports a rejected gateway call as a definitive no-effect", async () => {
250
+ const root = new Context();
251
+ await root.plugin(LlmRegistry);
252
+ await root.plugin(
253
+ createFrockAiRuntimePlugin(
254
+ runtimeConfig(() =>
255
+ Promise.reject(
256
+ new Error("AI Gateway rejected the request (429): slow down"),
257
+ ),
258
+ ),
259
+ ),
260
+ );
261
+
262
+ let failure: unknown;
263
+ try {
264
+ for await (const event of root.llm.stream(
265
+ request,
266
+ new AbortController().signal,
267
+ )) {
268
+ void event;
269
+ }
270
+ } catch (error) {
271
+ failure = error;
272
+ }
273
+
274
+ // Uncertain here would park the run on a reconciliation this Package
275
+ // cannot perform, wedging the Bot on a transient gateway error.
276
+ expect(failure).toBeInstanceOf(LlmEffectNotStartedError);
277
+ expect((failure as Error).message).toBe(
278
+ "AI Gateway rejected the request (429): slow down",
279
+ );
280
+ await root.fiber.dispose();
281
+ });
282
+
283
+ test("cancels the gateway response stream when the Turn is aborted", async () => {
284
+ let cancelled = false;
285
+ const root = new Context();
286
+ await root.plugin(LlmRegistry);
287
+ await root.plugin(
288
+ createFrockAiRuntimePlugin(
289
+ runtimeConfig(() =>
290
+ Promise.resolve(
291
+ new ReadableStream({
292
+ cancel() {
293
+ cancelled = true;
294
+ },
295
+ }),
296
+ ),
297
+ ),
298
+ ),
299
+ );
300
+ const controller = new AbortController();
301
+ const consume = (async () => {
302
+ for await (const event of root.llm.stream(request, controller.signal)) {
303
+ void event;
304
+ }
305
+ })();
306
+
307
+ await Promise.resolve();
308
+ controller.abort(new Error("Turn cancelled"));
309
+ await expect(consume).rejects.toThrow("Turn cancelled");
310
+ expect(cancelled).toBe(true);
311
+ await root.fiber.dispose();
312
+ });
313
+ });
314
+
315
+ // A Stop must end one request and nothing else. The provider is registered
316
+ // once and serves every Turn, so anything request-scoped it kept on itself — an
317
+ // abort scope, a client, a stream — would let a cancelled Turn take the next
318
+ // one down with it.
319
+ describe("Frock AI request isolation", () => {
320
+ test("leaves the next request working after one is cancelled", async () => {
321
+ const bodies: Array<ReadableStream<Uint8Array>> = [];
322
+ const root = new Context();
323
+ await root.plugin(LlmRegistry);
324
+ await root.plugin(
325
+ createFrockAiRuntimePlugin(
326
+ runtimeConfig(() => {
327
+ const body =
328
+ bodies.length === 0
329
+ ? pushableSse().body
330
+ : sse(
331
+ 'data: {"choices":[{"delta":{"content":"second"},"finish_reason":"stop"}]}\n\n' +
332
+ "data: [DONE]\n\n",
333
+ );
334
+ bodies.push(body);
335
+ return Promise.resolve(body);
336
+ }),
337
+ ),
338
+ );
339
+
340
+ const cancelled = new AbortController();
341
+ const abandoned = (async () => {
342
+ for await (const event of root.llm.stream(request, cancelled.signal)) {
343
+ void event;
344
+ }
345
+ })().then(
346
+ () => undefined,
347
+ (error: unknown) => error,
348
+ );
349
+ await settle();
350
+ cancelled.abort(new Error("Turn cancelled"));
351
+ expect((await abandoned) as Error).toBeInstanceOf(Error);
352
+
353
+ const events: unknown[] = [];
354
+ for await (const event of root.llm.stream(
355
+ { ...request, requestId: "effect-2" },
356
+ new AbortController().signal,
357
+ )) {
358
+ events.push(event);
359
+ }
360
+
361
+ expect(events).toEqual([
362
+ { type: "text-delta", text: "second" },
363
+ { type: "finish", reason: "completed" },
364
+ ]);
365
+ await root.fiber.dispose();
366
+ });
367
+ });
368
+
369
+ // The gateway binding takes no signal of its own, so before the shared
370
+ // deadline seam a gateway that accepted the request and then went quiet was
371
+ // bounded by nothing short of the fifteen-minute Turn deadline: an empty
372
+ // bubble, for a quarter of an hour, saying nothing about why.
373
+ describe("Frock AI deadlines", () => {
374
+ test("fails the step when the gateway produces no first byte", async () => {
375
+ const clock = manualClock();
376
+ const root = new Context();
377
+ await root.plugin(LlmRegistry);
378
+ await root.plugin(
379
+ createFrockAiRuntimePlugin(
380
+ runtimeConfig(
381
+ () => new Promise<ReadableStream<Uint8Array>>(() => undefined),
382
+ { schedule: clock.schedule },
383
+ ),
384
+ ),
385
+ );
386
+
387
+ const consume = (async () => {
388
+ for await (const event of root.llm.stream(
389
+ request,
390
+ new AbortController().signal,
391
+ )) {
392
+ void event;
393
+ }
394
+ })();
395
+ await settle();
396
+ clock.advance(MODEL_FIRST_BYTE_DEADLINE_MS_V1);
397
+
398
+ const failure = await consume.then(
399
+ () => undefined,
400
+ (error: unknown) => error,
401
+ );
402
+ expect(failure).toBeInstanceOf(ModelRequestDeadlineError);
403
+ expect((failure as ModelRequestDeadlineError).phase).toBe("first-byte");
404
+ expect((failure as Error).message).toBe(
405
+ MODEL_FIRST_BYTE_DEADLINE_REASON_V1,
406
+ );
407
+ await root.fiber.dispose();
408
+ });
409
+
410
+ test("fails the step when the gateway starts an answer and then stalls", async () => {
411
+ const clock = manualClock();
412
+ const { body, push } = pushableSse();
413
+ const root = new Context();
414
+ await root.plugin(LlmRegistry);
415
+ await root.plugin(
416
+ createFrockAiRuntimePlugin(
417
+ runtimeConfig(() => Promise.resolve(body), {
418
+ schedule: clock.schedule,
419
+ }),
420
+ ),
421
+ );
422
+
423
+ const events: unknown[] = [];
424
+ // The outcome is watched from the moment the stream starts: a failure this
425
+ // test only looked at later would be an unobserved rejection first.
426
+ const outcome = (async () => {
427
+ for await (const event of root.llm.stream(
428
+ request,
429
+ new AbortController().signal,
430
+ )) {
431
+ events.push(event);
432
+ }
433
+ })().then(
434
+ () => undefined,
435
+ (error: unknown) => error,
436
+ );
437
+ push('data: {"choices":[{"delta":{"content":"Half a "}}]}\n\n');
438
+ await settle();
439
+ // The answer has started, so the clock now running is the idle one — well
440
+ // short of the first-byte allowance this never reaches.
441
+ clock.advance(MODEL_IDLE_DEADLINE_MS_V1);
442
+
443
+ const failure = await outcome;
444
+ expect(failure).toBeInstanceOf(ModelRequestDeadlineError);
445
+ expect((failure as ModelRequestDeadlineError).phase).toBe("idle");
446
+ expect((failure as Error).message).toBe(MODEL_IDLE_DEADLINE_REASON_V1);
447
+ expect(events).toEqual([{ type: "text-delta", text: "Half a " }]);
448
+ await root.fiber.dispose();
449
+ });
450
+
451
+ test("lets a stream that keeps producing chunks finish, leaving no timer armed", async () => {
452
+ const clock = manualClock();
453
+ const { body, push } = pushableSse();
454
+ const root = new Context();
455
+ await root.plugin(LlmRegistry);
456
+ await root.plugin(
457
+ createFrockAiRuntimePlugin(
458
+ runtimeConfig(() => Promise.resolve(body), {
459
+ schedule: clock.schedule,
460
+ }),
461
+ ),
462
+ );
463
+
464
+ const events: unknown[] = [];
465
+ const consume = (async () => {
466
+ for await (const event of root.llm.stream(
467
+ request,
468
+ new AbortController().signal,
469
+ )) {
470
+ events.push(event);
471
+ }
472
+ })();
473
+ for (const chunk of ["one", "two", "three"]) {
474
+ push(`data: {"choices":[{"delta":{"content":"${chunk}"}}]}\n\n`);
475
+ await settle();
476
+ // Each chunk lands inside the idle allowance, so the clock rearms rather
477
+ // than firing.
478
+ clock.advance(MODEL_IDLE_DEADLINE_MS_V1 - 1);
479
+ await settle();
480
+ }
481
+ push(
482
+ 'data: {"choices":[{"delta":{},"finish_reason":"stop"}]}\n\ndata: [DONE]\n\n',
483
+ );
484
+ await settle();
485
+ await consume;
486
+
487
+ expect(events).toEqual([
488
+ { type: "text-delta", text: "one" },
489
+ { type: "text-delta", text: "two" },
490
+ { type: "text-delta", text: "three" },
491
+ { type: "finish", reason: "completed" },
492
+ ]);
493
+ // A live timer in a Worker isolate holds the request open long after
494
+ // anybody is listening for it.
495
+ expect(clock.armed).toBe(0);
496
+ await root.fiber.dispose();
497
+ });
498
+ });
499
+
500
+ describe("Frock AI reconciliation", () => {
501
+ test("reports an interrupted response as not retrievable so the run settles", async () => {
502
+ const root = new Context();
503
+ await root.plugin(LlmRegistry);
504
+ await root.plugin(
505
+ createFrockAiRuntimePlugin({
506
+ connectionId: FROCK_AI_CONNECTION_ID,
507
+ connectionGeneration: FROCK_AI_CONNECTION_GENERATION,
508
+ autoRoute: "dynamic/auto",
509
+ runChatCompletion: () =>
510
+ Promise.reject(new Error("must not be reached")),
511
+ }),
512
+ );
513
+
514
+ const outcome = await root.llm.reconcile(
515
+ request,
516
+ new AbortController().signal,
517
+ );
518
+
519
+ expect(outcome).toEqual({
520
+ status: "not-retrievable",
521
+ reason:
522
+ "Frock AI keeps no durable copy of an interrupted response, so it cannot be recovered",
523
+ });
524
+ await root.fiber.dispose();
525
+ });
526
+ });
package/src/runtime.ts ADDED
@@ -0,0 +1,122 @@
1
+ import {
2
+ LlmEffectNotStartedError,
3
+ type LlmProvider,
4
+ type LlmReconciliationCapability,
5
+ type NormalizedModelRequest,
6
+ } from "@frockbot/kernel-contracts";
7
+ import {
8
+ type ModelRequestDeadlineOptionsV1,
9
+ requestToWire,
10
+ streamWithModelRequestDeadlinesV1,
11
+ } from "@frockbot/provider-openai-compatible";
12
+ import type { Plugin } from "cordis";
13
+ import { FROCK_AI_PROVIDER_TYPE, gatewayModelForFrockIdV1 } from "./catalog.js";
14
+
15
+ export type OpenAICompatibleChatCompletionBodyV1 = Record<string, unknown>;
16
+
17
+ /**
18
+ * The narrow native host seam. Cloudflare's generated `Ai` type remains in
19
+ * apps/cloudflare; the Package consumes one streaming gateway operation.
20
+ */
21
+ export type FrockAiChatCompletionV1 = (
22
+ gatewayModel: string,
23
+ body: OpenAICompatibleChatCompletionBodyV1,
24
+ /** Cancels the gateway request; the host bounds it with its own deadline. */
25
+ signal?: AbortSignal,
26
+ ) => Promise<ReadableStream<Uint8Array>>;
27
+
28
+ export interface FrockAiRuntimeConfig {
29
+ connectionId: string;
30
+ connectionGeneration: string;
31
+ autoRoute: string;
32
+ runChatCompletion: FrockAiChatCompletionV1;
33
+ /**
34
+ * Deadline overrides and the timer seam behind them. The gateway binding
35
+ * takes no signal of its own, so this is the only bound on a gateway call
36
+ * that accepts the request and then says nothing.
37
+ */
38
+ deadlines?: ModelRequestDeadlineOptionsV1;
39
+ }
40
+
41
+ class FrockAiProvider implements LlmProvider {
42
+ readonly id = FROCK_AI_PROVIDER_TYPE;
43
+
44
+ /**
45
+ * The Gateway keeps no addressable copy of a completion, so an interrupted
46
+ * stream can never be read back. Saying so is what lets the run settle as a
47
+ * failure with its partial text intact; staying silent parks it on a
48
+ * retrieval that would never arrive.
49
+ */
50
+ readonly reconciliation: LlmReconciliationCapability = {
51
+ retrieve: async () => ({
52
+ status: "not-retrievable",
53
+ reason:
54
+ "Frock AI keeps no durable copy of an interrupted response, so it cannot be recovered",
55
+ }),
56
+ };
57
+
58
+ constructor(private readonly config: FrockAiRuntimeConfig) {}
59
+
60
+ async *stream(request: NormalizedModelRequest, signal: AbortSignal) {
61
+ const binding = request.modelBinding;
62
+ if (
63
+ binding?.connectionId !== this.config.connectionId ||
64
+ binding.connectionGeneration !== this.config.connectionGeneration
65
+ ) {
66
+ throw new LlmEffectNotStartedError(
67
+ "Frock AI request has invalid Connection authority",
68
+ );
69
+ }
70
+ signal.throwIfAborted();
71
+ const wire = requestToWire(request);
72
+ const { model: _model, ...body } = wire;
73
+ const gatewayModel = gatewayModelForFrockIdV1(
74
+ request.model,
75
+ this.config.autoRoute,
76
+ );
77
+ // A rejection here happened before a stream existed, so no provider effect
78
+ // was ever begun. That is definitive rather than uncertain: reported as a
79
+ // bare failure it would park the run on a reconciliation this Package
80
+ // cannot perform, and the Bot would stay wedged on a transient gateway
81
+ // error.
82
+ // Both bounds at once, and they are not the same bound. `signal` is the
83
+ // caller's cancellation — a Stop, a superseded Turn — and main's change
84
+ // hands it to the gateway so the request is actually torn down. The
85
+ // deadline seam wraps that with the clock: this transport is a native
86
+ // binding, so a gateway that accepted the request and then went quiet was
87
+ // otherwise bounded by nothing short of the fifteen-minute Turn deadline.
88
+ // The seam's signal is derived from the caller's, so passing it down keeps
89
+ // the cancellation and adds the deadline.
90
+ yield* streamWithModelRequestDeadlinesV1(
91
+ async (deadlineSignal) => {
92
+ try {
93
+ return await this.config.runChatCompletion(
94
+ gatewayModel,
95
+ body,
96
+ deadlineSignal,
97
+ );
98
+ } catch (error) {
99
+ deadlineSignal.throwIfAborted();
100
+ throw new LlmEffectNotStartedError(
101
+ error instanceof Error
102
+ ? error.message
103
+ : "Frock AI request did not reach the gateway",
104
+ );
105
+ }
106
+ },
107
+ signal,
108
+ this.config.deadlines ?? {},
109
+ );
110
+ }
111
+ }
112
+
113
+ export function createFrockAiRuntimePlugin(
114
+ config: FrockAiRuntimeConfig,
115
+ ): Plugin.Function {
116
+ const plugin: Plugin.Function = (ctx) =>
117
+ ctx.llm.register(new FrockAiProvider(config));
118
+ plugin.inject = ["llm"];
119
+ return plugin;
120
+ }
121
+
122
+ export default createFrockAiRuntimePlugin;