@frockbot/provider-openai-compatible 0.3.5 → 0.3.6
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 +3 -3
- package/src/deadlines.test.ts +192 -0
- package/src/index.ts +130 -18
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/provider-openai-compatible",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.6",
|
|
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.
|
|
15
|
-
"@frockbot/plugin-models": "0.3.
|
|
14
|
+
"@frockbot/kernel-contracts": "0.3.6",
|
|
15
|
+
"@frockbot/plugin-models": "0.3.6",
|
|
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.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
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
-
|
|
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
|
|