@frockbot/provider-openai-compatible 0.3.13 → 0.3.15

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.13",
3
+ "version": "0.3.15",
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.13",
15
- "@frockbot/plugin-models": "0.3.13",
14
+ "@frockbot/kernel-contracts": "0.3.15",
15
+ "@frockbot/plugin-models": "0.3.15",
16
16
  "cordis": "4.0.0-rc.8"
17
17
  },
18
18
  "devDependencies": {
@@ -7,6 +7,7 @@
7
7
  import { describe, expect, test } from "bun:test";
8
8
  import {
9
9
  MODEL_FIRST_BYTE_DEADLINE_REASON_V1,
10
+ ModelProviderFailureError,
10
11
  ModelRequestDeadlineError,
11
12
  type NormalizedModelRequest,
12
13
  } from "@frockbot/kernel-contracts";
@@ -86,8 +87,10 @@ describe("a model request that produces nothing", () => {
86
87
  () => undefined,
87
88
  (error: unknown) => error,
88
89
  );
89
- expect(failure).toBeInstanceOf(ModelRequestDeadlineError);
90
- expect((failure as ModelRequestDeadlineError).phase).toBe("first-byte");
90
+ expect(failure).toBeInstanceOf(ModelProviderFailureError);
91
+ expect((failure as ModelProviderFailureError).classification).toBe(
92
+ "transient",
93
+ );
91
94
  expect((failure as Error).message).toBe(
92
95
  MODEL_FIRST_BYTE_DEADLINE_REASON_V1,
93
96
  );
package/src/index.test.ts CHANGED
@@ -1,8 +1,15 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { type NormalizedModelRequest } from "@frockbot/kernel-contracts";
2
+ import {
3
+ ModelProviderFailureError,
4
+ type NormalizedModelRequest,
5
+ } from "@frockbot/kernel-contracts";
3
6
  import { LlmRegistry } from "@frockbot/plugin-models";
4
7
  import { Context } from "cordis";
5
- import { OpenAICompatibleProvider, requestToWire } from "./index.js";
8
+ import {
9
+ OpenAICompatibleProvider,
10
+ requestToWire,
11
+ retryAfterMillisecondsV1,
12
+ } from "./index.js";
6
13
 
7
14
  const request: NormalizedModelRequest = {
8
15
  requestId: "request-1",
@@ -251,11 +258,29 @@ describe("OpenAICompatibleProvider", () => {
251
258
  await root.fiber.dispose();
252
259
  });
253
260
 
254
- test("redacts provider response bodies from HTTP errors", async () => {
261
+ test.each([
262
+ [400, "permanent"],
263
+ [401, "permanent"],
264
+ [403, "permanent"],
265
+ [404, "permanent"],
266
+ [413, "permanent"],
267
+ [429, "transient"],
268
+ [500, "transient"],
269
+ [503, "transient"],
270
+ [418, "unknown"],
271
+ ] as const)("classifies HTTP %i as %s", async (status, classification) => {
255
272
  const provider = new OpenAICompatibleProvider({
256
273
  baseUrl: "https://models.example/v1",
257
274
  fetch: () =>
258
- Promise.resolve(new Response("bad credentials", { status: 401 })),
275
+ Promise.resolve(
276
+ Response.json(
277
+ { error: { message: "provider reason", code: "provider_code" } },
278
+ {
279
+ status,
280
+ headers: status === 429 ? { "retry-after": "3" } : {},
281
+ },
282
+ ),
283
+ ),
259
284
  });
260
285
 
261
286
  let failure: unknown;
@@ -269,12 +294,71 @@ describe("OpenAICompatibleProvider", () => {
269
294
  } catch (error) {
270
295
  failure = error;
271
296
  }
272
- expect(failure instanceof Error ? failure.message : "").toBe(
273
- "Model request failed (401)",
297
+ expect(failure).toBeInstanceOf(ModelProviderFailureError);
298
+ expect((failure as ModelProviderFailureError).classification).toBe(
299
+ classification,
300
+ );
301
+ expect((failure as ModelProviderFailureError).providerReason).toContain(
302
+ "provider reason",
303
+ );
304
+ expect((failure as ModelProviderFailureError).retryAfterMs).toBe(
305
+ status === 429 ? 3_000 : undefined,
274
306
  );
275
307
  });
308
+
309
+ test("classifies content policy and network reset shapes", async () => {
310
+ const content = new OpenAICompatibleProvider({
311
+ baseUrl: "https://models.example/v1",
312
+ fetch: () =>
313
+ Promise.resolve(
314
+ Response.json(
315
+ {
316
+ error: {
317
+ message: "blocked",
318
+ code: "content_policy_violation",
319
+ },
320
+ },
321
+ { status: 422 },
322
+ ),
323
+ ),
324
+ });
325
+ const reset = new OpenAICompatibleProvider({
326
+ baseUrl: "https://models.example/v1",
327
+ fetch: () => Promise.reject(new TypeError("socket reset")),
328
+ });
329
+ for (const [provider, classification] of [
330
+ [content, "permanent"],
331
+ [reset, "transient"],
332
+ ] as const) {
333
+ const failure = await collectFailure(provider);
334
+ expect((failure as ModelProviderFailureError).classification).toBe(
335
+ classification,
336
+ );
337
+ }
338
+ });
339
+
340
+ test("parses both Retry-After forms", () => {
341
+ expect(retryAfterMillisecondsV1("1.5", 0)).toBe(1_500);
342
+ expect(
343
+ retryAfterMillisecondsV1("Thu, 01 Jan 1970 00:00:05 GMT", 2_000),
344
+ ).toBe(3_000);
345
+ });
276
346
  });
277
347
 
348
+ async function collectFailure(provider: OpenAICompatibleProvider) {
349
+ try {
350
+ for await (const event of provider.stream(
351
+ request,
352
+ new AbortController().signal,
353
+ )) {
354
+ void event;
355
+ }
356
+ } catch (error) {
357
+ return error;
358
+ }
359
+ throw new Error("provider did not fail");
360
+ }
361
+
278
362
  // An image a tool produced, on the wire.
279
363
  //
280
364
  // Two behaviours, and the difference between them has to be visible in the
package/src/index.ts CHANGED
@@ -1,10 +1,13 @@
1
1
  import {
2
+ boundedModelProviderReasonV1,
2
3
  type LlmMessage,
3
4
  type LlmProvider,
4
5
  type LlmStreamEvent,
5
6
  MODEL_REQUEST_DEADLINES_V1,
6
7
  type ModelRequestDeadlinesV1,
7
8
  ModelRequestDeadlineError,
9
+ ModelProviderFailureError,
10
+ type ModelProviderFailureClassV1,
8
11
  type NormalizedModelRequest,
9
12
  } from "@frockbot/kernel-contracts";
10
13
  import type { Plugin } from "cordis";
@@ -17,13 +20,112 @@ export type FetchLike = (
17
20
  init?: RequestInit,
18
21
  ) => Promise<Response>;
19
22
 
20
- export class OpenAICompatibleHttpError extends Error {
21
- constructor(readonly status: number) {
22
- super(`Model request failed (${status})`);
23
+ export class OpenAICompatibleHttpError extends ModelProviderFailureError {
24
+ constructor(
25
+ readonly status: number,
26
+ reason = `Model request failed (${status})`,
27
+ retryAfterMs?: number,
28
+ errorCode?: string,
29
+ ) {
30
+ super({
31
+ classification: classifyOpenAICompatibleFailureV1(status, errorCode),
32
+ reason,
33
+ ...(retryAfterMs === undefined ? {} : { retryAfterMs }),
34
+ });
23
35
  this.name = "OpenAICompatibleHttpError";
24
36
  }
25
37
  }
26
38
 
39
+ const CONTENT_POLICY_CODES = new Set([
40
+ "content_filter",
41
+ "content_policy_violation",
42
+ "moderation_blocked",
43
+ "safety_violation",
44
+ ]);
45
+
46
+ export function classifyOpenAICompatibleFailureV1(
47
+ status: number,
48
+ errorCode?: string,
49
+ ): ModelProviderFailureClassV1 {
50
+ if (errorCode && CONTENT_POLICY_CODES.has(errorCode.toLowerCase())) {
51
+ return "permanent";
52
+ }
53
+ if (status === 408 || status === 429 || status >= 500) return "transient";
54
+ if ([400, 401, 403, 404, 413].includes(status)) return "permanent";
55
+ return "unknown";
56
+ }
57
+
58
+ export function retryAfterMillisecondsV1(
59
+ value: string | null,
60
+ now = Date.now(),
61
+ ): number | undefined {
62
+ if (!value) return undefined;
63
+ const seconds = Number(value);
64
+ if (Number.isFinite(seconds) && seconds >= 0) {
65
+ return Math.ceil(seconds * 1000);
66
+ }
67
+ const at = Date.parse(value);
68
+ return Number.isFinite(at) ? Math.max(0, at - now) : undefined;
69
+ }
70
+
71
+ function openAIErrorDetailV1(text: string): {
72
+ reason?: string;
73
+ code?: string;
74
+ } {
75
+ if (!text.trim()) return {};
76
+ try {
77
+ const payload = JSON.parse(text) as unknown;
78
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
79
+ return { reason: text };
80
+ }
81
+ const error = (payload as Record<string, unknown>).error;
82
+ if (typeof error === "string") return { reason: error };
83
+ if (!error || typeof error !== "object" || Array.isArray(error)) return {};
84
+ const record = error as Record<string, unknown>;
85
+ return {
86
+ ...(typeof record.message === "string" ? { reason: record.message } : {}),
87
+ ...(typeof record.code === "string"
88
+ ? { code: record.code }
89
+ : typeof record.type === "string"
90
+ ? { code: record.type }
91
+ : {}),
92
+ };
93
+ } catch {
94
+ return { reason: text };
95
+ }
96
+ }
97
+
98
+ function networkFailureV1(error: unknown): ModelProviderFailureError {
99
+ const message = error instanceof Error ? error.message : String(error);
100
+ const name = error instanceof Error ? error.name : "";
101
+ const transient =
102
+ error instanceof TypeError ||
103
+ ["NetworkError", "TimeoutError"].includes(name) ||
104
+ /\b(?:ECONNRESET|ECONNREFUSED|EPIPE|ETIMEDOUT|network|socket|gateway timeout)\b/i.test(
105
+ message,
106
+ );
107
+ return new ModelProviderFailureError({
108
+ classification: transient ? "transient" : "unknown",
109
+ reason: message,
110
+ });
111
+ }
112
+
113
+ async function httpFailureV1(response: Response): Promise<never> {
114
+ const text = (await response.text().catch(() => "")).slice(0, 2_000);
115
+ const detail = openAIErrorDetailV1(text);
116
+ const reason = boundedModelProviderReasonV1(
117
+ detail.reason
118
+ ? `Model request failed (${response.status}): ${detail.reason}`
119
+ : `Model request failed (${response.status})`,
120
+ );
121
+ throw new OpenAICompatibleHttpError(
122
+ response.status,
123
+ reason,
124
+ retryAfterMillisecondsV1(response.headers.get("retry-after")),
125
+ detail.code,
126
+ );
127
+ }
128
+
27
129
  export interface OpenAICompatibleConfig {
28
130
  baseUrl: string;
29
131
  apiKey?: string;
@@ -548,37 +650,50 @@ export class OpenAICompatibleProvider implements LlmProvider {
548
650
  // request and then went quiet held the Turn open for as long as the socket
549
651
  // stayed up — seventeen minutes, in the incident this exists for, with
550
652
  // nothing on the person's screen the whole time.
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
- },
576
- signal,
577
- {
578
- ...(this.config.deadlines ? { deadlines: this.config.deadlines } : {}),
579
- ...(this.config.schedule ? { schedule: this.config.schedule } : {}),
580
- },
581
- );
653
+ try {
654
+ yield* streamWithModelRequestDeadlinesV1(
655
+ async (deadlineSignal) => {
656
+ const response = await fetcher(
657
+ `${this.config.baseUrl}/chat/completions`,
658
+ {
659
+ method: "POST",
660
+ headers,
661
+ body: JSON.stringify(
662
+ requestToWire(request, {
663
+ ...(this.config.acceptsImages === undefined
664
+ ? {}
665
+ : { acceptsImages: this.config.acceptsImages }),
666
+ }),
667
+ ),
668
+ signal: deadlineSignal,
669
+ },
670
+ );
671
+ if (!response.ok) await httpFailureV1(response);
672
+ if (!response.body)
673
+ throw new Error("Model response did not include a stream");
674
+ return response.body;
675
+ },
676
+ signal,
677
+ {
678
+ ...(this.config.deadlines
679
+ ? { deadlines: this.config.deadlines }
680
+ : {}),
681
+ ...(this.config.schedule ? { schedule: this.config.schedule } : {}),
682
+ },
683
+ );
684
+ } catch (error) {
685
+ if (signal.aborted || error instanceof ModelProviderFailureError) {
686
+ throw error;
687
+ }
688
+ if (error instanceof ModelRequestDeadlineError) {
689
+ if (error.phase === "idle") throw error;
690
+ throw new ModelProviderFailureError({
691
+ classification: "transient",
692
+ reason: error.message,
693
+ });
694
+ }
695
+ throw networkFailureV1(error);
696
+ }
582
697
  }
583
698
  }
584
699