@frockbot/provider-openai-compatible 0.3.14 → 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 +3 -3
- package/src/deadlines.test.ts +5 -2
- package/src/index.test.ts +90 -6
- package/src/index.ts +149 -34
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.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.
|
|
15
|
-
"@frockbot/plugin-models": "0.3.
|
|
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": {
|
package/src/deadlines.test.ts
CHANGED
|
@@ -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(
|
|
90
|
-
expect((failure as
|
|
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 {
|
|
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 {
|
|
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(
|
|
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(
|
|
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
|
|
273
|
-
|
|
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
|
|
21
|
-
constructor(
|
|
22
|
-
|
|
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
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
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
|
|