@loomwork/llm 0.0.0-stage → 0.1.1
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/LICENSE +202 -0
- package/NOTICE +8 -0
- package/README.md +80 -2
- package/dist/assembleStream.d.ts +95 -0
- package/dist/assembleStream.d.ts.map +1 -0
- package/dist/assembleStream.js +180 -0
- package/dist/assembleStream.js.map +1 -0
- package/dist/capabilities.d.ts +43 -0
- package/dist/capabilities.d.ts.map +1 -0
- package/dist/capabilities.js +77 -0
- package/dist/capabilities.js.map +1 -0
- package/dist/createLlmEffect.d.ts +110 -0
- package/dist/createLlmEffect.d.ts.map +1 -0
- package/dist/createLlmEffect.js +177 -0
- package/dist/createLlmEffect.js.map +1 -0
- package/dist/errors.d.ts +96 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +280 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +71 -0
- package/dist/index.js.map +1 -0
- package/dist/parts.d.ts +220 -0
- package/dist/parts.d.ts.map +1 -0
- package/dist/parts.js +231 -0
- package/dist/parts.js.map +1 -0
- package/dist/providers/AnthropicProvider.d.ts +53 -0
- package/dist/providers/AnthropicProvider.d.ts.map +1 -0
- package/dist/providers/AnthropicProvider.js +443 -0
- package/dist/providers/AnthropicProvider.js.map +1 -0
- package/dist/providers/GeminiProvider.d.ts +66 -0
- package/dist/providers/GeminiProvider.d.ts.map +1 -0
- package/dist/providers/GeminiProvider.js +281 -0
- package/dist/providers/GeminiProvider.js.map +1 -0
- package/dist/providers/OpenAiProvider.d.ts +56 -0
- package/dist/providers/OpenAiProvider.d.ts.map +1 -0
- package/dist/providers/OpenAiProvider.js +402 -0
- package/dist/providers/OpenAiProvider.js.map +1 -0
- package/dist/providers/extraHeaders.d.ts +7 -0
- package/dist/providers/extraHeaders.d.ts.map +1 -0
- package/dist/providers/extraHeaders.js +40 -0
- package/dist/providers/extraHeaders.js.map +1 -0
- package/dist/providers/systemPrompt.d.ts +10 -0
- package/dist/providers/systemPrompt.d.ts.map +1 -0
- package/dist/providers/systemPrompt.js +22 -0
- package/dist/providers/systemPrompt.js.map +1 -0
- package/dist/reasoning.d.ts +154 -0
- package/dist/reasoning.d.ts.map +1 -0
- package/dist/reasoning.js +256 -0
- package/dist/reasoning.js.map +1 -0
- package/dist/resolveContent.d.ts +74 -0
- package/dist/resolveContent.d.ts.map +1 -0
- package/dist/resolveContent.js +335 -0
- package/dist/resolveContent.js.map +1 -0
- package/dist/types.d.ts +152 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/untrusted.d.ts +91 -0
- package/dist/untrusted.d.ts.map +1 -0
- package/dist/untrusted.js +385 -0
- package/dist/untrusted.js.map +1 -0
- package/package.json +54 -3
- package/src/assembleStream.ts +219 -0
- package/src/capabilities.ts +96 -0
- package/src/createLlmEffect.ts +287 -0
- package/src/errors.ts +269 -0
- package/src/index.ts +137 -0
- package/src/parts.ts +409 -0
- package/src/providers/AnthropicProvider.ts +550 -0
- package/src/providers/GeminiProvider.ts +380 -0
- package/src/providers/OpenAiProvider.ts +511 -0
- package/src/providers/extraHeaders.ts +41 -0
- package/src/providers/systemPrompt.ts +19 -0
- package/src/reasoning.ts +386 -0
- package/src/resolveContent.ts +425 -0
- package/src/types.ts +175 -0
- package/src/untrusted.ts +440 -0
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type LlmCapabilities,
|
|
3
|
+
assertRequestSupported,
|
|
4
|
+
capabilitiesForModel,
|
|
5
|
+
} from "../capabilities";
|
|
6
|
+
import {
|
|
7
|
+
DEFAULT_MAX_LINE_BYTES,
|
|
8
|
+
DEFAULT_MAX_RESPONSE_BYTES,
|
|
9
|
+
DEFAULT_MAX_STREAM_BYTES,
|
|
10
|
+
LlmHttpError,
|
|
11
|
+
LlmStreamError,
|
|
12
|
+
StreamLimiter,
|
|
13
|
+
drainBody,
|
|
14
|
+
readBoundedJson,
|
|
15
|
+
resolveLimit,
|
|
16
|
+
} from "../errors";
|
|
17
|
+
import { type LlmMediaPart, type LlmReasoning, type LlmResultPart, textOf } from "../parts";
|
|
18
|
+
import { inlineData } from "../resolveContent";
|
|
19
|
+
import type {
|
|
20
|
+
LlmMessage,
|
|
21
|
+
LlmProvider,
|
|
22
|
+
LlmRequest,
|
|
23
|
+
LlmResponse,
|
|
24
|
+
LlmStreamChunk,
|
|
25
|
+
LlmToolCall,
|
|
26
|
+
} from "../types";
|
|
27
|
+
import { fenceUntrustedMedia } from "../untrusted";
|
|
28
|
+
import { validateExtraHeaders } from "./extraHeaders";
|
|
29
|
+
import { resolveSystem } from "./systemPrompt";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* OpenAI's `finish_reason` → the SPI's. ONE implementation shared by `complete`
|
|
33
|
+
* and `stream`: a duplicated mapping is where two paths to one outcome start
|
|
34
|
+
* disagreeing, which is what the conformance parity check exists to catch.
|
|
35
|
+
*/
|
|
36
|
+
function normalizeFinishReason(raw: unknown): LlmResponse["stopReason"] {
|
|
37
|
+
return raw === "tool_calls" ? "tool_use" : raw === "length" ? "max_tokens" : "end_turn";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Parse tool arguments, surfacing malformed JSON rather than dropping the call. */
|
|
41
|
+
function parseArguments(argsJson: string): unknown {
|
|
42
|
+
if (argsJson === "") return {};
|
|
43
|
+
try {
|
|
44
|
+
return JSON.parse(argsJson) as unknown;
|
|
45
|
+
} catch {
|
|
46
|
+
return { __unparsedArguments: argsJson };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface OpenAiProviderConfig {
|
|
51
|
+
/**
|
|
52
|
+
* Sent as `reasoning_effort` when set; servers that support reasoning return it only when asked.
|
|
53
|
+
* Which values a model accepts varies: `"minimal"` (gpt-5) and `"none"` (gpt-5.1+, Ollama) are
|
|
54
|
+
* not accepted everywhere, and an unsupported value is refused by the server, not by this client.
|
|
55
|
+
*/
|
|
56
|
+
reasoningEffort?: "none" | "minimal" | "low" | "medium" | "high";
|
|
57
|
+
/**
|
|
58
|
+
* Extra request headers sent on every request (for example `anthropic-workspace-id`). Header
|
|
59
|
+
* names and values must be strings without CR/LF; a header the provider sets itself (auth,
|
|
60
|
+
* protocol, content type) cannot be overridden and throws at construction. Unset, the request
|
|
61
|
+
* is unchanged.
|
|
62
|
+
*/
|
|
63
|
+
headers?: Readonly<Record<string, string>>;
|
|
64
|
+
apiKey?: string;
|
|
65
|
+
model?: string;
|
|
66
|
+
baseUrl?: string;
|
|
67
|
+
/**
|
|
68
|
+
* Cap on a complete response body, in bytes after decompression (default 8 MiB). Over it the
|
|
69
|
+
* body is cancelled and `LlmResponseLimitError` (provider and reason only) is thrown.
|
|
70
|
+
*/
|
|
71
|
+
maxResponseBytes?: number;
|
|
72
|
+
/** Cap on the total bytes read from a stream (default 128 MiB); same error. */
|
|
73
|
+
maxStreamBytes?: number;
|
|
74
|
+
/** Cap on one line of a stream, in UTF-8 bytes (default 1 MiB); same error. */
|
|
75
|
+
maxLineBytes?: number;
|
|
76
|
+
/** Override the declared modalities (e.g. a model without vision or audio input). */
|
|
77
|
+
capabilities?: LlmCapabilities;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Chat Completions: images, `input_audio` (wav/mp3) and PDF `file` inputs. */
|
|
81
|
+
const OPENAI_CAPABILITIES: LlmCapabilities = {
|
|
82
|
+
image: ["image/jpeg", "image/png", "image/gif", "image/webp"],
|
|
83
|
+
audio: ["audio/wav", "audio/x-wav", "audio/mpeg", "audio/mp3"],
|
|
84
|
+
file: ["application/pdf"],
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Model families that take images (and PDFs): gpt-4o, gpt-4.1, gpt-5, o1 / o3 (not the -mini and
|
|
89
|
+
* -preview variants), o4, and dated gpt-4-turbo (not the -preview ones). Plain gpt-4 and its dated
|
|
90
|
+
* snapshots, gpt-3.5 and anything unlisted are text-only.
|
|
91
|
+
*/
|
|
92
|
+
const OPENAI_MEDIA_MODELS = [
|
|
93
|
+
/^gpt-4o/,
|
|
94
|
+
/^gpt-4\.1/,
|
|
95
|
+
/^gpt-5/,
|
|
96
|
+
/^o1(-(?!mini|preview)|$)/,
|
|
97
|
+
/^o3(-(?!mini)|$)/,
|
|
98
|
+
/^o4/,
|
|
99
|
+
/^gpt-4-turbo(-\d{4}-\d{2}-\d{2})?$/,
|
|
100
|
+
];
|
|
101
|
+
const OPENAI_DEFAULT_URL = "https://api.openai.com";
|
|
102
|
+
|
|
103
|
+
/** ADR 0133 - `reasoning_content` (an OpenAI-compatible server's field) -> reasoning. Shared by `complete` and `stream`. */
|
|
104
|
+
function compatReasoning(text: string): LlmReasoning[] {
|
|
105
|
+
return text === "" ? [] : [{ kind: "text", text, provider: "openai-compat" }];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** `reasoning_content` (DeepSeek-style servers) or `reasoning` (Ollama and others); the first non-empty wins. */
|
|
109
|
+
function readReasoningField(x: {
|
|
110
|
+
reasoning_content?: string | null;
|
|
111
|
+
reasoning?: string | null;
|
|
112
|
+
}): string {
|
|
113
|
+
return x.reasoning_content || x.reasoning || "";
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function dataUrl(p: LlmMediaPart): string {
|
|
117
|
+
return `data:${p.mediaType};base64,${inlineData(p)}`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function mediaBlock(p: LlmMediaPart): Record<string, unknown> {
|
|
121
|
+
switch (p.type) {
|
|
122
|
+
case "image":
|
|
123
|
+
return { type: "image_url", image_url: { url: dataUrl(p) } };
|
|
124
|
+
case "audio": {
|
|
125
|
+
const mt = p.mediaType.toLowerCase();
|
|
126
|
+
const format = mt.endsWith("wav")
|
|
127
|
+
? "wav"
|
|
128
|
+
: mt.endsWith("mpeg") || mt.endsWith("mp3")
|
|
129
|
+
? "mp3"
|
|
130
|
+
: undefined;
|
|
131
|
+
if (format === undefined)
|
|
132
|
+
throw new Error(`OpenAI does not accept audio of type "${p.mediaType}"`);
|
|
133
|
+
return { type: "input_audio", input_audio: { data: inlineData(p), format } };
|
|
134
|
+
}
|
|
135
|
+
case "file":
|
|
136
|
+
return {
|
|
137
|
+
type: "file",
|
|
138
|
+
file: { filename: p.name ?? "file.pdf", file_data: dataUrl(p) },
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* SPI messages → Chat Completions messages. Exported for tests.
|
|
145
|
+
*
|
|
146
|
+
* `tool_result` parts become `role: "tool"` messages (which must directly follow the
|
|
147
|
+
* assistant message that made the calls, hence they come first); a tool message holds text
|
|
148
|
+
* only, so media a tool returned rides in a trailing `user` message. Other user parts become
|
|
149
|
+
* one content-array `user` message; an assistant's `tool_use` parts become `tool_calls`.
|
|
150
|
+
*/
|
|
151
|
+
export function mapMessages(messages: readonly LlmMessage[]): Record<string, unknown>[] {
|
|
152
|
+
const out: Record<string, unknown>[] = [];
|
|
153
|
+
for (const m of messages) {
|
|
154
|
+
if (typeof m.content === "string") {
|
|
155
|
+
out.push({ role: m.role, content: m.content });
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (m.role === "assistant") {
|
|
159
|
+
const toolCalls = m.content
|
|
160
|
+
.filter((p) => p.type === "tool_use")
|
|
161
|
+
.map((p) => {
|
|
162
|
+
const u = p as { id: string; name: string; input: unknown };
|
|
163
|
+
return {
|
|
164
|
+
id: u.id,
|
|
165
|
+
type: "function",
|
|
166
|
+
function: { name: u.name, arguments: JSON.stringify(u.input ?? {}) },
|
|
167
|
+
};
|
|
168
|
+
});
|
|
169
|
+
const text = textOf(m.content);
|
|
170
|
+
out.push({
|
|
171
|
+
role: "assistant",
|
|
172
|
+
content: text === "" && toolCalls.length > 0 ? null : text,
|
|
173
|
+
...(toolCalls.length > 0 && { tool_calls: toolCalls }),
|
|
174
|
+
});
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (m.role === "system") {
|
|
178
|
+
out.push({ role: "system", content: textOf(m.content) });
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
const userBlocks: Record<string, unknown>[] = [];
|
|
182
|
+
const toolMedia: Record<string, unknown>[] = [];
|
|
183
|
+
for (const p of fenceUntrustedMedia(m.content)) {
|
|
184
|
+
if (p.type === "tool_result") {
|
|
185
|
+
const items =
|
|
186
|
+
typeof p.content === "string" ? [{ type: "text" as const, text: p.content }] : p.content;
|
|
187
|
+
const text = items.flatMap((r) => (r.type === "text" ? [r.text] : [])).join("\n");
|
|
188
|
+
out.push({ role: "tool", tool_call_id: p.toolUseId, content: text });
|
|
189
|
+
let headed = false;
|
|
190
|
+
for (const r of items) {
|
|
191
|
+
if (r.type === "text") continue;
|
|
192
|
+
// One header per media-returning call, so the model can tell whose media follows.
|
|
193
|
+
if (!headed) {
|
|
194
|
+
toolMedia.push({ type: "text", text: `Media returned by tool call ${p.toolUseId}:` });
|
|
195
|
+
headed = true;
|
|
196
|
+
}
|
|
197
|
+
// The fence travels WITH the media into the trailing user message; the `tool` message
|
|
198
|
+
// (text only) never holds the bytes, so a bracket left there would fence nothing.
|
|
199
|
+
for (const q of fenceUntrustedMedia<LlmResultPart>([r])) {
|
|
200
|
+
toolMedia.push(q.type === "text" ? { type: "text", text: q.text } : mediaBlock(q));
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
} else if (p.type === "text") userBlocks.push({ type: "text", text: p.text });
|
|
204
|
+
else if (p.type === "tool_use")
|
|
205
|
+
throw new Error("tool_use parts belong in assistant messages");
|
|
206
|
+
else if (p.type === "reasoning")
|
|
207
|
+
throw new Error("reasoning parts belong in assistant messages");
|
|
208
|
+
else userBlocks.push(mediaBlock(p));
|
|
209
|
+
}
|
|
210
|
+
const blocks = [...toolMedia, ...userBlocks];
|
|
211
|
+
if (blocks.length > 0) out.push({ role: "user", content: blocks });
|
|
212
|
+
}
|
|
213
|
+
return out;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
interface OpenAiChoice {
|
|
217
|
+
message?: {
|
|
218
|
+
content?: string | null;
|
|
219
|
+
/** Some OpenAI-compatible servers return reasoning here; OpenAI's own Chat Completions does not (ADR 0133). */
|
|
220
|
+
reasoning_content?: string | null;
|
|
221
|
+
reasoning?: string | null;
|
|
222
|
+
tool_calls?: Array<{
|
|
223
|
+
id: string;
|
|
224
|
+
function: { name: string; arguments: string };
|
|
225
|
+
}>;
|
|
226
|
+
};
|
|
227
|
+
delta?: {
|
|
228
|
+
content?: string | null;
|
|
229
|
+
reasoning_content?: string | null;
|
|
230
|
+
reasoning?: string | null;
|
|
231
|
+
tool_calls?: Array<{
|
|
232
|
+
index: number;
|
|
233
|
+
id?: string;
|
|
234
|
+
function?: { name?: string; arguments?: string };
|
|
235
|
+
}>;
|
|
236
|
+
};
|
|
237
|
+
finish_reason?: string | null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
interface OpenAiChatResponse {
|
|
241
|
+
choices: OpenAiChoice[];
|
|
242
|
+
usage?: { prompt_tokens: number; completion_tokens: number };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export class OpenAiProvider implements LlmProvider {
|
|
246
|
+
private readonly apiKey: string;
|
|
247
|
+
private readonly model: string;
|
|
248
|
+
private readonly baseUrl: string;
|
|
249
|
+
private readonly maxResponseBytes: number;
|
|
250
|
+
private readonly maxStreamBytes: number;
|
|
251
|
+
private readonly maxLineBytes: number;
|
|
252
|
+
private readonly extraHeaders: Readonly<Record<string, string>>;
|
|
253
|
+
private readonly reasoningEffort: OpenAiProviderConfig["reasoningEffort"];
|
|
254
|
+
readonly capabilities: LlmCapabilities;
|
|
255
|
+
|
|
256
|
+
constructor(config: OpenAiProviderConfig = {}) {
|
|
257
|
+
this.reasoningEffort = config.reasoningEffort;
|
|
258
|
+
this.extraHeaders = validateExtraHeaders(
|
|
259
|
+
config.headers,
|
|
260
|
+
["authorization", "content-type"],
|
|
261
|
+
"OpenAiProvider"
|
|
262
|
+
);
|
|
263
|
+
const model = config.model ?? "gpt-4o";
|
|
264
|
+
// `input_audio` is only accepted by the audio-preview family, not by gpt-4o proper.
|
|
265
|
+
const { audio: _audio, ...noAudio } = OPENAI_CAPABILITIES;
|
|
266
|
+
this.capabilities =
|
|
267
|
+
config.capabilities ??
|
|
268
|
+
(config.baseUrl !== undefined && config.baseUrl !== OPENAI_DEFAULT_URL
|
|
269
|
+
? {}
|
|
270
|
+
: capabilitiesForModel(
|
|
271
|
+
model,
|
|
272
|
+
/audio/.test(model) ? OPENAI_CAPABILITIES : noAudio,
|
|
273
|
+
OPENAI_MEDIA_MODELS
|
|
274
|
+
));
|
|
275
|
+
this.apiKey =
|
|
276
|
+
config.apiKey ?? (typeof process !== "undefined" ? (process.env.OPENAI_API_KEY ?? "") : "");
|
|
277
|
+
this.model = config.model ?? "gpt-4o";
|
|
278
|
+
this.baseUrl = config.baseUrl ?? OPENAI_DEFAULT_URL;
|
|
279
|
+
this.maxResponseBytes = resolveLimit(
|
|
280
|
+
config.maxResponseBytes,
|
|
281
|
+
DEFAULT_MAX_RESPONSE_BYTES,
|
|
282
|
+
"maxResponseBytes"
|
|
283
|
+
);
|
|
284
|
+
this.maxStreamBytes = resolveLimit(
|
|
285
|
+
config.maxStreamBytes,
|
|
286
|
+
DEFAULT_MAX_STREAM_BYTES,
|
|
287
|
+
"maxStreamBytes"
|
|
288
|
+
);
|
|
289
|
+
this.maxLineBytes = resolveLimit(config.maxLineBytes, DEFAULT_MAX_LINE_BYTES, "maxLineBytes");
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
private buildBody(request: LlmRequest, stream = false): Record<string, unknown> {
|
|
293
|
+
// The one system-prompt rule (see `resolveSystem`): a single leading system message.
|
|
294
|
+
const system = resolveSystem(request);
|
|
295
|
+
const mapped = mapMessages(request.messages.filter((m) => m.role !== "system"));
|
|
296
|
+
const messages =
|
|
297
|
+
system !== undefined ? [{ role: "system", content: system }, ...mapped] : mapped;
|
|
298
|
+
const body: Record<string, unknown> = {
|
|
299
|
+
model: this.model,
|
|
300
|
+
messages,
|
|
301
|
+
stream,
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
if (request.maxTokens !== undefined) body.max_tokens = request.maxTokens;
|
|
305
|
+
if (request.temperature !== undefined) body.temperature = request.temperature;
|
|
306
|
+
if (this.reasoningEffort !== undefined) body.reasoning_effort = this.reasoningEffort;
|
|
307
|
+
|
|
308
|
+
if (request.tools !== undefined && request.tools.length > 0) {
|
|
309
|
+
body.tools = request.tools.map((t) => ({
|
|
310
|
+
type: "function",
|
|
311
|
+
function: { name: t.name, description: t.description, parameters: t.inputSchema },
|
|
312
|
+
}));
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return body;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async complete(request: LlmRequest): Promise<LlmResponse> {
|
|
319
|
+
assertRequestSupported(request, this.capabilities, "OpenAiProvider");
|
|
320
|
+
const resp = await fetch(`${this.baseUrl}/v1/chat/completions`, {
|
|
321
|
+
method: "POST",
|
|
322
|
+
headers: {
|
|
323
|
+
...this.extraHeaders,
|
|
324
|
+
"content-type": "application/json",
|
|
325
|
+
authorization: `Bearer ${this.apiKey}`,
|
|
326
|
+
},
|
|
327
|
+
body: JSON.stringify(this.buildBody(request)),
|
|
328
|
+
...(request.signal !== undefined && { signal: request.signal }),
|
|
329
|
+
// Never follow a redirect (it would re-send the body and credentials to another origin): a 3xx
|
|
330
|
+
// comes back as a response and becomes `LlmHttpError(provider, status)` below.
|
|
331
|
+
redirect: "manual",
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
if (!resp.ok) {
|
|
335
|
+
await drainBody(resp);
|
|
336
|
+
throw new LlmHttpError("openai", resp.status);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const data = (await readBoundedJson(
|
|
340
|
+
resp,
|
|
341
|
+
"openai",
|
|
342
|
+
this.maxResponseBytes
|
|
343
|
+
)) as OpenAiChatResponse;
|
|
344
|
+
const choice = data.choices[0];
|
|
345
|
+
if (!choice?.message) {
|
|
346
|
+
return { content: "", stopReason: "end_turn" };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const content = choice.message.content ?? "";
|
|
350
|
+
const toolCalls = (choice.message.tool_calls ?? []).map((tc) => ({
|
|
351
|
+
id: tc.id,
|
|
352
|
+
name: tc.function.name,
|
|
353
|
+
input: parseArguments(tc.function.arguments),
|
|
354
|
+
}));
|
|
355
|
+
|
|
356
|
+
const stopReason = normalizeFinishReason(choice.finish_reason);
|
|
357
|
+
const reasoning = compatReasoning(readReasoningField(choice.message));
|
|
358
|
+
|
|
359
|
+
return {
|
|
360
|
+
content,
|
|
361
|
+
...(toolCalls.length > 0 && { toolCalls }),
|
|
362
|
+
stopReason,
|
|
363
|
+
...(data.usage !== undefined && {
|
|
364
|
+
usage: {
|
|
365
|
+
inputTokens: data.usage.prompt_tokens,
|
|
366
|
+
outputTokens: data.usage.completion_tokens,
|
|
367
|
+
},
|
|
368
|
+
}),
|
|
369
|
+
...(reasoning.length > 0 && { reasoning }),
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async *stream(request: LlmRequest): AsyncIterable<LlmStreamChunk> {
|
|
374
|
+
assertRequestSupported(request, this.capabilities, "OpenAiProvider");
|
|
375
|
+
const resp = await fetch(`${this.baseUrl}/v1/chat/completions`, {
|
|
376
|
+
method: "POST",
|
|
377
|
+
headers: {
|
|
378
|
+
...this.extraHeaders,
|
|
379
|
+
"content-type": "application/json",
|
|
380
|
+
authorization: `Bearer ${this.apiKey}`,
|
|
381
|
+
},
|
|
382
|
+
// `include_usage` is what makes the streamed path able to report the token
|
|
383
|
+
// counts the completed path always did — without it K3 meters nothing when
|
|
384
|
+
// streaming, and K8.4 has no mid-flight signal to sever on.
|
|
385
|
+
body: JSON.stringify({
|
|
386
|
+
...this.buildBody(request, true),
|
|
387
|
+
stream_options: { include_usage: true },
|
|
388
|
+
}),
|
|
389
|
+
...(request.signal !== undefined && { signal: request.signal }),
|
|
390
|
+
// Never follow a redirect (it would re-send the body and credentials to another origin): a 3xx
|
|
391
|
+
// comes back as a response and becomes `LlmHttpError(provider, status)` below.
|
|
392
|
+
redirect: "manual",
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
if (!resp.ok) {
|
|
396
|
+
await drainBody(resp);
|
|
397
|
+
throw new LlmHttpError("openai", resp.status);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (!resp.body) return;
|
|
401
|
+
|
|
402
|
+
const reader = resp.body.getReader();
|
|
403
|
+
const limiter = new StreamLimiter("openai", this.maxStreamBytes, this.maxLineBytes);
|
|
404
|
+
const decoder = new TextDecoder();
|
|
405
|
+
let buffer = "";
|
|
406
|
+
|
|
407
|
+
// Accumulated so the TERMINAL chunk reports what `complete` would have.
|
|
408
|
+
// Tool-call arguments stream as fragments keyed by `index` and are only valid
|
|
409
|
+
// JSON once concatenated, so they are parsed at the end, never per delta.
|
|
410
|
+
const openCalls = new Map<number, { id: string; name: string; argsJson: string }>();
|
|
411
|
+
let stopReason: LlmResponse["stopReason"] | undefined;
|
|
412
|
+
let usage: { inputTokens: number; outputTokens: number } | undefined;
|
|
413
|
+
let reasoningText = "";
|
|
414
|
+
|
|
415
|
+
const terminal = (): LlmStreamChunk => {
|
|
416
|
+
const toolCalls: LlmToolCall[] = [...openCalls.entries()]
|
|
417
|
+
.sort((a, b) => a[0] - b[0]) // by stream index, so order matches `complete`
|
|
418
|
+
.map(([, c]) => ({ id: c.id, name: c.name, input: parseArguments(c.argsJson) }));
|
|
419
|
+
return {
|
|
420
|
+
delta: "",
|
|
421
|
+
done: true,
|
|
422
|
+
...(toolCalls.length > 0 && { toolCalls }),
|
|
423
|
+
...(stopReason !== undefined && { stopReason }),
|
|
424
|
+
...(usage !== undefined && { usage }),
|
|
425
|
+
...(reasoningText !== "" && { reasoning: compatReasoning(reasoningText) }),
|
|
426
|
+
};
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
try {
|
|
430
|
+
while (true) {
|
|
431
|
+
const { done, value } = await reader.read();
|
|
432
|
+
if (done) break;
|
|
433
|
+
limiter.chunk(value);
|
|
434
|
+
buffer += decoder.decode(value, { stream: true });
|
|
435
|
+
|
|
436
|
+
const lines = buffer.split("\n");
|
|
437
|
+
buffer = lines.pop() ?? "";
|
|
438
|
+
limiter.lines(lines, buffer);
|
|
439
|
+
|
|
440
|
+
for (const line of lines) {
|
|
441
|
+
if (!line.startsWith("data: ")) continue;
|
|
442
|
+
const json = line.slice(6).trim();
|
|
443
|
+
if (json === "[DONE]") {
|
|
444
|
+
yield terminal();
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
try {
|
|
448
|
+
const chunk = JSON.parse(json) as OpenAiChatResponse;
|
|
449
|
+
const streamed = (chunk as { error?: { code?: unknown; type?: unknown } | null }).error;
|
|
450
|
+
if (streamed !== undefined && streamed !== null) {
|
|
451
|
+
// The message text can echo the prompt: only the code or type is kept.
|
|
452
|
+
throw new LlmStreamError("openai", streamed.code ?? streamed.type);
|
|
453
|
+
}
|
|
454
|
+
// The usage-only chunk (`include_usage`) arrives AFTER the finished
|
|
455
|
+
// choice and carries an empty `choices` array, so it must be read before
|
|
456
|
+
// any early return on finish_reason — which is why the terminal chunk is
|
|
457
|
+
// emitted at [DONE], not at the finish.
|
|
458
|
+
if (chunk.usage !== undefined) {
|
|
459
|
+
usage = {
|
|
460
|
+
inputTokens: chunk.usage.prompt_tokens,
|
|
461
|
+
outputTokens: chunk.usage.completion_tokens,
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
const choice = chunk.choices[0];
|
|
465
|
+
const delta = choice?.delta?.content ?? "";
|
|
466
|
+
const reasoningDelta = readReasoningField(choice?.delta ?? {});
|
|
467
|
+
reasoningText += reasoningDelta;
|
|
468
|
+
const toolDeltas: NonNullable<LlmStreamChunk["toolDelta"]>[] = [];
|
|
469
|
+
for (const tc of choice?.delta?.tool_calls ?? []) {
|
|
470
|
+
const open = openCalls.get(tc.index) ?? { id: "", name: "", argsJson: "" };
|
|
471
|
+
if (tc.id !== undefined) open.id = tc.id;
|
|
472
|
+
if (tc.function?.name !== undefined) open.name = tc.function.name;
|
|
473
|
+
if (tc.function?.arguments !== undefined) open.argsJson += tc.function.arguments;
|
|
474
|
+
const first = !openCalls.has(tc.index);
|
|
475
|
+
openCalls.set(tc.index, open);
|
|
476
|
+
const inputDelta = tc.function?.arguments ?? "";
|
|
477
|
+
if (first || inputDelta !== "") {
|
|
478
|
+
toolDeltas.push({
|
|
479
|
+
id: open.id,
|
|
480
|
+
...(first && { name: open.name }),
|
|
481
|
+
inputDelta,
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
if (choice?.finish_reason != null) {
|
|
486
|
+
stopReason = normalizeFinishReason(choice.finish_reason);
|
|
487
|
+
}
|
|
488
|
+
if (reasoningDelta !== "") yield { delta: "", done: false, reasoningDelta };
|
|
489
|
+
if (delta !== "") {
|
|
490
|
+
yield { delta, done: false };
|
|
491
|
+
}
|
|
492
|
+
for (const toolDelta of toolDeltas) yield { delta: "", done: false, toolDelta };
|
|
493
|
+
} catch (e) {
|
|
494
|
+
if (e instanceof LlmStreamError) throw e;
|
|
495
|
+
// skip malformed SSE lines
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// The stream ended without an explicit [DONE] (some gateways omit it): the
|
|
501
|
+
// terminal chunk is still owed, or `assembleStream` would reject the stream.
|
|
502
|
+
yield terminal();
|
|
503
|
+
} finally {
|
|
504
|
+
try {
|
|
505
|
+
await reader.cancel();
|
|
506
|
+
} catch {
|
|
507
|
+
// already closed or released: nothing to free
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Caller-supplied extra request headers for the HTTP providers (for example Anthropic's
|
|
3
|
+
* `anthropic-workspace-id`). Validated once at construction; a provider's own auth and protocol
|
|
4
|
+
* headers can never be replaced. Errors name the header and never carry a value.
|
|
5
|
+
*/
|
|
6
|
+
export function validateExtraHeaders(
|
|
7
|
+
headers: Readonly<Record<string, string>> | undefined,
|
|
8
|
+
protectedNames: readonly string[],
|
|
9
|
+
provider: string
|
|
10
|
+
): Readonly<Record<string, string>> {
|
|
11
|
+
if (headers === undefined) return {};
|
|
12
|
+
const owned = new Set(protectedNames.map((n) => n.toLowerCase()));
|
|
13
|
+
const out: Record<string, string> = {};
|
|
14
|
+
const seen = new Set<string>();
|
|
15
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
16
|
+
if (/[\r\n]/.test(name)) {
|
|
17
|
+
throw new Error(`${provider}: header name contains a CR or LF character`);
|
|
18
|
+
}
|
|
19
|
+
if (name.length === 0 || !/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(name)) {
|
|
20
|
+
throw new Error(`${provider}: invalid header name "${name.slice(0, 64)}"`);
|
|
21
|
+
}
|
|
22
|
+
if (typeof value !== "string") {
|
|
23
|
+
throw new Error(`${provider}: header "${name}" must have a string value`);
|
|
24
|
+
}
|
|
25
|
+
if (/[\r\n]/.test(value)) {
|
|
26
|
+
throw new Error(`${provider}: header "${name}" value contains a CR or LF character`);
|
|
27
|
+
}
|
|
28
|
+
const lower = name.toLowerCase();
|
|
29
|
+
if (owned.has(lower)) {
|
|
30
|
+
throw new Error(
|
|
31
|
+
`${provider}: header "${name}" is protected (owned by the provider) and cannot be overridden`
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
if (seen.has(lower)) {
|
|
35
|
+
throw new Error(`${provider}: header "${name}" is given twice (names are case-insensitive)`);
|
|
36
|
+
}
|
|
37
|
+
seen.add(lower);
|
|
38
|
+
out[name] = value;
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { textOf } from "../parts";
|
|
2
|
+
import type { LlmRequest } from "../types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The ONE system-prompt rule every provider applies (documented on `LlmRequest.system`): a non-empty
|
|
6
|
+
* `request.system` wins and the system-role messages in `messages` are dropped; an empty string counts
|
|
7
|
+
* as unset; with no `request.system`, the system-role messages' text is joined in order with a blank
|
|
8
|
+
* line. `undefined` means send no system prompt. Callers drop system-role messages from the turns
|
|
9
|
+
* they map either way.
|
|
10
|
+
*/
|
|
11
|
+
export function resolveSystem(request: LlmRequest): string | undefined {
|
|
12
|
+
if (request.system !== undefined && request.system !== "") return request.system;
|
|
13
|
+
const joined = request.messages
|
|
14
|
+
.filter((m) => m.role === "system")
|
|
15
|
+
.map((m) => textOf(m.content))
|
|
16
|
+
.filter((t) => t !== "")
|
|
17
|
+
.join("\n\n");
|
|
18
|
+
return joined === "" ? undefined : joined;
|
|
19
|
+
}
|