@frockbot/provider-openai-compatible 0.3.6 → 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 +3 -3
- package/src/deadlines.test.ts +2 -1
- package/src/index.test.ts +2 -2
- package/src/index.ts +132 -48
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.8",
|
|
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.8",
|
|
15
|
+
"@frockbot/plugin-models": "0.3.8",
|
|
16
16
|
"cordis": "4.0.0-rc.8"
|
|
17
17
|
},
|
|
18
18
|
"devDependencies": {
|
package/src/deadlines.test.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// waiting two minutes for it.
|
|
7
7
|
import { describe, expect, test } from "bun:test";
|
|
8
8
|
import {
|
|
9
|
+
MODEL_FIRST_BYTE_DEADLINE_REASON_V1,
|
|
9
10
|
ModelRequestDeadlineError,
|
|
10
11
|
type NormalizedModelRequest,
|
|
11
12
|
} from "@frockbot/kernel-contracts";
|
|
@@ -88,7 +89,7 @@ describe("a model request that produces nothing", () => {
|
|
|
88
89
|
expect(failure).toBeInstanceOf(ModelRequestDeadlineError);
|
|
89
90
|
expect((failure as ModelRequestDeadlineError).phase).toBe("first-byte");
|
|
90
91
|
expect((failure as Error).message).toBe(
|
|
91
|
-
|
|
92
|
+
MODEL_FIRST_BYTE_DEADLINE_REASON_V1,
|
|
92
93
|
);
|
|
93
94
|
});
|
|
94
95
|
});
|
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
|
|
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: "
|
|
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
|
@@ -41,7 +41,24 @@ export interface OpenAICompatibleConfig {
|
|
|
41
41
|
* Timer seam, so a deadline test does not have to wait two minutes for one.
|
|
42
42
|
* Defaults to `setTimeout`.
|
|
43
43
|
*/
|
|
44
|
-
schedule?:
|
|
44
|
+
schedule?: ModelRequestScheduleV1;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* How a deadline arms its timer: run `run` after `milliseconds`, and return
|
|
49
|
+
* the cancel. Injected so a test can drive the deadlines by hand.
|
|
50
|
+
*/
|
|
51
|
+
export type ModelRequestScheduleV1 = (
|
|
52
|
+
run: () => void,
|
|
53
|
+
milliseconds: number,
|
|
54
|
+
) => () => void;
|
|
55
|
+
|
|
56
|
+
/** The deadline seam every transport's stream is wrapped in. */
|
|
57
|
+
export interface ModelRequestDeadlineOptionsV1 {
|
|
58
|
+
/** Overrides {@link MODEL_REQUEST_DEADLINES_V1}. */
|
|
59
|
+
deadlines?: Partial<ModelRequestDeadlinesV1>;
|
|
60
|
+
/** Timer seam. Defaults to `setTimeout`. */
|
|
61
|
+
schedule?: ModelRequestScheduleV1;
|
|
45
62
|
}
|
|
46
63
|
|
|
47
64
|
/**
|
|
@@ -415,6 +432,91 @@ export async function* streamOpenAICompatibleBody(
|
|
|
415
432
|
};
|
|
416
433
|
}
|
|
417
434
|
|
|
435
|
+
/**
|
|
436
|
+
* Wait for `opening`, but give up when the deadline clock does.
|
|
437
|
+
*
|
|
438
|
+
* A transport that cannot be handed a signal — a native binding, say — keeps
|
|
439
|
+
* running after we stop waiting for it, so whatever it eventually produces is
|
|
440
|
+
* cancelled rather than left holding a socket nobody reads.
|
|
441
|
+
*/
|
|
442
|
+
async function openWithinDeadlineV1(
|
|
443
|
+
opening: Promise<ReadableStream<Uint8Array>>,
|
|
444
|
+
signal: AbortSignal,
|
|
445
|
+
): Promise<ReadableStream<Uint8Array>> {
|
|
446
|
+
let abandon: (() => void) | undefined;
|
|
447
|
+
try {
|
|
448
|
+
return await new Promise<ReadableStream<Uint8Array>>((resolve, reject) => {
|
|
449
|
+
if (signal.aborted) {
|
|
450
|
+
reject(signal.reason as Error);
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
abandon = () => {
|
|
454
|
+
reject(signal.reason as Error);
|
|
455
|
+
void opening.then(
|
|
456
|
+
async (body) => {
|
|
457
|
+
// Only a body nobody is reading is ours to cancel; once the decoder
|
|
458
|
+
// holds the lock, its own abort handling closes the stream.
|
|
459
|
+
if (body.locked) return;
|
|
460
|
+
try {
|
|
461
|
+
await body.cancel(signal.reason);
|
|
462
|
+
} catch {
|
|
463
|
+
// A body already closed or errored needs no cancelling.
|
|
464
|
+
}
|
|
465
|
+
},
|
|
466
|
+
() => undefined,
|
|
467
|
+
);
|
|
468
|
+
};
|
|
469
|
+
signal.addEventListener("abort", abandon, { once: true });
|
|
470
|
+
opening.then(resolve, reject);
|
|
471
|
+
});
|
|
472
|
+
} finally {
|
|
473
|
+
// The listener goes with the wait it belonged to. Left attached, a later
|
|
474
|
+
// abort — the idle deadline, a Stop — would reject a promise nobody is
|
|
475
|
+
// waiting on any more, which every runtime reports as a crash.
|
|
476
|
+
if (abandon) signal.removeEventListener("abort", abandon);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Run one model request under the first-byte and idle deadlines.
|
|
482
|
+
*
|
|
483
|
+
* The single seam every transport goes through, HTTP or native binding: it
|
|
484
|
+
* owns the clock, so a Package supplying its own transport cannot forget the
|
|
485
|
+
* deadlines, and there is one place to change what they are. `open` is handed
|
|
486
|
+
* the deadline-aware signal and returns the response body to decode.
|
|
487
|
+
*/
|
|
488
|
+
export async function* streamWithModelRequestDeadlinesV1(
|
|
489
|
+
open: (signal: AbortSignal) => Promise<ReadableStream<Uint8Array>>,
|
|
490
|
+
signal: AbortSignal,
|
|
491
|
+
options: ModelRequestDeadlineOptionsV1 = {},
|
|
492
|
+
): AsyncIterable<LlmStreamEvent> {
|
|
493
|
+
const clock = new ModelRequestClockV1(
|
|
494
|
+
signal,
|
|
495
|
+
{ ...MODEL_REQUEST_DEADLINES_V1, ...options.deadlines },
|
|
496
|
+
options.schedule ?? defaultScheduleV1,
|
|
497
|
+
);
|
|
498
|
+
try {
|
|
499
|
+
const body = await openWithinDeadlineV1(open(clock.signal), clock.signal);
|
|
500
|
+
for await (const event of streamOpenAICompatibleBody(body, clock.signal)) {
|
|
501
|
+
clock.progressed();
|
|
502
|
+
yield event;
|
|
503
|
+
}
|
|
504
|
+
} catch (error) {
|
|
505
|
+
// The abort reason is the real failure; `AbortError` is only how it
|
|
506
|
+
// reached us. Without this the Turn reports a cancellation nobody asked
|
|
507
|
+
// for instead of the deadline it actually hit.
|
|
508
|
+
if (
|
|
509
|
+
clock.signal.reason instanceof ModelRequestDeadlineError &&
|
|
510
|
+
!signal.aborted
|
|
511
|
+
) {
|
|
512
|
+
throw clock.signal.reason;
|
|
513
|
+
}
|
|
514
|
+
throw error;
|
|
515
|
+
} finally {
|
|
516
|
+
clock.disarm();
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
418
520
|
export class OpenAICompatibleProvider implements LlmProvider {
|
|
419
521
|
readonly id: string;
|
|
420
522
|
private config: OpenAICompatibleConfig;
|
|
@@ -446,55 +548,37 @@ export class OpenAICompatibleProvider implements LlmProvider {
|
|
|
446
548
|
// request and then went quiet held the Turn open for as long as the socket
|
|
447
549
|
// stayed up — seventeen minutes, in the incident this exists for, with
|
|
448
550
|
// nothing on the person's screen the whole time.
|
|
449
|
-
|
|
551
|
+
yield* streamWithModelRequestDeadlinesV1(
|
|
552
|
+
async (deadlineSignal) => {
|
|
553
|
+
const response = await fetcher(
|
|
554
|
+
`${this.config.baseUrl}/chat/completions`,
|
|
555
|
+
{
|
|
556
|
+
method: "POST",
|
|
557
|
+
headers,
|
|
558
|
+
body: JSON.stringify(
|
|
559
|
+
requestToWire(request, {
|
|
560
|
+
...(this.config.acceptsImages === undefined
|
|
561
|
+
? {}
|
|
562
|
+
: { acceptsImages: this.config.acceptsImages }),
|
|
563
|
+
}),
|
|
564
|
+
),
|
|
565
|
+
signal: deadlineSignal,
|
|
566
|
+
},
|
|
567
|
+
);
|
|
568
|
+
if (!response.ok) {
|
|
569
|
+
await response.body?.cancel();
|
|
570
|
+
throw new OpenAICompatibleHttpError(response.status);
|
|
571
|
+
}
|
|
572
|
+
if (!response.body)
|
|
573
|
+
throw new Error("Model response did not include a stream");
|
|
574
|
+
return response.body;
|
|
575
|
+
},
|
|
450
576
|
signal,
|
|
451
|
-
{
|
|
452
|
-
|
|
577
|
+
{
|
|
578
|
+
...(this.config.deadlines ? { deadlines: this.config.deadlines } : {}),
|
|
579
|
+
...(this.config.schedule ? { schedule: this.config.schedule } : {}),
|
|
580
|
+
},
|
|
453
581
|
);
|
|
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");
|
|
476
|
-
|
|
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
|
-
}
|
|
498
582
|
}
|
|
499
583
|
}
|
|
500
584
|
|