@frockbot/provider-openai-compatible 0.3.15 → 0.3.17
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/index.test.ts +157 -0
- package/src/index.ts +294 -24
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.17",
|
|
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.17",
|
|
15
|
+
"@frockbot/plugin-models": "0.3.17",
|
|
16
16
|
"cordis": "4.0.0-rc.8"
|
|
17
17
|
},
|
|
18
18
|
"devDependencies": {
|
package/src/index.test.ts
CHANGED
|
@@ -7,8 +7,10 @@ import { LlmRegistry } from "@frockbot/plugin-models";
|
|
|
7
7
|
import { Context } from "cordis";
|
|
8
8
|
import {
|
|
9
9
|
OpenAICompatibleProvider,
|
|
10
|
+
planOpenAICompatibleRequestV1,
|
|
10
11
|
requestToWire,
|
|
11
12
|
retryAfterMillisecondsV1,
|
|
13
|
+
usageFromPayloadV1,
|
|
12
14
|
} from "./index.js";
|
|
13
15
|
|
|
14
16
|
const request: NormalizedModelRequest = {
|
|
@@ -41,10 +43,82 @@ const request: NormalizedModelRequest = {
|
|
|
41
43
|
};
|
|
42
44
|
|
|
43
45
|
describe("OpenAICompatibleProvider", () => {
|
|
46
|
+
test("maps schemas for OpenAI and OpenRouter-compatible endpoints", () => {
|
|
47
|
+
const structured = {
|
|
48
|
+
...request,
|
|
49
|
+
responseFormat: {
|
|
50
|
+
type: "json_schema" as const,
|
|
51
|
+
name: "answer",
|
|
52
|
+
schema: {
|
|
53
|
+
type: "object" as const,
|
|
54
|
+
properties: { answer: { type: "string" as const } },
|
|
55
|
+
required: ["answer"],
|
|
56
|
+
additionalProperties: false,
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
const plan = planOpenAICompatibleRequestV1(structured, {
|
|
61
|
+
structuredOutput: "json_schema",
|
|
62
|
+
responseFormatDialect: "openai",
|
|
63
|
+
});
|
|
64
|
+
expect(plan.note).toBeUndefined();
|
|
65
|
+
expect(plan.body.response_format).toEqual({
|
|
66
|
+
type: "json_schema",
|
|
67
|
+
json_schema: {
|
|
68
|
+
name: "answer",
|
|
69
|
+
strict: true,
|
|
70
|
+
schema: structured.responseFormat.schema,
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
expect(plan.body.stream).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("maps the direct Workers AI schema and disables streaming", () => {
|
|
77
|
+
const structured: NormalizedModelRequest = {
|
|
78
|
+
...request,
|
|
79
|
+
responseFormat: {
|
|
80
|
+
type: "json_schema",
|
|
81
|
+
name: "answer",
|
|
82
|
+
schema: { type: "string" },
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
const plan = planOpenAICompatibleRequestV1(structured, {
|
|
86
|
+
structuredOutput: "json_schema",
|
|
87
|
+
responseFormatDialect: "workers-ai",
|
|
88
|
+
});
|
|
89
|
+
expect(plan.body.response_format).toEqual({
|
|
90
|
+
type: "json_schema",
|
|
91
|
+
json_schema: { type: "string" },
|
|
92
|
+
});
|
|
93
|
+
expect(plan.body.stream).toBe(false);
|
|
94
|
+
expect(plan.body.stream_options).toBeUndefined();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("downgrades unsupported schemas explicitly and adds prompt guidance", () => {
|
|
98
|
+
const plan = planOpenAICompatibleRequestV1(
|
|
99
|
+
{
|
|
100
|
+
...request,
|
|
101
|
+
responseFormat: {
|
|
102
|
+
type: "json_schema",
|
|
103
|
+
name: "answer",
|
|
104
|
+
schema: { type: "string" },
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
{ structuredOutput: "none" },
|
|
108
|
+
);
|
|
109
|
+
expect(plan.note).toMatchObject({
|
|
110
|
+
code: "structured-output-downgraded",
|
|
111
|
+
effective: "prompt",
|
|
112
|
+
});
|
|
113
|
+
expect(JSON.stringify(plan.body.messages)).toContain("Return only JSON");
|
|
114
|
+
expect(plan.body.response_format).toBeUndefined();
|
|
115
|
+
});
|
|
116
|
+
|
|
44
117
|
test("normalizes FrockBot messages and tools to the wire format", () => {
|
|
45
118
|
expect(requestToWire(request)).toMatchObject({
|
|
46
119
|
model: "test-model",
|
|
47
120
|
stream: true,
|
|
121
|
+
stream_options: { include_usage: true },
|
|
48
122
|
messages: [
|
|
49
123
|
{ role: "system", content: "Be useful." },
|
|
50
124
|
{ role: "user", content: "What time is it?" },
|
|
@@ -69,12 +143,49 @@ describe("OpenAICompatibleProvider", () => {
|
|
|
69
143
|
});
|
|
70
144
|
});
|
|
71
145
|
|
|
146
|
+
test("normalizes OpenAI token details", () => {
|
|
147
|
+
expect(
|
|
148
|
+
usageFromPayloadV1({
|
|
149
|
+
usage: {
|
|
150
|
+
prompt_tokens: 80,
|
|
151
|
+
completion_tokens: 20,
|
|
152
|
+
prompt_tokens_details: { cached_tokens: 32 },
|
|
153
|
+
completion_tokens_details: { reasoning_tokens: 7 },
|
|
154
|
+
},
|
|
155
|
+
}),
|
|
156
|
+
).toEqual({
|
|
157
|
+
type: "usage",
|
|
158
|
+
usage: {
|
|
159
|
+
inputTokens: 80,
|
|
160
|
+
outputTokens: 20,
|
|
161
|
+
cachedInputTokens: 32,
|
|
162
|
+
reasoningTokens: 7,
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("normalizes Workers AI and Ollama token fields", () => {
|
|
168
|
+
expect(
|
|
169
|
+
usageFromPayloadV1({ usage: { input_tokens: 12, output_tokens: 4 } }),
|
|
170
|
+
).toEqual({
|
|
171
|
+
type: "usage",
|
|
172
|
+
usage: { inputTokens: 12, outputTokens: 4 },
|
|
173
|
+
});
|
|
174
|
+
expect(
|
|
175
|
+
usageFromPayloadV1({ prompt_eval_count: 18, eval_count: 6 }),
|
|
176
|
+
).toEqual({
|
|
177
|
+
type: "usage",
|
|
178
|
+
usage: { inputTokens: 18, outputTokens: 6 },
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
72
182
|
test("streams text and assembles fragmented tool calls", async () => {
|
|
73
183
|
const encoder = new TextEncoder();
|
|
74
184
|
const payloads = [
|
|
75
185
|
'data: {"choices":[{"delta":{"content":"Checking "}}]}\n\n',
|
|
76
186
|
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call-1","function":{"name":"current_","arguments":"{\\"zone\\":"}}]}}]}\n\n',
|
|
77
187
|
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"name":"time","arguments":"\\"UTC\\"}"}}]},"finish_reason":"tool_calls"}]}\n\n',
|
|
188
|
+
'data: {"choices":[],"usage":{"prompt_tokens":40,"completion_tokens":9,"prompt_tokens_details":{"cached_tokens":10}}}\n\n',
|
|
78
189
|
"data: [DONE]\n\n",
|
|
79
190
|
];
|
|
80
191
|
let capturedUrl = "";
|
|
@@ -120,6 +231,10 @@ describe("OpenAICompatibleProvider", () => {
|
|
|
120
231
|
expect(capturedIdempotencyKey).toBeNull();
|
|
121
232
|
expect(events).toEqual([
|
|
122
233
|
{ type: "text-delta", text: "Checking " },
|
|
234
|
+
{
|
|
235
|
+
type: "usage",
|
|
236
|
+
usage: { inputTokens: 40, outputTokens: 9, cachedInputTokens: 10 },
|
|
237
|
+
},
|
|
123
238
|
{
|
|
124
239
|
type: "tool-call",
|
|
125
240
|
call: {
|
|
@@ -132,6 +247,48 @@ describe("OpenAICompatibleProvider", () => {
|
|
|
132
247
|
]);
|
|
133
248
|
});
|
|
134
249
|
|
|
250
|
+
test("normalizes a non-streaming structured response", async () => {
|
|
251
|
+
const provider = new OpenAICompatibleProvider({
|
|
252
|
+
baseUrl: "https://models.example/v1",
|
|
253
|
+
structuredOutput: "json_schema",
|
|
254
|
+
responseFormatDialect: "workers-ai",
|
|
255
|
+
fetch: () =>
|
|
256
|
+
Promise.resolve(
|
|
257
|
+
Response.json({
|
|
258
|
+
usage: { input_tokens: 14, output_tokens: 5 },
|
|
259
|
+
choices: [
|
|
260
|
+
{
|
|
261
|
+
message: { content: '{"answer":"yes"}' },
|
|
262
|
+
finish_reason: "stop",
|
|
263
|
+
},
|
|
264
|
+
],
|
|
265
|
+
}),
|
|
266
|
+
),
|
|
267
|
+
});
|
|
268
|
+
const events = [];
|
|
269
|
+
for await (const event of provider.stream(
|
|
270
|
+
{
|
|
271
|
+
...request,
|
|
272
|
+
responseFormat: {
|
|
273
|
+
type: "json_schema",
|
|
274
|
+
name: "answer",
|
|
275
|
+
schema: { type: "object", additionalProperties: true },
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
new AbortController().signal,
|
|
279
|
+
)) {
|
|
280
|
+
events.push(event);
|
|
281
|
+
}
|
|
282
|
+
expect(events).toEqual([
|
|
283
|
+
{
|
|
284
|
+
type: "usage",
|
|
285
|
+
usage: { inputTokens: 14, outputTokens: 5 },
|
|
286
|
+
},
|
|
287
|
+
{ type: "text-delta", text: '{"answer":"yes"}' },
|
|
288
|
+
{ type: "finish", reason: "completed" },
|
|
289
|
+
]);
|
|
290
|
+
});
|
|
291
|
+
|
|
135
292
|
test("rejects a truncated stream without a terminal marker", async () => {
|
|
136
293
|
const provider = new OpenAICompatibleProvider({
|
|
137
294
|
baseUrl: "https://models.example/v1",
|
package/src/index.ts
CHANGED
|
@@ -9,6 +9,8 @@ import {
|
|
|
9
9
|
ModelProviderFailureError,
|
|
10
10
|
type ModelProviderFailureClassV1,
|
|
11
11
|
type NormalizedModelRequest,
|
|
12
|
+
type ResponseFormatNoteV1,
|
|
13
|
+
type StructuredOutputSupportV1,
|
|
12
14
|
} from "@frockbot/kernel-contracts";
|
|
13
15
|
import type { Plugin } from "cordis";
|
|
14
16
|
|
|
@@ -137,6 +139,10 @@ export interface OpenAICompatibleConfig {
|
|
|
137
139
|
* model id decides through {@link modelAcceptsImagesV1}.
|
|
138
140
|
*/
|
|
139
141
|
acceptsImages?: boolean;
|
|
142
|
+
/** The strongest structured-output mode this endpoint accepts. */
|
|
143
|
+
structuredOutput?: StructuredOutputSupportV1;
|
|
144
|
+
/** Workers AI takes the schema directly; OpenAI/OpenRouter wrap it by name. */
|
|
145
|
+
responseFormatDialect?: "openai" | "workers-ai";
|
|
140
146
|
/** Overrides {@link MODEL_REQUEST_DEADLINES_V1}. */
|
|
141
147
|
deadlines?: Partial<ModelRequestDeadlinesV1>;
|
|
142
148
|
/**
|
|
@@ -341,8 +347,27 @@ function messageToWire(
|
|
|
341
347
|
|
|
342
348
|
export function requestToWire(
|
|
343
349
|
request: NormalizedModelRequest,
|
|
344
|
-
options:
|
|
350
|
+
options: OpenAIRequestOptionsV1 = {},
|
|
345
351
|
): Record<string, unknown> {
|
|
352
|
+
return planOpenAICompatibleRequestV1(request, options).body;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export interface OpenAIRequestOptionsV1 {
|
|
356
|
+
acceptsImages?: boolean;
|
|
357
|
+
structuredOutput?: StructuredOutputSupportV1;
|
|
358
|
+
responseFormatDialect?: "openai" | "workers-ai";
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export interface OpenAIRequestPlanV1 {
|
|
362
|
+
body: Record<string, unknown>;
|
|
363
|
+
note?: ResponseFormatNoteV1;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Maps the provider-neutral format and records any fidelity downgrade. */
|
|
367
|
+
export function planOpenAICompatibleRequestV1(
|
|
368
|
+
request: NormalizedModelRequest,
|
|
369
|
+
options: OpenAIRequestOptionsV1 = {},
|
|
370
|
+
): OpenAIRequestPlanV1 {
|
|
346
371
|
const acceptsImages =
|
|
347
372
|
options.acceptsImages ?? modelAcceptsImagesV1(request.model);
|
|
348
373
|
const messages: Record<string, unknown>[] = [];
|
|
@@ -351,22 +376,82 @@ export function requestToWire(
|
|
|
351
376
|
for (const message of request.messages) {
|
|
352
377
|
messages.push(...messageToWire(message, acceptsImages));
|
|
353
378
|
}
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
379
|
+
const support = options.structuredOutput ?? "none";
|
|
380
|
+
const format = request.responseFormat;
|
|
381
|
+
let responseFormat: Record<string, unknown> | undefined;
|
|
382
|
+
let note: ResponseFormatNoteV1 | undefined;
|
|
383
|
+
if (format?.type === "json_schema" && support === "json_schema") {
|
|
384
|
+
responseFormat =
|
|
385
|
+
options.responseFormatDialect === "workers-ai"
|
|
386
|
+
? { type: "json_schema", json_schema: format.schema }
|
|
387
|
+
: {
|
|
388
|
+
type: "json_schema",
|
|
389
|
+
json_schema: {
|
|
390
|
+
name: format.name,
|
|
391
|
+
strict: true,
|
|
392
|
+
schema: format.schema,
|
|
366
393
|
},
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
|
|
394
|
+
};
|
|
395
|
+
} else if (format && support !== "none") {
|
|
396
|
+
responseFormat = { type: "json_object" };
|
|
397
|
+
if (format.type === "json_schema") {
|
|
398
|
+
note = {
|
|
399
|
+
code: "structured-output-downgraded",
|
|
400
|
+
requested: "json_schema",
|
|
401
|
+
effective: "json",
|
|
402
|
+
message: `Provider ${request.provider} supports JSON mode but not JSON Schema; the shared validator remains authoritative`,
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
} else if (format) {
|
|
406
|
+
note = {
|
|
407
|
+
code: "structured-output-downgraded",
|
|
408
|
+
requested: format.type,
|
|
409
|
+
effective: "prompt",
|
|
410
|
+
message: `Provider ${request.provider} has no native structured-output mode; the request uses prompt guidance and shared validation`,
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
if (format) {
|
|
414
|
+
const instruction =
|
|
415
|
+
format.type === "json_schema"
|
|
416
|
+
? `Return only JSON matching this schema exactly: ${JSON.stringify(format.schema)}`
|
|
417
|
+
: "Return only one valid JSON value, with no Markdown or commentary.";
|
|
418
|
+
if (request.system) {
|
|
419
|
+
messages[0] = {
|
|
420
|
+
role: "system",
|
|
421
|
+
content: `${request.system}\n\n${instruction}`,
|
|
422
|
+
};
|
|
423
|
+
} else {
|
|
424
|
+
messages.unshift({ role: "system", content: instruction });
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
const stream = !(
|
|
428
|
+
format &&
|
|
429
|
+
support !== "none" &&
|
|
430
|
+
options.responseFormatDialect === "workers-ai"
|
|
431
|
+
);
|
|
432
|
+
return {
|
|
433
|
+
body: {
|
|
434
|
+
model: request.model,
|
|
435
|
+
// Workers AI documents JSON mode as non-streaming. The decoder accepts
|
|
436
|
+
// both response shapes while the contract remains an event stream.
|
|
437
|
+
stream,
|
|
438
|
+
...(stream ? { stream_options: { include_usage: true } } : {}),
|
|
439
|
+
messages,
|
|
440
|
+
...(responseFormat ? { response_format: responseFormat } : {}),
|
|
441
|
+
...(request.tools.length > 0
|
|
442
|
+
? {
|
|
443
|
+
tools: request.tools.map((tool) => ({
|
|
444
|
+
type: "function",
|
|
445
|
+
function: {
|
|
446
|
+
name: tool.name,
|
|
447
|
+
description: tool.description,
|
|
448
|
+
parameters: tool.inputSchema,
|
|
449
|
+
},
|
|
450
|
+
})),
|
|
451
|
+
}
|
|
452
|
+
: {}),
|
|
453
|
+
},
|
|
454
|
+
...(note ? { note } : {}),
|
|
370
455
|
};
|
|
371
456
|
}
|
|
372
457
|
|
|
@@ -433,6 +518,76 @@ function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
|
433
518
|
: undefined;
|
|
434
519
|
}
|
|
435
520
|
|
|
521
|
+
function usageIntegerV1(value: unknown, label: string): number | undefined {
|
|
522
|
+
if (value === undefined) return undefined;
|
|
523
|
+
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
|
524
|
+
throw new Error(`Model returned invalid ${label}`);
|
|
525
|
+
}
|
|
526
|
+
return value as number;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Normalizes token accounting returned by OpenAI-shaped, Workers AI/Gateway,
|
|
531
|
+
* and Ollama streams. Unknown payloads are ignored so the Agent loop can use
|
|
532
|
+
* its durable byte-size estimate.
|
|
533
|
+
*/
|
|
534
|
+
export function usageFromPayloadV1(
|
|
535
|
+
value: unknown,
|
|
536
|
+
): Extract<LlmStreamEvent, { type: "usage" }> | undefined {
|
|
537
|
+
const payload = asRecord(value);
|
|
538
|
+
if (!payload) return undefined;
|
|
539
|
+
const usage = asRecord(payload.usage) ?? payload;
|
|
540
|
+
const inputTokens = usageIntegerV1(
|
|
541
|
+
usage.prompt_tokens ??
|
|
542
|
+
usage.input_tokens ??
|
|
543
|
+
usage.prompt_eval_count ??
|
|
544
|
+
payload.prompt_eval_count,
|
|
545
|
+
"input token count",
|
|
546
|
+
);
|
|
547
|
+
const outputTokens = usageIntegerV1(
|
|
548
|
+
usage.completion_tokens ??
|
|
549
|
+
usage.output_tokens ??
|
|
550
|
+
usage.eval_count ??
|
|
551
|
+
payload.eval_count,
|
|
552
|
+
"output token count",
|
|
553
|
+
);
|
|
554
|
+
if (inputTokens === undefined || outputTokens === undefined) return undefined;
|
|
555
|
+
|
|
556
|
+
const inputDetails =
|
|
557
|
+
asRecord(usage.prompt_tokens_details) ??
|
|
558
|
+
asRecord(usage.input_tokens_details);
|
|
559
|
+
const outputDetails =
|
|
560
|
+
asRecord(usage.completion_tokens_details) ??
|
|
561
|
+
asRecord(usage.output_tokens_details);
|
|
562
|
+
const cachedInputTokens = usageIntegerV1(
|
|
563
|
+
inputDetails?.cached_tokens ?? usage.cached_input_tokens,
|
|
564
|
+
"cached input token count",
|
|
565
|
+
);
|
|
566
|
+
const reasoningTokens = usageIntegerV1(
|
|
567
|
+
outputDetails?.reasoning_tokens ?? usage.reasoning_tokens,
|
|
568
|
+
"reasoning token count",
|
|
569
|
+
);
|
|
570
|
+
if (cachedInputTokens !== undefined && cachedInputTokens > inputTokens) {
|
|
571
|
+
throw new Error(
|
|
572
|
+
"Model returned cached input tokens above total input tokens",
|
|
573
|
+
);
|
|
574
|
+
}
|
|
575
|
+
if (reasoningTokens !== undefined && reasoningTokens > outputTokens) {
|
|
576
|
+
throw new Error(
|
|
577
|
+
"Model returned reasoning tokens above total output tokens",
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
return {
|
|
581
|
+
type: "usage",
|
|
582
|
+
usage: {
|
|
583
|
+
inputTokens,
|
|
584
|
+
outputTokens,
|
|
585
|
+
...(cachedInputTokens === undefined ? {} : { cachedInputTokens }),
|
|
586
|
+
...(reasoningTokens === undefined ? {} : { reasoningTokens }),
|
|
587
|
+
},
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
|
|
436
591
|
function applyToolDeltas(
|
|
437
592
|
value: unknown,
|
|
438
593
|
tools: Map<number, ToolAccumulator>,
|
|
@@ -475,11 +630,39 @@ export async function* streamOpenAICompatibleBody(
|
|
|
475
630
|
body: ReadableStream<Uint8Array>,
|
|
476
631
|
signal: AbortSignal,
|
|
477
632
|
): AsyncIterable<LlmStreamEvent> {
|
|
633
|
+
const [probeBody, replayBody] = body.tee();
|
|
634
|
+
const probeReader = probeBody.getReader();
|
|
635
|
+
const probeDecoder = new TextDecoder();
|
|
636
|
+
let prefix = "";
|
|
637
|
+
const cancelProbe = (): void => {
|
|
638
|
+
void probeReader.cancel(signal.reason).catch(() => undefined);
|
|
639
|
+
if (!replayBody.locked) {
|
|
640
|
+
void replayBody.cancel(signal.reason).catch(() => undefined);
|
|
641
|
+
}
|
|
642
|
+
};
|
|
643
|
+
signal.addEventListener("abort", cancelProbe, { once: true });
|
|
644
|
+
try {
|
|
645
|
+
signal.throwIfAborted();
|
|
646
|
+
while (!prefix.trimStart() && prefix.length < 4_096) {
|
|
647
|
+
const { done, value } = await probeReader.read();
|
|
648
|
+
signal.throwIfAborted();
|
|
649
|
+
if (done) break;
|
|
650
|
+
prefix += probeDecoder.decode(value, { stream: true });
|
|
651
|
+
}
|
|
652
|
+
} finally {
|
|
653
|
+
signal.removeEventListener("abort", cancelProbe);
|
|
654
|
+
void probeReader.cancel().catch(() => undefined);
|
|
655
|
+
probeReader.releaseLock();
|
|
656
|
+
}
|
|
657
|
+
if (prefix.trimStart().startsWith("{")) {
|
|
658
|
+
yield* readOpenAICompatibleJsonV1(replayBody, signal);
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
478
661
|
const tools = new Map<number, ToolAccumulator>();
|
|
479
662
|
let finishReason: string | undefined;
|
|
480
663
|
let terminal = false;
|
|
481
664
|
let sawChoice = false;
|
|
482
|
-
for await (const data of readSseData(
|
|
665
|
+
for await (const data of readSseData(replayBody, signal)) {
|
|
483
666
|
if (data === "[DONE]") {
|
|
484
667
|
terminal = true;
|
|
485
668
|
break;
|
|
@@ -487,6 +670,8 @@ export async function* streamOpenAICompatibleBody(
|
|
|
487
670
|
const payload = asRecord(
|
|
488
671
|
parseJson(data, "Model returned an invalid stream event"),
|
|
489
672
|
);
|
|
673
|
+
const usage = usageFromPayloadV1(payload);
|
|
674
|
+
if (usage) yield usage;
|
|
490
675
|
const choices = payload?.choices;
|
|
491
676
|
const choice = Array.isArray(choices) ? asRecord(choices[0]) : undefined;
|
|
492
677
|
const delta = asRecord(choice?.delta);
|
|
@@ -534,6 +719,83 @@ export async function* streamOpenAICompatibleBody(
|
|
|
534
719
|
};
|
|
535
720
|
}
|
|
536
721
|
|
|
722
|
+
async function* readOpenAICompatibleJsonV1(
|
|
723
|
+
body: ReadableStream<Uint8Array>,
|
|
724
|
+
signal: AbortSignal,
|
|
725
|
+
): AsyncIterable<LlmStreamEvent> {
|
|
726
|
+
const reader = body.getReader();
|
|
727
|
+
const chunks: Uint8Array[] = [];
|
|
728
|
+
let bytes = 0;
|
|
729
|
+
try {
|
|
730
|
+
while (true) {
|
|
731
|
+
signal.throwIfAborted();
|
|
732
|
+
const { done, value } = await reader.read();
|
|
733
|
+
if (done) break;
|
|
734
|
+
bytes += value.byteLength;
|
|
735
|
+
if (bytes > MAX_SSE_RESPONSE_BYTES) await rejectOversizedSse(reader);
|
|
736
|
+
chunks.push(value);
|
|
737
|
+
}
|
|
738
|
+
} finally {
|
|
739
|
+
reader.releaseLock();
|
|
740
|
+
}
|
|
741
|
+
const combined = new Uint8Array(bytes);
|
|
742
|
+
let offset = 0;
|
|
743
|
+
for (const chunk of chunks) {
|
|
744
|
+
combined.set(chunk, offset);
|
|
745
|
+
offset += chunk.byteLength;
|
|
746
|
+
}
|
|
747
|
+
const payload = asRecord(
|
|
748
|
+
parseJson(
|
|
749
|
+
new TextDecoder().decode(combined),
|
|
750
|
+
"Model returned an invalid response",
|
|
751
|
+
),
|
|
752
|
+
);
|
|
753
|
+
const choices = payload?.choices;
|
|
754
|
+
const choice = Array.isArray(choices) ? asRecord(choices[0]) : undefined;
|
|
755
|
+
const message = asRecord(choice?.message);
|
|
756
|
+
if (!choice || !message) {
|
|
757
|
+
throw new Error("Model response did not include a valid choice");
|
|
758
|
+
}
|
|
759
|
+
const usage = usageFromPayloadV1(payload);
|
|
760
|
+
if (usage) yield usage;
|
|
761
|
+
if (typeof message.content === "string" && message.content) {
|
|
762
|
+
yield { type: "text-delta", text: message.content };
|
|
763
|
+
}
|
|
764
|
+
const tools = new Map<number, ToolAccumulator>();
|
|
765
|
+
if (Array.isArray(message.tool_calls)) {
|
|
766
|
+
applyToolDeltas(
|
|
767
|
+
message.tool_calls.map((candidate, index) => ({
|
|
768
|
+
...(asRecord(candidate) ?? {}),
|
|
769
|
+
index,
|
|
770
|
+
})),
|
|
771
|
+
tools,
|
|
772
|
+
);
|
|
773
|
+
}
|
|
774
|
+
for (const tool of [...tools.values()].sort(
|
|
775
|
+
(left, right) => left.index - right.index,
|
|
776
|
+
)) {
|
|
777
|
+
if (!tool.name)
|
|
778
|
+
throw new Error("Model returned a tool call without a name");
|
|
779
|
+
yield {
|
|
780
|
+
type: "tool-call",
|
|
781
|
+
call: {
|
|
782
|
+
id: tool.id || crypto.randomUUID(),
|
|
783
|
+
name: tool.name,
|
|
784
|
+
input: parseToolInput(tool.arguments),
|
|
785
|
+
},
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
yield {
|
|
789
|
+
type: "finish",
|
|
790
|
+
reason:
|
|
791
|
+
tools.size > 0 || choice.finish_reason === "tool_calls"
|
|
792
|
+
? "tool-calls"
|
|
793
|
+
: choice.finish_reason === "length"
|
|
794
|
+
? "max-tokens"
|
|
795
|
+
: "completed",
|
|
796
|
+
};
|
|
797
|
+
}
|
|
798
|
+
|
|
537
799
|
/**
|
|
538
800
|
* Wait for `opening`, but give up when the deadline clock does.
|
|
539
801
|
*
|
|
@@ -621,12 +883,16 @@ export async function* streamWithModelRequestDeadlinesV1(
|
|
|
621
883
|
|
|
622
884
|
export class OpenAICompatibleProvider implements LlmProvider {
|
|
623
885
|
readonly id: string;
|
|
886
|
+
readonly supports;
|
|
624
887
|
private config: OpenAICompatibleConfig;
|
|
625
888
|
|
|
626
889
|
constructor(config: OpenAICompatibleConfig) {
|
|
627
890
|
if (!config.baseUrl.trim())
|
|
628
891
|
throw new Error("OpenAI-compatible baseUrl is required");
|
|
629
892
|
this.id = config.providerId ?? "openai-compatible";
|
|
893
|
+
this.supports = {
|
|
894
|
+
structuredOutput: config.structuredOutput ?? "none",
|
|
895
|
+
} as const;
|
|
630
896
|
this.config = { ...config, baseUrl: config.baseUrl.replace(/\/$/, "") };
|
|
631
897
|
}
|
|
632
898
|
|
|
@@ -634,6 +900,16 @@ export class OpenAICompatibleProvider implements LlmProvider {
|
|
|
634
900
|
request: NormalizedModelRequest,
|
|
635
901
|
signal: AbortSignal,
|
|
636
902
|
): AsyncIterable<LlmStreamEvent> {
|
|
903
|
+
const plan = planOpenAICompatibleRequestV1(request, {
|
|
904
|
+
...(this.config.acceptsImages === undefined
|
|
905
|
+
? {}
|
|
906
|
+
: { acceptsImages: this.config.acceptsImages }),
|
|
907
|
+
structuredOutput: this.supports.structuredOutput,
|
|
908
|
+
...(this.config.responseFormatDialect
|
|
909
|
+
? { responseFormatDialect: this.config.responseFormatDialect }
|
|
910
|
+
: {}),
|
|
911
|
+
});
|
|
912
|
+
if (plan.note) yield { type: "response-format-note", note: plan.note };
|
|
637
913
|
// Workerd rejects a detached global `fetch` ("Illegal invocation"), so the
|
|
638
914
|
// default fetcher forwards through a closure rather than aliasing it.
|
|
639
915
|
const fetcher =
|
|
@@ -658,13 +934,7 @@ export class OpenAICompatibleProvider implements LlmProvider {
|
|
|
658
934
|
{
|
|
659
935
|
method: "POST",
|
|
660
936
|
headers,
|
|
661
|
-
body: JSON.stringify(
|
|
662
|
-
requestToWire(request, {
|
|
663
|
-
...(this.config.acceptsImages === undefined
|
|
664
|
-
? {}
|
|
665
|
-
: { acceptsImages: this.config.acceptsImages }),
|
|
666
|
-
}),
|
|
667
|
-
),
|
|
937
|
+
body: JSON.stringify(plan.body),
|
|
668
938
|
signal: deadlineSignal,
|
|
669
939
|
},
|
|
670
940
|
);
|