@providerkit/core 0.1.0
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 +21 -0
- package/README.md +245 -0
- package/dist/context.d.ts +69 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +132 -0
- package/dist/context.js.map +1 -0
- package/dist/errors.d.ts +86 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +356 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +13 -0
- package/dist/index.js.map +1 -0
- package/dist/providers/anthropic.d.ts +26 -0
- package/dist/providers/anthropic.d.ts.map +1 -0
- package/dist/providers/anthropic.js +245 -0
- package/dist/providers/anthropic.js.map +1 -0
- package/dist/providers/openai.d.ts +30 -0
- package/dist/providers/openai.d.ts.map +1 -0
- package/dist/providers/openai.js +185 -0
- package/dist/providers/openai.js.map +1 -0
- package/dist/retry.d.ts +79 -0
- package/dist/retry.d.ts.map +1 -0
- package/dist/retry.js +200 -0
- package/dist/retry.js.map +1 -0
- package/dist/schema.d.ts +2 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +48 -0
- package/dist/schema.js.map +1 -0
- package/dist/tool-args.d.ts +12 -0
- package/dist/tool-args.d.ts.map +1 -0
- package/dist/tool-args.js +113 -0
- package/dist/tool-args.js.map +1 -0
- package/dist/tools.d.ts +82 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +155 -0
- package/dist/tools.js.map +1 -0
- package/dist/transport.d.ts +31 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +157 -0
- package/dist/transport.js.map +1 -0
- package/dist/types.d.ts +168 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +75 -0
- package/dist/types.js.map +1 -0
- package/dist/usage.d.ts +50 -0
- package/dist/usage.d.ts.map +1 -0
- package/dist/usage.js +71 -0
- package/dist/usage.js.map +1 -0
- package/dist/watchdog.d.ts +34 -0
- package/dist/watchdog.d.ts.map +1 -0
- package/dist/watchdog.js +85 -0
- package/dist/watchdog.js.map +1 -0
- package/dist/zod.d.ts +32 -0
- package/dist/zod.d.ts.map +1 -0
- package/dist/zod.js +49 -0
- package/dist/zod.js.map +1 -0
- package/package.json +76 -0
- package/src/context.ts +150 -0
- package/src/errors.ts +398 -0
- package/src/index.ts +12 -0
- package/src/providers/anthropic.ts +315 -0
- package/src/providers/openai.ts +246 -0
- package/src/retry.ts +246 -0
- package/src/schema.ts +67 -0
- package/src/tool-args.ts +117 -0
- package/src/tools.ts +237 -0
- package/src/transport.ts +162 -0
- package/src/types.ts +231 -0
- package/src/usage.ts +106 -0
- package/src/watchdog.ts +119 -0
- package/src/zod.ts +74 -0
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
// Anthropic-shape adapter — SSE from POST /v1/messages.
|
|
2
|
+
import { ProviderError } from "../errors.ts";
|
|
3
|
+
import { streamSse, apiUrl } from "../transport.ts";
|
|
4
|
+
import type {
|
|
5
|
+
ChatMessage,
|
|
6
|
+
ContentPart,
|
|
7
|
+
Effort,
|
|
8
|
+
FinishReason,
|
|
9
|
+
Provider,
|
|
10
|
+
ProviderChunk,
|
|
11
|
+
StreamOptions,
|
|
12
|
+
ToolDefinition,
|
|
13
|
+
} from "../types.ts";
|
|
14
|
+
|
|
15
|
+
export interface AnthropicConfig {
|
|
16
|
+
apiKey: string;
|
|
17
|
+
model: string;
|
|
18
|
+
baseUrl?: string;
|
|
19
|
+
/** Bound default; a per-call `effort` overrides it. */
|
|
20
|
+
effort?: Effort;
|
|
21
|
+
/** Anthropic requires an output ceiling on every request. */
|
|
22
|
+
maxTokens?: number;
|
|
23
|
+
version?: string;
|
|
24
|
+
fetchImpl?: typeof fetch;
|
|
25
|
+
/** Send the key as a Bearer instead of `x-api-key` — what a subscription
|
|
26
|
+
* access token needs. */
|
|
27
|
+
bearer?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const DEFAULT_BASE_URL = "https://api.anthropic.com";
|
|
31
|
+
const DEFAULT_VERSION = "2023-06-01";
|
|
32
|
+
/** Anthropic rejects a request without one, so a default is not optional. */
|
|
33
|
+
const DEFAULT_MAX_TOKENS = 8_192;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Thinking budgets, in output tokens. Thinking and the answer SHARE
|
|
37
|
+
* `max_tokens`, so a budget is always left below the ceiling — a budget at or
|
|
38
|
+
* above it leaves no room to answer, and the turn ends mid-thought.
|
|
39
|
+
*/
|
|
40
|
+
const THINKING_BUDGET: Record<Exclude<Effort, "none">, number> = {
|
|
41
|
+
low: 2_048,
|
|
42
|
+
medium: 8_192,
|
|
43
|
+
high: 16_384,
|
|
44
|
+
max: 32_768,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
function mapStopReason(reason: string | undefined): FinishReason | undefined {
|
|
48
|
+
switch (reason) {
|
|
49
|
+
case "end_turn":
|
|
50
|
+
case "stop_sequence":
|
|
51
|
+
return "stop";
|
|
52
|
+
case "tool_use":
|
|
53
|
+
return "tool_calls";
|
|
54
|
+
case "max_tokens":
|
|
55
|
+
return "length";
|
|
56
|
+
case "refusal":
|
|
57
|
+
return "content_filter";
|
|
58
|
+
default:
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function partsToAnthropic(content: string | ContentPart[]): unknown[] {
|
|
64
|
+
if (typeof content === "string") return [{ type: "text", text: content }];
|
|
65
|
+
return content.map((part) =>
|
|
66
|
+
part.type === "text"
|
|
67
|
+
? { type: "text", text: part.text }
|
|
68
|
+
: {
|
|
69
|
+
type: "image",
|
|
70
|
+
source: { type: "base64", media_type: part.mimeType, data: part.data },
|
|
71
|
+
},
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Anthropic takes `system` at the top level and expects tool RESULTS as user
|
|
77
|
+
* turns carrying `tool_result` blocks — not as a role of their own. Consecutive
|
|
78
|
+
* tool results are merged into one user turn, which the API requires.
|
|
79
|
+
*/
|
|
80
|
+
export function toAnthropicMessages(messages: readonly ChatMessage[]): {
|
|
81
|
+
system?: string;
|
|
82
|
+
messages: unknown[];
|
|
83
|
+
} {
|
|
84
|
+
const system = messages
|
|
85
|
+
.filter((m) => m.role === "system")
|
|
86
|
+
.map((m) => m.content)
|
|
87
|
+
.join("\n\n");
|
|
88
|
+
|
|
89
|
+
const out: { role: string; content: unknown[] }[] = [];
|
|
90
|
+
const pushBlocks = (role: string, blocks: unknown[]) => {
|
|
91
|
+
const last = out[out.length - 1];
|
|
92
|
+
if (last?.role === role) last.content.push(...blocks);
|
|
93
|
+
else out.push({ role, content: blocks });
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
for (const message of messages) {
|
|
97
|
+
if (message.role === "system") continue;
|
|
98
|
+
if (message.role === "user") {
|
|
99
|
+
pushBlocks("user", partsToAnthropic(message.content));
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (message.role === "tool") {
|
|
103
|
+
pushBlocks("user", [
|
|
104
|
+
{
|
|
105
|
+
type: "tool_result",
|
|
106
|
+
tool_use_id: message.toolCallId,
|
|
107
|
+
content: [
|
|
108
|
+
{ type: "text", text: message.content },
|
|
109
|
+
...(message.images ?? []).map((image) => ({
|
|
110
|
+
type: "image",
|
|
111
|
+
source: { type: "base64", media_type: image.mimeType, data: image.data },
|
|
112
|
+
})),
|
|
113
|
+
],
|
|
114
|
+
},
|
|
115
|
+
]);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
// assistant. Reasoning is deliberately NOT replayed: Anthropic's thinking
|
|
119
|
+
// blocks carry signatures we never captured, and a block without its
|
|
120
|
+
// signature is rejected.
|
|
121
|
+
const blocks: unknown[] = [];
|
|
122
|
+
if (message.content) blocks.push({ type: "text", text: message.content });
|
|
123
|
+
for (const call of message.toolCalls ?? []) {
|
|
124
|
+
blocks.push({
|
|
125
|
+
type: "tool_use",
|
|
126
|
+
id: call.id,
|
|
127
|
+
name: call.name,
|
|
128
|
+
input: safeParse(call.arguments),
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
if (blocks.length > 0) pushBlocks("assistant", blocks);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return { ...(system ? { system } : {}), messages: out };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function safeParse(raw: string): unknown {
|
|
138
|
+
try {
|
|
139
|
+
return raw ? JSON.parse(raw) : {};
|
|
140
|
+
} catch {
|
|
141
|
+
return {};
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
interface AnthropicEvent {
|
|
146
|
+
type?: string;
|
|
147
|
+
message?: {
|
|
148
|
+
usage?: {
|
|
149
|
+
input_tokens?: number;
|
|
150
|
+
output_tokens?: number;
|
|
151
|
+
cache_read_input_tokens?: number;
|
|
152
|
+
cache_creation_input_tokens?: number;
|
|
153
|
+
};
|
|
154
|
+
};
|
|
155
|
+
content_block?: { type?: string; id?: string; name?: string };
|
|
156
|
+
delta?: {
|
|
157
|
+
type?: string;
|
|
158
|
+
text?: string;
|
|
159
|
+
thinking?: string;
|
|
160
|
+
partial_json?: string;
|
|
161
|
+
stop_reason?: string;
|
|
162
|
+
};
|
|
163
|
+
usage?: { output_tokens?: number };
|
|
164
|
+
index?: number;
|
|
165
|
+
error?: { message?: string; type?: string };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function createAnthropicProvider(config: AnthropicConfig): Provider {
|
|
169
|
+
const baseUrl = config.baseUrl ?? DEFAULT_BASE_URL;
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
id: "anthropic",
|
|
173
|
+
model: config.model,
|
|
174
|
+
|
|
175
|
+
async *createStream(
|
|
176
|
+
messages: ChatMessage[],
|
|
177
|
+
tools: ToolDefinition[],
|
|
178
|
+
opts: StreamOptions = {},
|
|
179
|
+
): AsyncIterable<ProviderChunk> {
|
|
180
|
+
const model = opts.model ?? config.model;
|
|
181
|
+
const maxTokens = opts.maxTokens ?? config.maxTokens ?? DEFAULT_MAX_TOKENS;
|
|
182
|
+
const effort = opts.effort ?? config.effort ?? "none";
|
|
183
|
+
const { system, messages: body } = toAnthropicMessages(messages);
|
|
184
|
+
|
|
185
|
+
const request: Record<string, unknown> = {
|
|
186
|
+
model,
|
|
187
|
+
max_tokens: maxTokens,
|
|
188
|
+
messages: body,
|
|
189
|
+
stream: true,
|
|
190
|
+
};
|
|
191
|
+
if (system) request.system = system;
|
|
192
|
+
if (opts.temperature !== undefined) request.temperature = opts.temperature;
|
|
193
|
+
if (tools.length > 0) {
|
|
194
|
+
request.tools = tools.map((tool) => ({
|
|
195
|
+
name: tool.name,
|
|
196
|
+
description: tool.description,
|
|
197
|
+
input_schema: tool.inputSchema,
|
|
198
|
+
}));
|
|
199
|
+
}
|
|
200
|
+
if (opts.toolChoice && opts.toolChoice !== "auto") {
|
|
201
|
+
request.tool_choice =
|
|
202
|
+
opts.toolChoice === "none"
|
|
203
|
+
? { type: "none" }
|
|
204
|
+
: opts.toolChoice === "required"
|
|
205
|
+
? { type: "any" }
|
|
206
|
+
: { type: "tool", name: opts.toolChoice.name };
|
|
207
|
+
}
|
|
208
|
+
if (effort !== "none") {
|
|
209
|
+
const budget = Math.min(THINKING_BUDGET[effort], Math.floor(maxTokens * 0.8));
|
|
210
|
+
request.thinking = { type: "enabled", budget_tokens: budget };
|
|
211
|
+
// Thinking and sampling are mutually exclusive on this shape.
|
|
212
|
+
delete request.temperature;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Anthropic reports cache reads and writes as fields of their OWN,
|
|
216
|
+
// EXCLUDED from `input_tokens` — where the OpenAI shapes report a cached
|
|
217
|
+
// subset already inside the prompt count. Reconciling here is what keeps
|
|
218
|
+
// one usage record meaningful across both, and a cost figure honest.
|
|
219
|
+
let inputTokens = 0;
|
|
220
|
+
let cachedInputTokens = 0;
|
|
221
|
+
let cacheWriteTokens = 0;
|
|
222
|
+
let outputTokens = 0;
|
|
223
|
+
let toolCall: { index: number; id: string; name: string } | null = null;
|
|
224
|
+
let blockIndex = -1;
|
|
225
|
+
|
|
226
|
+
for await (const data of streamSse({
|
|
227
|
+
url: apiUrl(baseUrl, "/v1/messages"),
|
|
228
|
+
headers: {
|
|
229
|
+
"anthropic-version": config.version ?? DEFAULT_VERSION,
|
|
230
|
+
...(config.bearer
|
|
231
|
+
? { authorization: `Bearer ${config.apiKey}` }
|
|
232
|
+
: { "x-api-key": config.apiKey }),
|
|
233
|
+
},
|
|
234
|
+
body: request,
|
|
235
|
+
provider: "anthropic",
|
|
236
|
+
...(opts.signal ? { signal: opts.signal } : {}),
|
|
237
|
+
...(config.fetchImpl ? { fetchImpl: config.fetchImpl } : {}),
|
|
238
|
+
})) {
|
|
239
|
+
let event: AnthropicEvent;
|
|
240
|
+
try {
|
|
241
|
+
event = JSON.parse(data) as AnthropicEvent;
|
|
242
|
+
} catch {
|
|
243
|
+
continue; // a keep-alive or a frame we do not model
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
switch (event.type) {
|
|
247
|
+
case "error":
|
|
248
|
+
throw new ProviderError(
|
|
249
|
+
"anthropic",
|
|
250
|
+
"overload",
|
|
251
|
+
event.error?.message ?? "anthropic stream error",
|
|
252
|
+
{ code: event.error?.type },
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
case "message_start": {
|
|
256
|
+
const usage = event.message?.usage;
|
|
257
|
+
cachedInputTokens = usage?.cache_read_input_tokens ?? 0;
|
|
258
|
+
cacheWriteTokens = usage?.cache_creation_input_tokens ?? 0;
|
|
259
|
+
inputTokens = (usage?.input_tokens ?? 0) + cachedInputTokens + cacheWriteTokens;
|
|
260
|
+
break;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
case "content_block_start": {
|
|
264
|
+
blockIndex += 1;
|
|
265
|
+
if (event.content_block?.type === "tool_use") {
|
|
266
|
+
toolCall = {
|
|
267
|
+
index: blockIndex,
|
|
268
|
+
id: event.content_block.id ?? "",
|
|
269
|
+
name: event.content_block.name ?? "",
|
|
270
|
+
};
|
|
271
|
+
yield {
|
|
272
|
+
type: "delta",
|
|
273
|
+
toolCalls: [{ index: toolCall.index, id: toolCall.id, name: toolCall.name }],
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
break;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
case "content_block_delta": {
|
|
280
|
+
const delta = event.delta;
|
|
281
|
+
if (delta?.type === "text_delta" && delta.text) {
|
|
282
|
+
yield { type: "delta", content: delta.text };
|
|
283
|
+
} else if (delta?.type === "thinking_delta" && delta.thinking) {
|
|
284
|
+
yield { type: "delta", reasoning: delta.thinking };
|
|
285
|
+
} else if (delta?.type === "input_json_delta" && toolCall) {
|
|
286
|
+
yield {
|
|
287
|
+
type: "delta",
|
|
288
|
+
toolCalls: [{ index: toolCall.index, arguments: delta.partial_json ?? "" }],
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
break;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
case "content_block_stop":
|
|
295
|
+
toolCall = null;
|
|
296
|
+
break;
|
|
297
|
+
|
|
298
|
+
case "message_delta": {
|
|
299
|
+
outputTokens = event.usage?.output_tokens ?? outputTokens;
|
|
300
|
+
const finishReason = mapStopReason(event.delta?.stop_reason);
|
|
301
|
+
if (finishReason) yield { type: "finish", finishReason };
|
|
302
|
+
break;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
case "message_stop":
|
|
306
|
+
yield {
|
|
307
|
+
type: "usage",
|
|
308
|
+
usage: { inputTokens, cachedInputTokens, cacheWriteTokens, outputTokens },
|
|
309
|
+
};
|
|
310
|
+
break;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
},
|
|
314
|
+
};
|
|
315
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
// OpenAI-shape adapter — SSE from POST /v1/chat/completions.
|
|
2
|
+
//
|
|
3
|
+
// This is the dialect most gateways speak, so one adapter serves OpenAI,
|
|
4
|
+
// OpenRouter, DeepSeek, GLM, Kimi, Groq, Together, vLLM, Ollama and LM Studio.
|
|
5
|
+
// Their divergences are small and named where they appear.
|
|
6
|
+
import { streamSse, apiUrl } from "../transport.ts";
|
|
7
|
+
import type {
|
|
8
|
+
ChatMessage,
|
|
9
|
+
ContentPart,
|
|
10
|
+
Effort,
|
|
11
|
+
FinishReason,
|
|
12
|
+
Provider,
|
|
13
|
+
ProviderChunk,
|
|
14
|
+
StreamOptions,
|
|
15
|
+
ToolDefinition,
|
|
16
|
+
} from "../types.ts";
|
|
17
|
+
import { toDataUri } from "../types.ts";
|
|
18
|
+
|
|
19
|
+
export interface OpenAIConfig {
|
|
20
|
+
apiKey: string;
|
|
21
|
+
model: string;
|
|
22
|
+
/** Any OpenAI-compatible endpoint. Defaults to OpenAI itself. */
|
|
23
|
+
baseUrl?: string;
|
|
24
|
+
/** Names the provider in errors and logs — "openrouter", "deepseek", … */
|
|
25
|
+
id?: string;
|
|
26
|
+
effort?: Effort;
|
|
27
|
+
maxTokens?: number;
|
|
28
|
+
fetchImpl?: typeof fetch;
|
|
29
|
+
headers?: Record<string, string>;
|
|
30
|
+
/**
|
|
31
|
+
* Pin OpenRouter to preferred upstream hosts so the PROMPT CACHE stays warm
|
|
32
|
+
* across rounds. The cache lives on the upstream host's account and default
|
|
33
|
+
* routing hops between them, and every hop is a cold cache — worse latency
|
|
34
|
+
* and higher effective input cost. Fallbacks stay on: this is a preference,
|
|
35
|
+
* not a lock.
|
|
36
|
+
*/
|
|
37
|
+
providerOrder?: string[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const DEFAULT_BASE_URL = "https://api.openai.com";
|
|
41
|
+
|
|
42
|
+
function mapFinishReason(reason: string | null | undefined): FinishReason | undefined {
|
|
43
|
+
switch (reason) {
|
|
44
|
+
case "stop":
|
|
45
|
+
return "stop";
|
|
46
|
+
case "length":
|
|
47
|
+
return "length";
|
|
48
|
+
case "tool_calls":
|
|
49
|
+
case "function_call":
|
|
50
|
+
return "tool_calls";
|
|
51
|
+
case "content_filter":
|
|
52
|
+
return "content_filter";
|
|
53
|
+
default:
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function partsToOpenAI(content: string | ContentPart[]): unknown {
|
|
59
|
+
if (typeof content === "string") return content;
|
|
60
|
+
return content.map((part) =>
|
|
61
|
+
part.type === "text"
|
|
62
|
+
? { type: "text", text: part.text }
|
|
63
|
+
: { type: "image_url", image_url: { url: toDataUri(part) } },
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Assistant turns carry `reasoning_content` when the history has it — thinking
|
|
69
|
+
* providers require the prior turn's chain-of-thought replayed on a turn that
|
|
70
|
+
* made a tool call. A caller running a turn with thinking OFF must strip it
|
|
71
|
+
* first (`stripReasoning`); the two cannot be mixed.
|
|
72
|
+
*/
|
|
73
|
+
export function toOpenAIMessages(messages: readonly ChatMessage[]): unknown[] {
|
|
74
|
+
return messages.map((message) => {
|
|
75
|
+
switch (message.role) {
|
|
76
|
+
case "system":
|
|
77
|
+
return { role: "system", content: message.content };
|
|
78
|
+
case "user":
|
|
79
|
+
return { role: "user", content: partsToOpenAI(message.content) };
|
|
80
|
+
case "tool":
|
|
81
|
+
return { role: "tool", tool_call_id: message.toolCallId, content: message.content };
|
|
82
|
+
case "assistant": {
|
|
83
|
+
const out: Record<string, unknown> = {
|
|
84
|
+
role: "assistant",
|
|
85
|
+
// Nullable content beside tool_calls is what this shape expects, but
|
|
86
|
+
// several gateways reject a bare null — "" satisfies both.
|
|
87
|
+
content: message.content || "",
|
|
88
|
+
};
|
|
89
|
+
if (message.reasoning) out.reasoning_content = message.reasoning;
|
|
90
|
+
if (message.toolCalls?.length) {
|
|
91
|
+
out.tool_calls = message.toolCalls.map((call) => ({
|
|
92
|
+
id: call.id,
|
|
93
|
+
type: "function",
|
|
94
|
+
function: { name: call.name, arguments: call.arguments },
|
|
95
|
+
}));
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
interface OpenAIChunk {
|
|
104
|
+
choices?: {
|
|
105
|
+
delta?: {
|
|
106
|
+
content?: string | null;
|
|
107
|
+
reasoning_content?: string | null;
|
|
108
|
+
reasoning?: string | null;
|
|
109
|
+
tool_calls?: {
|
|
110
|
+
index?: number;
|
|
111
|
+
id?: string;
|
|
112
|
+
function?: { name?: string; arguments?: string };
|
|
113
|
+
}[];
|
|
114
|
+
};
|
|
115
|
+
finish_reason?: string | null;
|
|
116
|
+
}[];
|
|
117
|
+
usage?: {
|
|
118
|
+
prompt_tokens?: number;
|
|
119
|
+
completion_tokens?: number;
|
|
120
|
+
prompt_tokens_details?: { cached_tokens?: number };
|
|
121
|
+
} | null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function createOpenAIProvider(config: OpenAIConfig): Provider {
|
|
125
|
+
const baseUrl = config.baseUrl ?? DEFAULT_BASE_URL;
|
|
126
|
+
const id = config.id ?? "openai";
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
id,
|
|
130
|
+
model: config.model,
|
|
131
|
+
|
|
132
|
+
async *createStream(
|
|
133
|
+
messages: ChatMessage[],
|
|
134
|
+
tools: ToolDefinition[],
|
|
135
|
+
opts: StreamOptions = {},
|
|
136
|
+
): AsyncIterable<ProviderChunk> {
|
|
137
|
+
const effort = opts.effort ?? config.effort;
|
|
138
|
+
|
|
139
|
+
const request: Record<string, unknown> = {
|
|
140
|
+
model: opts.model ?? config.model,
|
|
141
|
+
messages: toOpenAIMessages(messages),
|
|
142
|
+
stream: true,
|
|
143
|
+
// Without this the usage record never arrives and every call costs
|
|
144
|
+
// zero — a silent, total loss of the ledger.
|
|
145
|
+
stream_options: { include_usage: true },
|
|
146
|
+
};
|
|
147
|
+
const maxTokens = opts.maxTokens ?? config.maxTokens;
|
|
148
|
+
if (maxTokens !== undefined) request.max_tokens = maxTokens;
|
|
149
|
+
if (opts.temperature !== undefined) request.temperature = opts.temperature;
|
|
150
|
+
if (effort && effort !== "none") request.reasoning_effort = effort;
|
|
151
|
+
if (tools.length > 0) {
|
|
152
|
+
request.tools = tools.map((tool) => ({
|
|
153
|
+
type: "function",
|
|
154
|
+
function: {
|
|
155
|
+
name: tool.name,
|
|
156
|
+
description: tool.description,
|
|
157
|
+
parameters: tool.inputSchema,
|
|
158
|
+
},
|
|
159
|
+
}));
|
|
160
|
+
}
|
|
161
|
+
if (opts.toolChoice && opts.toolChoice !== "auto") {
|
|
162
|
+
request.tool_choice =
|
|
163
|
+
typeof opts.toolChoice === "string"
|
|
164
|
+
? opts.toolChoice
|
|
165
|
+
: { type: "function", function: { name: opts.toolChoice.name } };
|
|
166
|
+
}
|
|
167
|
+
if (opts.json) {
|
|
168
|
+
request.response_format = {
|
|
169
|
+
type: "json_schema",
|
|
170
|
+
json_schema: { name: opts.json.name, schema: opts.json.schema, strict: true },
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
if (config.providerOrder?.length) {
|
|
174
|
+
request.provider = { order: config.providerOrder, allow_fallbacks: true };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
for await (const data of streamSse({
|
|
178
|
+
url: apiUrl(baseUrl, "/v1/chat/completions"),
|
|
179
|
+
headers: { authorization: `Bearer ${config.apiKey}`, ...config.headers },
|
|
180
|
+
body: request,
|
|
181
|
+
provider: id,
|
|
182
|
+
...(opts.signal ? { signal: opts.signal } : {}),
|
|
183
|
+
...(config.fetchImpl ? { fetchImpl: config.fetchImpl } : {}),
|
|
184
|
+
})) {
|
|
185
|
+
let chunk: OpenAIChunk;
|
|
186
|
+
try {
|
|
187
|
+
chunk = JSON.parse(data) as OpenAIChunk;
|
|
188
|
+
} catch {
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// A usage-only frame carries no choices — this shape sends it last.
|
|
193
|
+
if (chunk.usage) {
|
|
194
|
+
const input = chunk.usage.prompt_tokens ?? 0;
|
|
195
|
+
const cached = chunk.usage.prompt_tokens_details?.cached_tokens ?? 0;
|
|
196
|
+
yield {
|
|
197
|
+
type: "usage",
|
|
198
|
+
usage: {
|
|
199
|
+
inputTokens: input,
|
|
200
|
+
// Already a SUBSET of prompt_tokens on this shape — unlike
|
|
201
|
+
// Anthropic's, which excludes them. No reconciling to do.
|
|
202
|
+
cachedInputTokens: cached,
|
|
203
|
+
outputTokens: chunk.usage.completion_tokens ?? 0,
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const choice = chunk.choices?.[0];
|
|
209
|
+
if (!choice) continue;
|
|
210
|
+
|
|
211
|
+
const delta = choice.delta;
|
|
212
|
+
if (delta) {
|
|
213
|
+
const out: ProviderChunk = { type: "delta" };
|
|
214
|
+
let has = false;
|
|
215
|
+
if (delta.content) {
|
|
216
|
+
out.content = delta.content;
|
|
217
|
+
has = true;
|
|
218
|
+
}
|
|
219
|
+
// `reasoning_content` is DeepSeek's field; `reasoning` is
|
|
220
|
+
// OpenRouter's normalized one. Whichever arrives is the same thing.
|
|
221
|
+
const reasoning = delta.reasoning_content ?? delta.reasoning;
|
|
222
|
+
if (reasoning) {
|
|
223
|
+
out.reasoning = reasoning;
|
|
224
|
+
has = true;
|
|
225
|
+
}
|
|
226
|
+
if (delta.tool_calls?.length) {
|
|
227
|
+
out.toolCalls = delta.tool_calls.map((call, position) => ({
|
|
228
|
+
// Some gateways omit `index` entirely on single-tool turns.
|
|
229
|
+
index: call.index ?? position,
|
|
230
|
+
...(call.id ? { id: call.id } : {}),
|
|
231
|
+
...(call.function?.name ? { name: call.function.name } : {}),
|
|
232
|
+
...(call.function?.arguments !== undefined
|
|
233
|
+
? { arguments: call.function.arguments }
|
|
234
|
+
: {}),
|
|
235
|
+
}));
|
|
236
|
+
has = true;
|
|
237
|
+
}
|
|
238
|
+
if (has) yield out;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const finishReason = mapFinishReason(choice.finish_reason);
|
|
242
|
+
if (finishReason) yield { type: "finish", finishReason };
|
|
243
|
+
}
|
|
244
|
+
},
|
|
245
|
+
};
|
|
246
|
+
}
|