@providerkit/core 0.15.0 → 0.16.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/README.md +3 -0
- package/dist/errors.d.ts +17 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +55 -6
- package/dist/errors.js.map +1 -1
- package/dist/presets.d.ts +37 -2
- package/dist/presets.d.ts.map +1 -1
- package/dist/presets.js +37 -2
- package/dist/presets.js.map +1 -1
- package/dist/providers/anthropic.d.ts.map +1 -1
- package/dist/providers/anthropic.js +84 -33
- package/dist/providers/anthropic.js.map +1 -1
- package/dist/providers/gemini.d.ts.map +1 -1
- package/dist/providers/gemini.js +25 -1
- package/dist/providers/gemini.js.map +1 -1
- package/dist/providers/openai.d.ts.map +1 -1
- package/dist/providers/openai.js +44 -16
- package/dist/providers/openai.js.map +1 -1
- package/dist/providers/responses.d.ts.map +1 -1
- package/dist/providers/responses.js +131 -24
- package/dist/providers/responses.js.map +1 -1
- package/dist/rate-limit.d.ts.map +1 -1
- package/dist/rate-limit.js +53 -22
- package/dist/rate-limit.js.map +1 -1
- package/dist/schema.d.ts +10 -0
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +49 -0
- package/dist/schema.js.map +1 -1
- package/dist/transport.d.ts +30 -3
- package/dist/transport.d.ts.map +1 -1
- package/dist/transport.js +67 -28
- package/dist/transport.js.map +1 -1
- package/dist/types.d.ts +26 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/usage.d.ts +5 -4
- package/dist/usage.d.ts.map +1 -1
- package/dist/usage.js +14 -5
- package/dist/usage.js.map +1 -1
- package/dist/watchdog.d.ts +22 -8
- package/dist/watchdog.d.ts.map +1 -1
- package/dist/watchdog.js +77 -27
- package/dist/watchdog.js.map +1 -1
- package/package.json +3 -2
- package/src/errors.ts +60 -5
- package/src/presets.ts +37 -2
- package/src/providers/anthropic.ts +94 -36
- package/src/providers/gemini.ts +30 -1
- package/src/providers/openai.ts +42 -16
- package/src/providers/responses.ts +158 -26
- package/src/rate-limit.ts +55 -24
- package/src/schema.ts +51 -0
- package/src/transport.ts +95 -24
- package/src/types.ts +26 -2
- package/src/usage.ts +14 -5
- package/src/watchdog.ts +89 -32
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Anthropic-shape adapter — SSE from POST /v1/messages.
|
|
2
|
-
import { fileRefused, streamError } from "../errors.ts";
|
|
2
|
+
import { fileRefused, streamCut, streamError } from "../errors.ts";
|
|
3
3
|
import { schemaPrompt, toAnthropicToolSchema } from "../schema.ts";
|
|
4
4
|
import { parseToolArgs } from "../tool-args.ts";
|
|
5
5
|
import { streamSse, apiUrl } from "../transport.ts";
|
|
@@ -14,6 +14,7 @@ import type {
|
|
|
14
14
|
Provider,
|
|
15
15
|
ProviderChunk,
|
|
16
16
|
StreamOptions,
|
|
17
|
+
ToolChoice,
|
|
17
18
|
ToolDefinition,
|
|
18
19
|
} from "../types.ts";
|
|
19
20
|
|
|
@@ -82,14 +83,19 @@ const THINKING_BUDGET: Record<Exclude<Effort, "none">, number> = {
|
|
|
82
83
|
|
|
83
84
|
/** How one Claude model that thinks adaptively takes `effort`. */
|
|
84
85
|
interface ClaudeThinking {
|
|
85
|
-
/** How `effort: "none"` is said: nothing, an explicit off,
|
|
86
|
-
* effort where there is no off
|
|
87
|
-
none: "omit" | "disabled" | "low";
|
|
86
|
+
/** How `effort: "none"` is said: nothing, an explicit off, an off for
|
|
87
|
+
* up-front thinking only, or the lowest effort where there is no off. */
|
|
88
|
+
none: "omit" | "disabled" | "between_tools" | "low";
|
|
88
89
|
/** Whether a non-default temperature or top_p survives while thinking is off. */
|
|
89
90
|
sampling: boolean;
|
|
91
|
+
/** Whether a tool choice that forces a tool (`any`, or one named tool) is
|
|
92
|
+
* accepted. Where it is not, it is a 400 on every request. */
|
|
93
|
+
forcedTool: boolean;
|
|
90
94
|
}
|
|
91
95
|
|
|
92
|
-
const ALWAYS_ON: ClaudeThinking = { none: "low", sampling: false };
|
|
96
|
+
const ALWAYS_ON: ClaudeThinking = { none: "low", sampling: false, forcedTool: true };
|
|
97
|
+
/** Always on, and a forced tool choice is a 400: the 5.1 and 5.5 models. */
|
|
98
|
+
const LATEST: ClaudeThinking = { ...ALWAYS_ON, forcedTool: false };
|
|
93
99
|
|
|
94
100
|
/**
|
|
95
101
|
* Claude models that take adaptive thinking, keyed by id. A graded effort is
|
|
@@ -103,6 +109,10 @@ const ALWAYS_ON: ClaudeThinking = { none: "low", sampling: false };
|
|
|
103
109
|
* `sampling: false` marks the models where a non-default temperature, top_p or
|
|
104
110
|
* top_k is a 400 on every request, thinking or not. Documented on the same date
|
|
105
111
|
* (…/build-with-claude/thinking, "Sampling parameters").
|
|
112
|
+
*
|
|
113
|
+
* `forcedTool: false` marks the models that "reject forced tool use on every
|
|
114
|
+
* request with a 400 error" (same page, "Response prefill and forced tool use",
|
|
115
|
+
* read 2026-10-01). Every other model here takes it with adaptive thinking.
|
|
106
116
|
*/
|
|
107
117
|
const CLAUDE_THINKING: ReadonlyMap<string, ClaudeThinking> = new Map([
|
|
108
118
|
// Always on: `enabled` and `disabled` are both 400s (Mythos Preview still
|
|
@@ -110,12 +120,17 @@ const CLAUDE_THINKING: ReadonlyMap<string, ClaudeThinking> = new Map([
|
|
|
110
120
|
// effort rather than silence, because silence runs the model's own default —
|
|
111
121
|
// `high`, or `medium` on Opus 5.5 — which is the request `none` exists to
|
|
112
122
|
// refuse (invariant 12).
|
|
113
|
-
["claude-fable-5-1",
|
|
114
|
-
["claude-mythos-5-1",
|
|
123
|
+
["claude-fable-5-1", LATEST],
|
|
124
|
+
["claude-mythos-5-1", LATEST],
|
|
125
|
+
["claude-opus-5-5", LATEST],
|
|
115
126
|
["claude-fable-5", ALWAYS_ON],
|
|
116
127
|
["claude-mythos-5", ALWAYS_ON],
|
|
117
128
|
["claude-mythos-preview", ALWAYS_ON],
|
|
118
|
-
|
|
129
|
+
// On by default, and `disabled` is a 400. `between_tools` is its lowest
|
|
130
|
+
// setting: no thinking up front, at effort high or below — and high is this
|
|
131
|
+
// model's default, so it needs no effort beside it (effort page, read
|
|
132
|
+
// 2026-10-01). It is also the one field it takes.
|
|
133
|
+
["claude-sonnet-5-5", { ...LATEST, none: "between_tools" }],
|
|
119
134
|
// On by default, and `disabled` is accepted at effort high or below. `none`
|
|
120
135
|
// still says `low`: with thinking disabled, Opus 5 occasionally writes a tool
|
|
121
136
|
// call into its visible text instead of a tool_use block, most often on
|
|
@@ -126,16 +141,16 @@ const CLAUDE_THINKING: ReadonlyMap<string, ClaudeThinking> = new Map([
|
|
|
126
141
|
// thinking on a turn it judges simple.
|
|
127
142
|
["claude-opus-5", ALWAYS_ON],
|
|
128
143
|
// On by default; `disabled` is accepted, with no such warning attached.
|
|
129
|
-
["claude-sonnet-5", { none: "disabled", sampling: false }],
|
|
144
|
+
["claude-sonnet-5", { none: "disabled", sampling: false, forcedTool: true }],
|
|
130
145
|
// Off by default, so saying nothing already is none.
|
|
131
|
-
["claude-opus-4-8", { none: "omit", sampling: false }],
|
|
132
|
-
["claude-opus-4-7", { none: "omit", sampling: false }],
|
|
146
|
+
["claude-opus-4-8", { none: "omit", sampling: false, forcedTool: true }],
|
|
147
|
+
["claude-opus-4-7", { none: "omit", sampling: false, forcedTool: true }],
|
|
133
148
|
// These still take `budget_tokens`, deprecated. Adaptive is the mode the docs
|
|
134
149
|
// say to use where both exist, and it drops extended mode's rule that the
|
|
135
150
|
// final assistant turn open with a thinking block — which this adapter,
|
|
136
151
|
// replaying no thinking, can never satisfy.
|
|
137
|
-
["claude-opus-4-6", { none: "omit", sampling: true }],
|
|
138
|
-
["claude-sonnet-4-6", { none: "omit", sampling: true }],
|
|
152
|
+
["claude-opus-4-6", { none: "omit", sampling: true, forcedTool: true }],
|
|
153
|
+
["claude-sonnet-4-6", { none: "omit", sampling: true, forcedTool: true }],
|
|
139
154
|
]);
|
|
140
155
|
|
|
141
156
|
/** Claude 4.5 and everything before it: extended thinking only (`adaptive` is
|
|
@@ -152,14 +167,15 @@ const CLAUDE_EXTENDED_ONLY = /^claude-(3-|(opus|sonnet|haiku)-4(-[0-5])?$)/;
|
|
|
152
167
|
* rejects `enabled`; the newest reject `disabled` too; and `output_config.effort`
|
|
153
168
|
* is accepted by every model that thinks adaptively. So an unknown id gets the
|
|
154
169
|
* one request none of them has refused, and no sampling fields, since every
|
|
155
|
-
* Claude since Opus 4.7 answers a non-default one with a 400.
|
|
156
|
-
*
|
|
170
|
+
* Claude since Opus 4.7 answers a non-default one with a 400. Nor a forced tool
|
|
171
|
+
* choice, which the newest refuse and an unforced request never trips. A model
|
|
172
|
+
* that turns out to differ will say so with a 400 of its own, and earn a row.
|
|
157
173
|
*/
|
|
158
174
|
function claudeThinking(model: string): ClaudeThinking | undefined {
|
|
159
175
|
if (!model.startsWith("claude-")) return undefined;
|
|
160
176
|
const family = model.replace(/-\d{8}$/, ""); // a dated snapshot is its family
|
|
161
177
|
if (CLAUDE_EXTENDED_ONLY.test(family)) return undefined;
|
|
162
|
-
return CLAUDE_THINKING.get(family) ??
|
|
178
|
+
return CLAUDE_THINKING.get(family) ?? LATEST;
|
|
163
179
|
}
|
|
164
180
|
|
|
165
181
|
/** Whether a request continues a tool loop: its last assistant message made
|
|
@@ -188,6 +204,18 @@ function mapStopReason(reason: string | undefined): FinishReason | undefined {
|
|
|
188
204
|
}
|
|
189
205
|
}
|
|
190
206
|
|
|
207
|
+
interface AnthropicToolChoice {
|
|
208
|
+
type: "auto" | "any" | "none" | "tool";
|
|
209
|
+
name?: string;
|
|
210
|
+
disable_parallel_tool_use?: boolean;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function toAnthropicToolChoice(choice: Exclude<ToolChoice, "auto">): AnthropicToolChoice {
|
|
214
|
+
if (choice === "none") return { type: "none" };
|
|
215
|
+
if (choice === "required") return { type: "any" };
|
|
216
|
+
return { type: "tool", name: choice.name };
|
|
217
|
+
}
|
|
218
|
+
|
|
191
219
|
function partsToAnthropic(content: string | ContentPart[], provider: string): unknown[] {
|
|
192
220
|
if (typeof content === "string") return [{ type: "text", text: content }];
|
|
193
221
|
return content.map((part) => {
|
|
@@ -354,22 +382,36 @@ export function createAnthropicProvider(config: AnthropicConfig): Provider {
|
|
|
354
382
|
if (opts.temperature !== undefined) request.temperature = opts.temperature;
|
|
355
383
|
if (opts.topP !== undefined) request.top_p = opts.topP;
|
|
356
384
|
if (opts.stopSequences?.length) request.stop_sequences = opts.stopSequences;
|
|
357
|
-
|
|
358
|
-
|
|
385
|
+
const claude = claudeThinking(model);
|
|
386
|
+
const forcesTool = opts.toolChoice === "required" || typeof opts.toolChoice === "object";
|
|
387
|
+
// A model that refuses a forced tool choice gets `auto` instead, which is
|
|
388
|
+
// the docs' own advice, and a named tool becomes the only tool it is
|
|
389
|
+
// offered. Sending the choice anyway is a 400 on every request.
|
|
390
|
+
// ponytail: the model may still answer in text. The upgrade is strict
|
|
391
|
+
// tool use or structured outputs, which the docs pair with `auto`.
|
|
392
|
+
const unforced = forcesTool && claude?.forcedTool === false;
|
|
393
|
+
const named = typeof opts.toolChoice === "object" ? opts.toolChoice.name : undefined;
|
|
394
|
+
const offered =
|
|
395
|
+
unforced && named !== undefined ? tools.filter((tool) => tool.name === named) : tools;
|
|
396
|
+
if (offered.length > 0) {
|
|
397
|
+
request.tools = offered.map((tool) => ({
|
|
359
398
|
name: tool.name,
|
|
360
399
|
description: tool.description,
|
|
361
400
|
input_schema: toAnthropicToolSchema(tool.inputSchema),
|
|
362
401
|
}));
|
|
363
402
|
}
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
opts.toolChoice
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
403
|
+
const choice =
|
|
404
|
+
opts.toolChoice && opts.toolChoice !== "auto" && !unforced
|
|
405
|
+
? toAnthropicToolChoice(opts.toolChoice)
|
|
406
|
+
: undefined;
|
|
407
|
+
// This wire says "one call at a time" inside the tool choice, so asking
|
|
408
|
+
// for it needs a choice to ride on — `auto` when the caller named none.
|
|
409
|
+
// `none` calls nothing, so there is nothing to serialize.
|
|
410
|
+
if (opts.parallelToolCalls === false && offered.length > 0 && choice?.type !== "none") {
|
|
411
|
+
request.tool_choice = { ...(choice ?? { type: "auto" }), disable_parallel_tool_use: true };
|
|
412
|
+
} else if (choice) {
|
|
413
|
+
request.tool_choice = choice;
|
|
371
414
|
}
|
|
372
|
-
const claude = claudeThinking(model);
|
|
373
415
|
// Extended mode, which is all Claude 4.5 and older have, can't think on
|
|
374
416
|
// two kinds of request this adapter sends, so those run without thinking.
|
|
375
417
|
// Documented (Anthropic Thinking page, read 2026-09-27):
|
|
@@ -382,7 +424,6 @@ export function createAnthropicProvider(config: AnthropicConfig): Provider {
|
|
|
382
424
|
// manual extended thinking." The caller asked for the tool, so it wins.
|
|
383
425
|
// ponytail: every loop step after the first goes unthought on these
|
|
384
426
|
// models. The upgrade is replaying the signed thinking blocks.
|
|
385
|
-
const forcesTool = opts.toolChoice === "required" || typeof opts.toolChoice === "object";
|
|
386
427
|
const unthought =
|
|
387
428
|
!claude && model.startsWith("claude-") && (forcesTool || continuesToolLoop(messages));
|
|
388
429
|
if (claude) {
|
|
@@ -390,6 +431,7 @@ export function createAnthropicProvider(config: AnthropicConfig): Provider {
|
|
|
390
431
|
// default, which on most of these is thinking ON.
|
|
391
432
|
if (effort === "none") {
|
|
392
433
|
if (claude.none === "disabled") request.thinking = { type: "disabled" };
|
|
434
|
+
if (claude.none === "between_tools") request.thinking = { type: "between_tools" };
|
|
393
435
|
if (claude.none === "low") request.output_config = { effort: "low" };
|
|
394
436
|
} else if (effort) {
|
|
395
437
|
request.thinking = { type: "adaptive" };
|
|
@@ -423,6 +465,19 @@ export function createAnthropicProvider(config: AnthropicConfig): Provider {
|
|
|
423
465
|
let outputTokens = 0;
|
|
424
466
|
let toolCall: { index: number; id: string; name: string } | null = null;
|
|
425
467
|
let blockIndex = -1;
|
|
468
|
+
// What proves the turn ended rather than the socket: a stated stop
|
|
469
|
+
// reason, or `message_stop`. See the check after the loop.
|
|
470
|
+
let stopped = false;
|
|
471
|
+
let closed = false;
|
|
472
|
+
const usageChunk = (): ProviderChunk => ({
|
|
473
|
+
type: "usage",
|
|
474
|
+
usage: {
|
|
475
|
+
inputTokens: freshInputTokens + cachedInputTokens + cacheWriteTokens,
|
|
476
|
+
cachedInputTokens,
|
|
477
|
+
cacheWriteTokens,
|
|
478
|
+
outputTokens,
|
|
479
|
+
},
|
|
480
|
+
});
|
|
426
481
|
|
|
427
482
|
for await (const data of streamSse({
|
|
428
483
|
url: apiUrl(baseUrl, "/v1/messages"),
|
|
@@ -438,6 +493,7 @@ export function createAnthropicProvider(config: AnthropicConfig): Provider {
|
|
|
438
493
|
provider: id,
|
|
439
494
|
...(opts.signal ? { signal: opts.signal } : {}),
|
|
440
495
|
...(config.fetchImpl ? { fetchImpl: config.fetchImpl } : {}),
|
|
496
|
+
...(opts.onActivity ? { onActivity: opts.onActivity } : {}),
|
|
441
497
|
})) {
|
|
442
498
|
let event: AnthropicEvent;
|
|
443
499
|
try {
|
|
@@ -505,24 +561,26 @@ export function createAnthropicProvider(config: AnthropicConfig): Provider {
|
|
|
505
561
|
cachedInputTokens = event.usage?.cache_read_input_tokens ?? cachedInputTokens;
|
|
506
562
|
cacheWriteTokens = event.usage?.cache_creation_input_tokens ?? cacheWriteTokens;
|
|
507
563
|
outputTokens = event.usage?.output_tokens ?? outputTokens;
|
|
564
|
+
if (event.delta?.stop_reason) stopped = true;
|
|
508
565
|
const finishReason = mapStopReason(event.delta?.stop_reason);
|
|
509
566
|
if (finishReason) yield { type: "finish", finishReason };
|
|
510
567
|
break;
|
|
511
568
|
}
|
|
512
569
|
|
|
513
570
|
case "message_stop":
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
usage: {
|
|
517
|
-
inputTokens: freshInputTokens + cachedInputTokens + cacheWriteTokens,
|
|
518
|
-
cachedInputTokens,
|
|
519
|
-
cacheWriteTokens,
|
|
520
|
-
outputTokens,
|
|
521
|
-
},
|
|
522
|
-
};
|
|
571
|
+
closed = true;
|
|
572
|
+
yield usageChunk();
|
|
523
573
|
break;
|
|
524
574
|
}
|
|
525
575
|
}
|
|
576
|
+
if (closed) return;
|
|
577
|
+
// A gateway that states its stop reason and then skips `message_stop`
|
|
578
|
+
// still finished the turn — and still billed it.
|
|
579
|
+
if (stopped) {
|
|
580
|
+
yield usageChunk();
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
throw streamCut(id);
|
|
526
584
|
},
|
|
527
585
|
};
|
|
528
586
|
return withConfiguredFallbacks(provider, config);
|
package/src/providers/gemini.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// its own chain of thought on the next turn. Written straight against the REST
|
|
6
6
|
// wire rather than @google/genai, because this package ships zero dependencies
|
|
7
7
|
// and the SDK is a Node-shaped one.
|
|
8
|
-
import { streamError } from "../errors.ts";
|
|
8
|
+
import { ProviderError, streamCut, streamError } from "../errors.ts";
|
|
9
9
|
import { schemaPrompt, toGeminiToolSchema } from "../schema.ts";
|
|
10
10
|
import { parseToolArgs } from "../tool-args.ts";
|
|
11
11
|
import { streamSse, apiUrl } from "../transport.ts";
|
|
@@ -257,6 +257,8 @@ interface GeminiResponse {
|
|
|
257
257
|
};
|
|
258
258
|
/** Present only on the in-band failure below — never on a real candidate. */
|
|
259
259
|
error?: GeminiStatus;
|
|
260
|
+
/** A prompt Gemini refused outright: no candidate at all, just the reason. */
|
|
261
|
+
promptFeedback?: { blockReason?: string };
|
|
260
262
|
}
|
|
261
263
|
|
|
262
264
|
export function createGeminiProvider(config: GeminiConfig): Provider {
|
|
@@ -329,6 +331,16 @@ export function createGeminiProvider(config: GeminiConfig): Provider {
|
|
|
329
331
|
];
|
|
330
332
|
if (systemParts.length > 0) request.systemInstruction = { parts: systemParts };
|
|
331
333
|
if (tools.length > 0) {
|
|
334
|
+
// Gemini has no switch for one call per turn. Sending the request
|
|
335
|
+
// anyway would hand back the parallel calls the caller ruled out.
|
|
336
|
+
if (opts.parallelToolCalls === false) {
|
|
337
|
+
throw new ProviderError(
|
|
338
|
+
id,
|
|
339
|
+
"invalid",
|
|
340
|
+
`${id}: Gemini can't limit a turn to one tool call. Leave parallelToolCalls unset ` +
|
|
341
|
+
"for Gemini models, or run the calls it returns one at a time.",
|
|
342
|
+
);
|
|
343
|
+
}
|
|
332
344
|
request.tools = [
|
|
333
345
|
{
|
|
334
346
|
functionDeclarations: tools.map((tool) => ({
|
|
@@ -351,6 +363,7 @@ export function createGeminiProvider(config: GeminiConfig): Provider {
|
|
|
351
363
|
// remembered rather than read off the final chunk.
|
|
352
364
|
let sawFunctionCalls = false;
|
|
353
365
|
let callIndex = 0;
|
|
366
|
+
let finished = false;
|
|
354
367
|
|
|
355
368
|
for await (const data of streamSse({
|
|
356
369
|
// Without `?alt=sse` the response is one long JSON array that only
|
|
@@ -361,6 +374,7 @@ export function createGeminiProvider(config: GeminiConfig): Provider {
|
|
|
361
374
|
provider: id,
|
|
362
375
|
...(opts.signal ? { signal: opts.signal } : {}),
|
|
363
376
|
...(config.fetchImpl ? { fetchImpl: config.fetchImpl } : {}),
|
|
377
|
+
...(opts.onActivity ? { onActivity: opts.onActivity } : {}),
|
|
364
378
|
})) {
|
|
365
379
|
let chunk: GeminiResponse;
|
|
366
380
|
try {
|
|
@@ -373,6 +387,14 @@ export function createGeminiProvider(config: GeminiConfig): Provider {
|
|
|
373
387
|
// throttle or an overload landing after that arrives here as a
|
|
374
388
|
// google.rpc.Status in the body rather than as a status line.
|
|
375
389
|
if (chunk.error) throw streamError(id, chunk.error);
|
|
390
|
+
// A blocked PROMPT has no candidate to finish, so it reads as a cut
|
|
391
|
+
// stream — retried, into the same block. It is a content failure.
|
|
392
|
+
const blocked = chunk.promptFeedback?.blockReason;
|
|
393
|
+
if (blocked) {
|
|
394
|
+
throw new ProviderError(id, "content", `${id}: the prompt was blocked (${blocked})`, {
|
|
395
|
+
code: blocked,
|
|
396
|
+
});
|
|
397
|
+
}
|
|
376
398
|
|
|
377
399
|
if (chunk.usageMetadata) {
|
|
378
400
|
const usage = chunk.usageMetadata;
|
|
@@ -385,6 +407,9 @@ export function createGeminiProvider(config: GeminiConfig): Provider {
|
|
|
385
407
|
// turn by most of what it cost.
|
|
386
408
|
outputTokens: (usage.candidatesTokenCount ?? 0) + (usage.thoughtsTokenCount ?? 0),
|
|
387
409
|
cachedInputTokens: usage.cachedContentTokenCount ?? 0,
|
|
410
|
+
...(usage.thoughtsTokenCount !== undefined
|
|
411
|
+
? { reasoningTokens: usage.thoughtsTokenCount }
|
|
412
|
+
: {}),
|
|
388
413
|
},
|
|
389
414
|
};
|
|
390
415
|
}
|
|
@@ -428,6 +453,7 @@ export function createGeminiProvider(config: GeminiConfig): Provider {
|
|
|
428
453
|
}
|
|
429
454
|
|
|
430
455
|
if (candidate.finishReason) {
|
|
456
|
+
finished = true;
|
|
431
457
|
yield {
|
|
432
458
|
type: "finish",
|
|
433
459
|
// A turn that called tools finishes as tool_calls whatever the
|
|
@@ -437,6 +463,9 @@ export function createGeminiProvider(config: GeminiConfig): Provider {
|
|
|
437
463
|
};
|
|
438
464
|
}
|
|
439
465
|
}
|
|
466
|
+
// Gemini states a finish reason on the last candidate of every turn it
|
|
467
|
+
// completes; a stream that closes without one was cut.
|
|
468
|
+
if (!finished) throw streamCut(id);
|
|
440
469
|
},
|
|
441
470
|
};
|
|
442
471
|
|
package/src/providers/openai.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// This is the dialect most gateways speak, so one adapter serves OpenAI,
|
|
4
4
|
// OpenRouter, DeepSeek, GLM, Kimi, Groq, Together, vLLM, Ollama and LM Studio.
|
|
5
5
|
// Their divergences are small and named where they appear.
|
|
6
|
-
import { fileRefused, streamError } from "../errors.ts";
|
|
6
|
+
import { fileRefused, streamCut, streamError } from "../errors.ts";
|
|
7
7
|
import { streamSse, apiUrl } from "../transport.ts";
|
|
8
8
|
import { attributionHeaders } from "../attribution.ts";
|
|
9
9
|
import type {
|
|
@@ -315,7 +315,14 @@ function partsToOpenAI(content: string | ContentPart[], provider: string): unkno
|
|
|
315
315
|
*/
|
|
316
316
|
export function toOpenAIMessages(messages: readonly ChatMessage[], provider = "openai"): unknown[] {
|
|
317
317
|
const out: unknown[] = [];
|
|
318
|
+
// Images from a run of tool results, held until the run ends. See "tool".
|
|
319
|
+
let toolImages: unknown[] = [];
|
|
320
|
+
const flushToolImages = () => {
|
|
321
|
+
if (toolImages.length > 0) out.push({ role: "user", content: toolImages });
|
|
322
|
+
toolImages = [];
|
|
323
|
+
};
|
|
318
324
|
for (const message of messages) {
|
|
325
|
+
if (message.role !== "tool") flushToolImages();
|
|
319
326
|
switch (message.role) {
|
|
320
327
|
case "system":
|
|
321
328
|
out.push({ role: "system", content: message.content });
|
|
@@ -329,17 +336,16 @@ export function toOpenAIMessages(messages: readonly ChatMessage[], provider = "o
|
|
|
329
336
|
out.push({ role: "tool", tool_call_id: message.toolCallId, content: message.content });
|
|
330
337
|
// This dialect has no image slot on a tool message — a `tool` role takes
|
|
331
338
|
// text and nothing else. A screenshot a tool hands back therefore
|
|
332
|
-
// follows as
|
|
333
|
-
//
|
|
334
|
-
//
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
});
|
|
339
|
+
// follows as a user message, which is the only way the model ever sees
|
|
340
|
+
// it. Dropped instead, the turn reads as a tool that returned words
|
|
341
|
+
// about a picture nobody was shown.
|
|
342
|
+
//
|
|
343
|
+
// It follows the whole RUN of tool results, not this one: an assistant
|
|
344
|
+
// turn that made parallel calls needs every answer before any user
|
|
345
|
+
// message, and an image wedged between two of them is a 400 that
|
|
346
|
+
// classifies as "invalid" — never retried, never walked to a backup.
|
|
347
|
+
for (const image of message.images ?? []) {
|
|
348
|
+
toolImages.push({ type: "image_url", image_url: { url: toDataUri(image) } });
|
|
343
349
|
}
|
|
344
350
|
break;
|
|
345
351
|
|
|
@@ -369,6 +375,7 @@ export function toOpenAIMessages(messages: readonly ChatMessage[], provider = "o
|
|
|
369
375
|
}
|
|
370
376
|
}
|
|
371
377
|
}
|
|
378
|
+
flushToolImages();
|
|
372
379
|
return out;
|
|
373
380
|
}
|
|
374
381
|
|
|
@@ -392,9 +399,12 @@ interface OpenAIChunk {
|
|
|
392
399
|
prompt_tokens?: number;
|
|
393
400
|
completion_tokens?: number;
|
|
394
401
|
prompt_tokens_details?: { cached_tokens?: number };
|
|
402
|
+
completion_tokens_details?: { reasoning_tokens?: number };
|
|
395
403
|
/** DeepSeek's native API reports the cache-hit count here instead of in
|
|
396
404
|
* `prompt_tokens_details`, and it is absent from every OpenAI SDK type. */
|
|
397
405
|
prompt_cache_hit_tokens?: number;
|
|
406
|
+
/** Kimi (Moonshot) puts it at the top level of `usage`. */
|
|
407
|
+
cached_tokens?: number;
|
|
398
408
|
/** OpenRouter only, from here down: what the call cost, in its credits,
|
|
399
409
|
* which are US dollars. */
|
|
400
410
|
cost?: unknown;
|
|
@@ -487,6 +497,8 @@ export function createOpenAIProvider(config: OpenAIConfig): Provider {
|
|
|
487
497
|
parameters: tool.inputSchema,
|
|
488
498
|
},
|
|
489
499
|
}));
|
|
500
|
+
// Only beside tools: OpenAI refuses the field on a request without any.
|
|
501
|
+
if (opts.parallelToolCalls === false) request.parallel_tool_calls = false;
|
|
490
502
|
}
|
|
491
503
|
if (opts.toolChoice && opts.toolChoice !== "auto") {
|
|
492
504
|
request.tool_choice =
|
|
@@ -561,6 +573,7 @@ export function createOpenAIProvider(config: OpenAIConfig): Provider {
|
|
|
561
573
|
? "/chat/completions"
|
|
562
574
|
: "/v1/chat/completions";
|
|
563
575
|
|
|
576
|
+
let ended = false;
|
|
564
577
|
for await (const data of streamSse({
|
|
565
578
|
url: apiUrl(baseUrl, config.path ?? defaultPath),
|
|
566
579
|
headers: {
|
|
@@ -575,6 +588,10 @@ export function createOpenAIProvider(config: OpenAIConfig): Provider {
|
|
|
575
588
|
provider: id,
|
|
576
589
|
...(opts.signal ? { signal: opts.signal } : {}),
|
|
577
590
|
...(config.fetchImpl ? { fetchImpl: config.fetchImpl } : {}),
|
|
591
|
+
...(opts.onActivity ? { onActivity: opts.onActivity } : {}),
|
|
592
|
+
onDone: () => {
|
|
593
|
+
ended = true;
|
|
594
|
+
},
|
|
578
595
|
})) {
|
|
579
596
|
let chunk: OpenAIChunk;
|
|
580
597
|
try {
|
|
@@ -594,13 +611,15 @@ export function createOpenAIProvider(config: OpenAIConfig): Provider {
|
|
|
594
611
|
// A usage-only frame carries no choices — this shape sends it last.
|
|
595
612
|
if (chunk.usage) {
|
|
596
613
|
const input = chunk.usage.prompt_tokens ?? 0;
|
|
597
|
-
//
|
|
598
|
-
//
|
|
599
|
-
// token at the full input rate — on an agent loop,
|
|
600
|
-
// prefix is overwhelmingly hits, that overstates a
|
|
614
|
+
// Three spellings for the same subset. DeepSeek's native endpoint and
|
|
615
|
+
// Kimi each use their own field, and reading only the standard one
|
|
616
|
+
// bills every cached token at the full input rate — on an agent loop,
|
|
617
|
+
// where the re-sent prefix is overwhelmingly hits, that overstates a
|
|
618
|
+
// run by up to 10×.
|
|
601
619
|
const cached =
|
|
602
620
|
chunk.usage.prompt_tokens_details?.cached_tokens ??
|
|
603
621
|
chunk.usage.prompt_cache_hit_tokens ??
|
|
622
|
+
chunk.usage.cached_tokens ??
|
|
604
623
|
0;
|
|
605
624
|
const reportedCostUsd = reportsCost ? openRouterCostUsd(chunk.usage) : undefined;
|
|
606
625
|
yield {
|
|
@@ -611,6 +630,9 @@ export function createOpenAIProvider(config: OpenAIConfig): Provider {
|
|
|
611
630
|
// Anthropic's, which excludes them. No reconciling to do.
|
|
612
631
|
cachedInputTokens: cached,
|
|
613
632
|
outputTokens: chunk.usage.completion_tokens ?? 0,
|
|
633
|
+
...(chunk.usage.completion_tokens_details?.reasoning_tokens !== undefined
|
|
634
|
+
? { reasoningTokens: chunk.usage.completion_tokens_details.reasoning_tokens }
|
|
635
|
+
: {}),
|
|
614
636
|
...(reportedCostUsd !== undefined ? { reportedCostUsd } : {}),
|
|
615
637
|
},
|
|
616
638
|
};
|
|
@@ -653,9 +675,13 @@ export function createOpenAIProvider(config: OpenAIConfig): Provider {
|
|
|
653
675
|
if (has) yield out;
|
|
654
676
|
}
|
|
655
677
|
|
|
678
|
+
if (choice.finish_reason) ended = true;
|
|
656
679
|
const finishReason = mapFinishReason(choice.finish_reason);
|
|
657
680
|
if (finishReason) yield { type: "finish", finishReason };
|
|
658
681
|
}
|
|
682
|
+
// Either proof will do: some gateways send `[DONE]` with no finish, and
|
|
683
|
+
// some a finish with no `[DONE]`. Neither means the socket closed early.
|
|
684
|
+
if (!ended) throw streamCut(id);
|
|
659
685
|
},
|
|
660
686
|
};
|
|
661
687
|
|