@frockbot/provider-openai-compatible 0.3.7 → 0.3.9

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.7",
3
+ "version": "0.3.9",
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.7",
15
- "@frockbot/plugin-models": "0.3.7",
14
+ "@frockbot/kernel-contracts": "0.3.9",
15
+ "@frockbot/plugin-models": "0.3.9",
16
16
  "cordis": "4.0.0-rc.8"
17
17
  },
18
18
  "devDependencies": {
@@ -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
- "Model request produced nothing within 120s",
92
+ MODEL_FIRST_BYTE_DEADLINE_REASON_V1,
92
93
  );
93
94
  });
94
95
  });
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?: (run: () => void, milliseconds: number) => () => void;
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
- const clock = new ModelRequestClockV1(
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
- { ...MODEL_REQUEST_DEADLINES_V1, ...this.config.deadlines },
452
- this.config.schedule ?? defaultScheduleV1,
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