@frockbot/provider-openai-compatible 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/provider-openai-compatible",
3
- "version": "0.3.5",
3
+ "version": "0.3.7",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -11,8 +11,8 @@
11
11
  "typecheck": "tsc --noEmit -p tsconfig.json"
12
12
  },
13
13
  "dependencies": {
14
- "@frockbot/kernel-contracts": "0.3.5",
15
- "@frockbot/plugin-models": "0.3.5",
14
+ "@frockbot/kernel-contracts": "0.3.7",
15
+ "@frockbot/plugin-models": "0.3.7",
16
16
  "cordis": "4.0.0-rc.8"
17
17
  },
18
18
  "devDependencies": {
@@ -0,0 +1,192 @@
1
+ // A Turn once hung for seventeen minutes with nothing on screen: there was no
2
+ // time bound anywhere on a model request, so a provider that accepted the call
3
+ // and then went quiet held the socket — and the Turn — open indefinitely.
4
+ //
5
+ // The timer is injected, so these tests assert the behaviour rather than
6
+ // waiting two minutes for it.
7
+ import { describe, expect, test } from "bun:test";
8
+ import {
9
+ ModelRequestDeadlineError,
10
+ type NormalizedModelRequest,
11
+ } from "@frockbot/kernel-contracts";
12
+ import { OpenAICompatibleProvider } from "./index.js";
13
+
14
+ const request: NormalizedModelRequest = {
15
+ requestId: "request-1",
16
+ provider: "openai-compatible",
17
+ model: "test-model",
18
+ system: "Be useful.",
19
+ messages: [{ role: "user", content: "hello" }],
20
+ tools: [],
21
+ };
22
+
23
+ /** A clock the test advances by hand. */
24
+ function manualClock() {
25
+ const pending = new Map<number, { run: () => void; due: number }>();
26
+ let next = 1;
27
+ let now = 0;
28
+ return {
29
+ schedule(run: () => void, milliseconds: number): () => void {
30
+ const id = next++;
31
+ pending.set(id, { run, due: now + milliseconds });
32
+ return () => pending.delete(id);
33
+ },
34
+ /** Fire everything due at or before `now + milliseconds`. */
35
+ advance(milliseconds: number): void {
36
+ now += milliseconds;
37
+ for (const [id, timer] of [...pending]) {
38
+ if (timer.due <= now) {
39
+ pending.delete(id);
40
+ timer.run();
41
+ }
42
+ }
43
+ },
44
+ get armed(): number {
45
+ return pending.size;
46
+ },
47
+ };
48
+ }
49
+
50
+ /** Let the generator's own pump run: the clock here is manual, real time is not. */
51
+ async function settle(): Promise<void> {
52
+ for (let tick = 0; tick < 10; tick += 1) {
53
+ await new Promise((resolve) => setTimeout(resolve, 0));
54
+ }
55
+ }
56
+
57
+ async function collect(iterable: AsyncIterable<unknown>): Promise<unknown[]> {
58
+ const events: unknown[] = [];
59
+ for await (const event of iterable) events.push(event);
60
+ return events;
61
+ }
62
+
63
+ describe("a model request that produces nothing", () => {
64
+ test("is abandoned at the first-byte deadline, naming the deadline as the reason", async () => {
65
+ const clock = manualClock();
66
+ const provider = new OpenAICompatibleProvider({
67
+ baseUrl: "https://models.example",
68
+ deadlines: { firstByteMs: 120_000, idleMs: 120_000 },
69
+ schedule: clock.schedule,
70
+ fetch: (_input, init) =>
71
+ new Promise((_resolve, reject) => {
72
+ init?.signal?.addEventListener("abort", () =>
73
+ reject(new Error("aborted")),
74
+ );
75
+ }),
76
+ });
77
+
78
+ const streaming = collect(
79
+ provider.stream(request, new AbortController().signal),
80
+ );
81
+ // The provider has been sent the request and has said nothing.
82
+ clock.advance(120_000);
83
+
84
+ const failure = await streaming.then(
85
+ () => undefined,
86
+ (error: unknown) => error,
87
+ );
88
+ expect(failure).toBeInstanceOf(ModelRequestDeadlineError);
89
+ expect((failure as ModelRequestDeadlineError).phase).toBe("first-byte");
90
+ expect((failure as Error).message).toBe(
91
+ "Model request produced nothing within 120s",
92
+ );
93
+ });
94
+ });
95
+
96
+ describe("a model response that stalls mid-answer", () => {
97
+ test("is abandoned at the idle deadline, not at the first-byte one", async () => {
98
+ const clock = manualClock();
99
+ let push: ((chunk: string) => void) | undefined;
100
+ const body = new ReadableStream<Uint8Array>({
101
+ start(controller) {
102
+ push = (chunk) => controller.enqueue(new TextEncoder().encode(chunk));
103
+ },
104
+ });
105
+ const provider = new OpenAICompatibleProvider({
106
+ baseUrl: "https://models.example",
107
+ deadlines: { firstByteMs: 10_000, idleMs: 60_000 },
108
+ schedule: clock.schedule,
109
+ fetch: () => Promise.resolve(new Response(body, { status: 200 })),
110
+ });
111
+
112
+ const events: unknown[] = [];
113
+ const streaming = (async () => {
114
+ for await (const event of provider.stream(
115
+ request,
116
+ new AbortController().signal,
117
+ )) {
118
+ events.push(event);
119
+ }
120
+ })();
121
+
122
+ push?.(
123
+ 'data: {"choices":[{"delta":{"content":"Half a "}}]}\n\ndata: {"choices":[{"delta":{"content":"thought"}}]}\n\n',
124
+ );
125
+ await settle();
126
+
127
+ // Past the first-byte allowance, inside the idle one: the answer started,
128
+ // so the clock it is being held to is the idle one.
129
+ clock.advance(10_000);
130
+ await settle();
131
+ clock.advance(60_000);
132
+
133
+ const failure = await streaming.then(
134
+ () => undefined,
135
+ (error: unknown) => error,
136
+ );
137
+ expect(failure).toBeInstanceOf(ModelRequestDeadlineError);
138
+ expect((failure as ModelRequestDeadlineError).phase).toBe("idle");
139
+ expect(events.length).toBeGreaterThan(0);
140
+ });
141
+ });
142
+
143
+ describe("a model request that finishes", () => {
144
+ test("leaves no timer armed", async () => {
145
+ const clock = manualClock();
146
+ const provider = new OpenAICompatibleProvider({
147
+ baseUrl: "https://models.example",
148
+ schedule: clock.schedule,
149
+ fetch: () =>
150
+ Promise.resolve(
151
+ new Response(
152
+ 'data: {"choices":[{"delta":{"content":"hi"},"finish_reason":"stop"}]}\n\ndata: [DONE]\n\n',
153
+ { status: 200 },
154
+ ),
155
+ ),
156
+ });
157
+
158
+ const events = await collect(
159
+ provider.stream(request, new AbortController().signal),
160
+ );
161
+
162
+ expect(events.length).toBeGreaterThan(0);
163
+ // A live timer in a Worker isolate holds the request open long after
164
+ // anybody is listening for it.
165
+ expect(clock.armed).toBe(0);
166
+ });
167
+
168
+ test("a caller's own Stop still reports the cancellation, not a deadline", async () => {
169
+ const clock = manualClock();
170
+ const caller = new AbortController();
171
+ const provider = new OpenAICompatibleProvider({
172
+ baseUrl: "https://models.example",
173
+ schedule: clock.schedule,
174
+ fetch: (_input, init) =>
175
+ new Promise((_resolve, reject) => {
176
+ init?.signal?.addEventListener("abort", () =>
177
+ reject(new Error("aborted by caller")),
178
+ );
179
+ }),
180
+ });
181
+
182
+ const streaming = collect(provider.stream(request, caller.signal));
183
+ caller.abort(new Error("Stop"));
184
+
185
+ const failure = await streaming.then(
186
+ () => undefined,
187
+ (error: unknown) => error,
188
+ );
189
+ expect(failure).not.toBeInstanceOf(ModelRequestDeadlineError);
190
+ expect(clock.armed).toBe(0);
191
+ });
192
+ });
package/src/index.test.ts CHANGED
@@ -224,7 +224,7 @@ describe("OpenAICompatibleProvider", () => {
224
224
  expect(cancelled).toBe(true);
225
225
  });
226
226
 
227
- test("reports retrieval unavailable without another provider request", async () => {
227
+ test("reports retrieval as not retrievable without another provider request", async () => {
228
228
  let requests = 0;
229
229
  const provider = new OpenAICompatibleProvider({
230
230
  baseUrl: "https://models.example/v1",
@@ -243,7 +243,7 @@ describe("OpenAICompatibleProvider", () => {
243
243
  );
244
244
 
245
245
  expect(outcome).toEqual({
246
- status: "unavailable",
246
+ status: "not-retrievable",
247
247
  reason:
248
248
  'LLM provider "openai-compatible" does not support provider-bound retrieval',
249
249
  });
package/src/index.ts CHANGED
@@ -2,6 +2,9 @@ import {
2
2
  type LlmMessage,
3
3
  type LlmProvider,
4
4
  type LlmStreamEvent,
5
+ MODEL_REQUEST_DEADLINES_V1,
6
+ type ModelRequestDeadlinesV1,
7
+ ModelRequestDeadlineError,
5
8
  type NormalizedModelRequest,
6
9
  } from "@frockbot/kernel-contracts";
7
10
  import type { Plugin } from "cordis";
@@ -32,6 +35,82 @@ export interface OpenAICompatibleConfig {
32
35
  * model id decides through {@link modelAcceptsImagesV1}.
33
36
  */
34
37
  acceptsImages?: boolean;
38
+ /** Overrides {@link MODEL_REQUEST_DEADLINES_V1}. */
39
+ deadlines?: Partial<ModelRequestDeadlinesV1>;
40
+ /**
41
+ * Timer seam, so a deadline test does not have to wait two minutes for one.
42
+ * Defaults to `setTimeout`.
43
+ */
44
+ schedule?: (run: () => void, milliseconds: number) => () => void;
45
+ }
46
+
47
+ /**
48
+ * A request's clock, watching for silence.
49
+ *
50
+ * One controller, aborted with a {@link ModelRequestDeadlineError} when the
51
+ * provider says nothing for too long, and rearmed on every stream event. It
52
+ * chains the caller's signal so a Stop still cancels immediately, and it is
53
+ * always disarmed in a `finally`: a live timer in a Worker isolate holds the
54
+ * request open long after anyone is listening.
55
+ */
56
+ class ModelRequestClockV1 {
57
+ readonly #controller = new AbortController();
58
+ readonly #deadlines: ModelRequestDeadlinesV1;
59
+ readonly #schedule: (run: () => void, milliseconds: number) => () => void;
60
+ #cancelTimer: (() => void) | undefined;
61
+ #disarmed = false;
62
+
63
+ constructor(
64
+ caller: AbortSignal,
65
+ deadlines: ModelRequestDeadlinesV1,
66
+ schedule: (run: () => void, milliseconds: number) => () => void,
67
+ ) {
68
+ this.#deadlines = deadlines;
69
+ this.#schedule = schedule;
70
+ if (caller.aborted) this.#controller.abort(caller.reason);
71
+ else {
72
+ caller.addEventListener(
73
+ "abort",
74
+ () => this.#controller.abort(caller.reason),
75
+ { once: true },
76
+ );
77
+ }
78
+ this.#arm("first-byte");
79
+ }
80
+
81
+ get signal(): AbortSignal {
82
+ return this.#controller.signal;
83
+ }
84
+
85
+ /** A stream event arrived: the clock restarts on the idle allowance. */
86
+ progressed(): void {
87
+ this.#arm("idle");
88
+ }
89
+
90
+ disarm(): void {
91
+ this.#disarmed = true;
92
+ this.#cancelTimer?.();
93
+ this.#cancelTimer = undefined;
94
+ }
95
+
96
+ #arm(phase: "first-byte" | "idle"): void {
97
+ if (this.#disarmed) return;
98
+ this.#cancelTimer?.();
99
+ const milliseconds =
100
+ phase === "first-byte"
101
+ ? this.#deadlines.firstByteMs
102
+ : this.#deadlines.idleMs;
103
+ this.#cancelTimer = this.#schedule(() => {
104
+ this.#controller.abort(
105
+ new ModelRequestDeadlineError(phase, milliseconds),
106
+ );
107
+ }, milliseconds);
108
+ }
109
+ }
110
+
111
+ function defaultScheduleV1(run: () => void, milliseconds: number): () => void {
112
+ const timer = setTimeout(run, milliseconds);
113
+ return () => clearTimeout(timer);
35
114
  }
36
115
 
37
116
  interface ToolAccumulator {
@@ -363,26 +442,59 @@ export class OpenAICompatibleProvider implements LlmProvider {
363
442
  };
364
443
  if (this.config.apiKey)
365
444
  headers.authorization = `Bearer ${this.config.apiKey}`;
366
- const response = await fetcher(`${this.config.baseUrl}/chat/completions`, {
367
- method: "POST",
368
- headers,
369
- body: JSON.stringify(
370
- requestToWire(request, {
371
- ...(this.config.acceptsImages === undefined
372
- ? {}
373
- : { acceptsImages: this.config.acceptsImages }),
374
- }),
375
- ),
445
+ // Nothing here used to have a time bound: a provider that accepted the
446
+ // request and then went quiet held the Turn open for as long as the socket
447
+ // stayed up — seventeen minutes, in the incident this exists for, with
448
+ // nothing on the person's screen the whole time.
449
+ const clock = new ModelRequestClockV1(
376
450
  signal,
377
- });
378
- if (!response.ok) {
379
- await response.body?.cancel();
380
- throw new OpenAICompatibleHttpError(response.status);
381
- }
382
- if (!response.body)
383
- throw new Error("Model response did not include a stream");
451
+ { ...MODEL_REQUEST_DEADLINES_V1, ...this.config.deadlines },
452
+ this.config.schedule ?? defaultScheduleV1,
453
+ );
454
+ try {
455
+ const response = await fetcher(
456
+ `${this.config.baseUrl}/chat/completions`,
457
+ {
458
+ method: "POST",
459
+ headers,
460
+ body: JSON.stringify(
461
+ requestToWire(request, {
462
+ ...(this.config.acceptsImages === undefined
463
+ ? {}
464
+ : { acceptsImages: this.config.acceptsImages }),
465
+ }),
466
+ ),
467
+ signal: clock.signal,
468
+ },
469
+ );
470
+ if (!response.ok) {
471
+ await response.body?.cancel();
472
+ throw new OpenAICompatibleHttpError(response.status);
473
+ }
474
+ if (!response.body)
475
+ throw new Error("Model response did not include a stream");
384
476
 
385
- yield* streamOpenAICompatibleBody(response.body, signal);
477
+ for await (const event of streamOpenAICompatibleBody(
478
+ response.body,
479
+ clock.signal,
480
+ )) {
481
+ clock.progressed();
482
+ yield event;
483
+ }
484
+ } catch (error) {
485
+ // The abort reason is the real failure; `AbortError` is only how it
486
+ // reached us. Without this the Turn reports a cancellation nobody asked
487
+ // for instead of the deadline it actually hit.
488
+ if (
489
+ clock.signal.reason instanceof ModelRequestDeadlineError &&
490
+ !signal.aborted
491
+ ) {
492
+ throw clock.signal.reason;
493
+ }
494
+ throw error;
495
+ } finally {
496
+ clock.disarm();
497
+ }
386
498
  }
387
499
  }
388
500