@providerkit/core 0.2.0 → 0.4.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 +16 -9
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +17 -2
- package/dist/errors.js.map +1 -1
- package/dist/providers/anthropic.d.ts +1 -6
- package/dist/providers/anthropic.d.ts.map +1 -1
- package/dist/providers/anthropic.js +46 -3
- package/dist/providers/anthropic.js.map +1 -1
- package/dist/providers/gemini.d.ts.map +1 -1
- package/dist/providers/gemini.js +4 -0
- package/dist/providers/gemini.js.map +1 -1
- package/dist/providers/openai.d.ts +38 -1
- package/dist/providers/openai.d.ts.map +1 -1
- package/dist/providers/openai.js +122 -16
- package/dist/providers/openai.js.map +1 -1
- package/dist/providers/responses.d.ts.map +1 -1
- package/dist/providers/responses.js +6 -1
- package/dist/providers/responses.js.map +1 -1
- package/dist/schema.d.ts +21 -0
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +40 -0
- package/dist/schema.js.map +1 -1
- package/dist/types.d.ts +36 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +22 -3
- package/dist/types.js.map +1 -1
- package/dist/watchdog.d.ts +46 -0
- package/dist/watchdog.d.ts.map +1 -1
- package/dist/watchdog.js +92 -1
- package/dist/watchdog.js.map +1 -1
- package/dist/zod.d.ts +8 -1
- package/dist/zod.d.ts.map +1 -1
- package/dist/zod.js +9 -2
- package/dist/zod.js.map +1 -1
- package/package.json +4 -1
- package/src/errors.ts +16 -2
- package/src/providers/anthropic.ts +48 -3
- package/src/providers/gemini.ts +2 -0
- package/src/providers/openai.ts +148 -16
- package/src/providers/responses.ts +5 -1
- package/src/schema.ts +41 -0
- package/src/types.ts +56 -3
- package/src/watchdog.ts +124 -1
- package/src/zod.ts +12 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@providerkit/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "The layer under your agent loop: one seam for every LLM provider, plus the failure handling you only learn in production.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -23,6 +23,9 @@
|
|
|
23
23
|
"LICENSE"
|
|
24
24
|
],
|
|
25
25
|
"sideEffects": false,
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=22"
|
|
28
|
+
},
|
|
26
29
|
"publishConfig": {
|
|
27
30
|
"access": "public"
|
|
28
31
|
},
|
package/src/errors.ts
CHANGED
|
@@ -325,7 +325,10 @@ const RATE_PATTERNS: readonly RegExp[] = [
|
|
|
325
325
|
const OVERLOAD_PATTERNS: readonly RegExp[] = [
|
|
326
326
|
/overloaded|overloaded_error/i,
|
|
327
327
|
/\bunavailable\b|UNAVAILABLE/,
|
|
328
|
-
/internal error
|
|
328
|
+
/internal error/i,
|
|
329
|
+
// Google's status enum, which is upper-case by contract — kept exact so it
|
|
330
|
+
// does not swallow the word in ordinary prose.
|
|
331
|
+
/\bINTERNAL\b/,
|
|
329
332
|
/\bcapacity\b/i,
|
|
330
333
|
/"code"\s*:\s*5\d\d/,
|
|
331
334
|
];
|
|
@@ -356,6 +359,15 @@ export function classifyHttp(status: number | undefined, body: string): ErrorKin
|
|
|
356
359
|
}
|
|
357
360
|
|
|
358
361
|
export function classify(err: unknown, status?: number, body?: string): ErrorKind {
|
|
362
|
+
// An error this package already classified knows its own kind, and nothing is
|
|
363
|
+
// learned by deriving it a second time from a status and a body it never had.
|
|
364
|
+
//
|
|
365
|
+
// This is not a shortcut, it is a correctness fix. The watchdog's own idle
|
|
366
|
+
// timeout is a ProviderError carrying kind "timeout" and no HTTP status, so
|
|
367
|
+
// re-deriving it landed on "unknown" — not transient, therefore never
|
|
368
|
+
// retried, which is the exact opposite of the reason the watchdog exists
|
|
369
|
+
// (invariant 2). A wedged stream aborted at 60s and then failed for good.
|
|
370
|
+
if (err instanceof ProviderError) return err.kind;
|
|
359
371
|
if (isAbort(err)) return "aborted";
|
|
360
372
|
if (isTransportFailure(err)) return "network";
|
|
361
373
|
|
|
@@ -381,7 +393,9 @@ export function classify(err: unknown, status?: number, body?: string): ErrorKin
|
|
|
381
393
|
if (matches(CONTENT_PATTERNS, text)) return "content";
|
|
382
394
|
// 529 is Anthropic's own "overloaded". 409 is how several gateways say
|
|
383
395
|
// "the model is still loading" — both are worth another attempt.
|
|
384
|
-
|
|
396
|
+
// 425 Too Early: the upstream is asking us to replay, not telling us we were
|
|
397
|
+
// wrong. Left as a plain 4xx it reads as `invalid` and is never retried.
|
|
398
|
+
if (code === 529 || code === 409 || code === 425) return "overload";
|
|
385
399
|
if (code !== undefined && code >= 500) return "overload";
|
|
386
400
|
if (matches(RATE_PATTERNS, text)) return "rate";
|
|
387
401
|
if (matches(OVERLOAD_PATTERNS, text)) return "overload";
|
|
@@ -3,6 +3,7 @@ import { streamError } from "../errors.ts";
|
|
|
3
3
|
import { parseToolArgs } from "../tool-args.ts";
|
|
4
4
|
import { streamSse, apiUrl } from "../transport.ts";
|
|
5
5
|
import type {
|
|
6
|
+
JsonOutput,
|
|
6
7
|
ChatMessage,
|
|
7
8
|
ContentPart,
|
|
8
9
|
Effort,
|
|
@@ -87,8 +88,47 @@ function partsToAnthropic(content: string | ContentPart[]): unknown[] {
|
|
|
87
88
|
* turns carrying `tool_result` blocks — not as a role of their own. Consecutive
|
|
88
89
|
* tool results are merged into one user turn, which the API requires.
|
|
89
90
|
*/
|
|
91
|
+
/**
|
|
92
|
+
* The system prompt as ONE cached block.
|
|
93
|
+
*
|
|
94
|
+
* Anthropic's prompt caching is opt-in PER BLOCK — a plain string system prompt
|
|
95
|
+
* is never cached, however many times it is re-sent. An agent loop re-sends this
|
|
96
|
+
* every single turn, and it is the largest stable prefix in the request, so
|
|
97
|
+
* without the breakpoint the whole thing bills at the full input rate on every
|
|
98
|
+
* round instead of a tenth of it on all but the first.
|
|
99
|
+
*
|
|
100
|
+
* Unconditional. Below the model's minimum cacheable length the field is
|
|
101
|
+
* ignored rather than rejected, and above it the one-time 1.25× write is repaid
|
|
102
|
+
* by the second turn — which, in the loop this package sits under, always comes.
|
|
103
|
+
*/
|
|
104
|
+
function systemBlocks(text: string): unknown[] | undefined {
|
|
105
|
+
return text ? [{ type: "text", text, cache_control: { type: "ephemeral" } }] : undefined;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The schema, as an extra system block — Anthropic has no native schema mode,
|
|
110
|
+
* and the seam promises that a provider without one gets the schema in the
|
|
111
|
+
* prompt instead. Without this an `opts.json` request went out carrying
|
|
112
|
+
* nothing at all: the model answered in prose, the caller's `JSON.parse` threw,
|
|
113
|
+
* and the turn failed on the happy path where no retry looks.
|
|
114
|
+
*
|
|
115
|
+
* It rides AFTER the cached block, and that order is load-bearing. A cache
|
|
116
|
+
* breakpoint caches everything before it, so folding a per-call schema into the
|
|
117
|
+
* cached block would change the cached prefix on every turn whose schema
|
|
118
|
+
* differs and throw the whole system prompt's cache away — paying for the
|
|
119
|
+
* schema with the most expensive thing in an agent loop.
|
|
120
|
+
*/
|
|
121
|
+
function jsonBlock(json: JsonOutput): unknown {
|
|
122
|
+
return {
|
|
123
|
+
type: "text",
|
|
124
|
+
text:
|
|
125
|
+
"Respond with a single JSON object matching this schema. No prose, no code fence:\n" +
|
|
126
|
+
JSON.stringify(json.schema),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
90
130
|
export function toAnthropicMessages(messages: readonly ChatMessage[]): {
|
|
91
|
-
system?:
|
|
131
|
+
system?: unknown[];
|
|
92
132
|
messages: unknown[];
|
|
93
133
|
} {
|
|
94
134
|
const system = messages
|
|
@@ -141,7 +181,8 @@ export function toAnthropicMessages(messages: readonly ChatMessage[]): {
|
|
|
141
181
|
if (blocks.length > 0) pushBlocks("assistant", blocks);
|
|
142
182
|
}
|
|
143
183
|
|
|
144
|
-
|
|
184
|
+
const blocks = systemBlocks(system);
|
|
185
|
+
return { ...(blocks ? { system: blocks } : {}), messages: out };
|
|
145
186
|
}
|
|
146
187
|
|
|
147
188
|
interface AnthropicEvent {
|
|
@@ -191,8 +232,11 @@ export function createAnthropicProvider(config: AnthropicConfig): Provider {
|
|
|
191
232
|
messages: body,
|
|
192
233
|
stream: true,
|
|
193
234
|
};
|
|
194
|
-
|
|
235
|
+
const systemBody = opts.json ? [...(system ?? []), jsonBlock(opts.json)] : system;
|
|
236
|
+
if (systemBody?.length) request.system = systemBody;
|
|
195
237
|
if (opts.temperature !== undefined) request.temperature = opts.temperature;
|
|
238
|
+
if (opts.topP !== undefined) request.top_p = opts.topP;
|
|
239
|
+
if (opts.stopSequences?.length) request.stop_sequences = opts.stopSequences;
|
|
196
240
|
if (tools.length > 0) {
|
|
197
241
|
request.tools = tools.map((tool) => ({
|
|
198
242
|
name: tool.name,
|
|
@@ -213,6 +257,7 @@ export function createAnthropicProvider(config: AnthropicConfig): Provider {
|
|
|
213
257
|
request.thinking = { type: "enabled", budget_tokens: budget };
|
|
214
258
|
// Thinking and sampling are mutually exclusive on this shape.
|
|
215
259
|
delete request.temperature;
|
|
260
|
+
delete request.top_p;
|
|
216
261
|
}
|
|
217
262
|
|
|
218
263
|
// Anthropic reports cache reads and writes as fields of their OWN,
|
package/src/providers/gemini.ts
CHANGED
|
@@ -252,6 +252,8 @@ export function createGeminiProvider(config: GeminiConfig): Provider {
|
|
|
252
252
|
const generationConfig: Record<string, unknown> = {};
|
|
253
253
|
if (maxTokens !== undefined) generationConfig.maxOutputTokens = maxTokens;
|
|
254
254
|
if (opts.temperature !== undefined) generationConfig.temperature = opts.temperature;
|
|
255
|
+
if (opts.topP !== undefined) generationConfig.topP = opts.topP;
|
|
256
|
+
if (opts.stopSequences?.length) generationConfig.stopSequences = opts.stopSequences;
|
|
255
257
|
// No effort means the model's own dynamic thinking. Sending MINIMAL here
|
|
256
258
|
// would switch that off for a caller who never asked, which is the whole
|
|
257
259
|
// reason the seam treats an absent effort as "never sent".
|
package/src/providers/openai.ts
CHANGED
|
@@ -16,15 +16,29 @@ import type {
|
|
|
16
16
|
ToolDefinition,
|
|
17
17
|
} from "../types.ts";
|
|
18
18
|
import { toDataUri } from "../types.ts";
|
|
19
|
+
import { isStrictSchema } from "../schema.ts";
|
|
19
20
|
|
|
20
21
|
export interface OpenAIConfig {
|
|
21
22
|
apiKey: string;
|
|
22
23
|
model: string;
|
|
23
24
|
/** Any OpenAI-compatible endpoint. Defaults to OpenAI itself. */
|
|
24
25
|
baseUrl?: string;
|
|
25
|
-
/** Names the provider in errors and logs — "openrouter", "deepseek", …
|
|
26
|
+
/** Names the provider in errors and logs — "openrouter", "deepseek", … It
|
|
27
|
+
* also picks the effort dialect below, unless `effortDialect` overrides. */
|
|
26
28
|
id?: string;
|
|
27
29
|
effort?: Effort;
|
|
30
|
+
/**
|
|
31
|
+
* Which spelling of "think this hard" this endpoint accepts. Inferred from
|
|
32
|
+
* `id`; set it when the gateway is not named after its dialect, or to `off`
|
|
33
|
+
* for one that rejects the field outright.
|
|
34
|
+
*/
|
|
35
|
+
effortDialect?: EffortDialect;
|
|
36
|
+
/**
|
|
37
|
+
* `schema` sends a `json_schema` response format, `object` plain JSON mode.
|
|
38
|
+
* Defaults to `schema` for OpenAI itself and `object` everywhere else, which
|
|
39
|
+
* is the only setting every gateway accepts.
|
|
40
|
+
*/
|
|
41
|
+
jsonMode?: "schema" | "object";
|
|
28
42
|
maxTokens?: number;
|
|
29
43
|
fetchImpl?: typeof fetch;
|
|
30
44
|
headers?: Record<string, string>;
|
|
@@ -40,6 +54,64 @@ export interface OpenAIConfig {
|
|
|
40
54
|
|
|
41
55
|
const DEFAULT_BASE_URL = "https://api.openai.com";
|
|
42
56
|
|
|
57
|
+
/** The spellings of "think this hard" across the dialects that share this
|
|
58
|
+
* adapter. `off` sends nothing and leaves the model on its own default. */
|
|
59
|
+
export type EffortDialect = "openai" | "openrouter" | "deepseek" | "off";
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Effort → the request fields THIS endpoint accepts.
|
|
63
|
+
*
|
|
64
|
+
* One knob, three incompatible spellings, and the differences are not cosmetic:
|
|
65
|
+
*
|
|
66
|
+
* - **OpenRouter has no off switch.** `reasoning.enabled: false` is refused by
|
|
67
|
+
* models that always think — GLM 5.3 Flash answers `400 "Reasoning is
|
|
68
|
+
* mandatory for this endpoint and cannot be disabled."` — so `none` is
|
|
69
|
+
* floored at `low` rather than sent. Named rather than omitted, because
|
|
70
|
+
* letting the endpoint pick leaves cost and latency unpinned on exactly the
|
|
71
|
+
* tasks that asked for neither. Measured against the live endpoint, and it
|
|
72
|
+
* cost one app its onboarding read before it was.
|
|
73
|
+
* - **DeepSeek V4 defaults thinking ON**, so `none` has to be an explicit
|
|
74
|
+
* refusal. That is the case proving OpenRouter's floor is a constraint and
|
|
75
|
+
* not a preference for always thinking.
|
|
76
|
+
* - **OpenAI** takes `reasoning_effort` and nothing at all for `none`.
|
|
77
|
+
*
|
|
78
|
+
* An absent effort sends nothing on every dialect: the seam's rule is that a
|
|
79
|
+
* knob the caller never touched is a knob the provider still owns.
|
|
80
|
+
*/
|
|
81
|
+
export function effortParams(
|
|
82
|
+
dialect: EffortDialect,
|
|
83
|
+
effort: Effort | undefined,
|
|
84
|
+
): Record<string, unknown> {
|
|
85
|
+
if (!effort) return {};
|
|
86
|
+
const level = effort === "max" ? "high" : effort === "none" ? null : effort;
|
|
87
|
+
switch (dialect) {
|
|
88
|
+
case "deepseek":
|
|
89
|
+
if (level === null) return { thinking: { type: "disabled" } };
|
|
90
|
+
// A graded level rides only when it asks for LESS. DeepSeek auto-bumps a
|
|
91
|
+
// complex agent or tool request past its own default, and naming the top
|
|
92
|
+
// tier here caps exactly the turns that most need the bump — so `high`
|
|
93
|
+
// and `max` say "on" and leave the ceiling where DeepSeek puts it, while
|
|
94
|
+
// `low` and `medium` mean what they say.
|
|
95
|
+
return level === "high"
|
|
96
|
+
? { thinking: { type: "enabled" } }
|
|
97
|
+
: { thinking: { type: "enabled" }, reasoning_effort: level };
|
|
98
|
+
case "openrouter":
|
|
99
|
+
return { reasoning: { effort: level ?? "low" } };
|
|
100
|
+
case "openai":
|
|
101
|
+
return level === null ? {} : { reasoning_effort: level };
|
|
102
|
+
case "off":
|
|
103
|
+
return {};
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Gateways named after their dialect get it for free; everything else keeps
|
|
108
|
+
* the dialect this adapter is named for. */
|
|
109
|
+
function dialectFor(id: string): EffortDialect {
|
|
110
|
+
if (id === "openrouter") return "openrouter";
|
|
111
|
+
if (id === "deepseek") return "deepseek";
|
|
112
|
+
return "openai";
|
|
113
|
+
}
|
|
114
|
+
|
|
43
115
|
function mapFinishReason(reason: string | null | undefined): FinishReason | undefined {
|
|
44
116
|
switch (reason) {
|
|
45
117
|
case "stop":
|
|
@@ -72,33 +144,62 @@ function partsToOpenAI(content: string | ContentPart[]): unknown {
|
|
|
72
144
|
* first (`stripReasoning`); the two cannot be mixed.
|
|
73
145
|
*/
|
|
74
146
|
export function toOpenAIMessages(messages: readonly ChatMessage[]): unknown[] {
|
|
75
|
-
|
|
147
|
+
const out: unknown[] = [];
|
|
148
|
+
for (const message of messages) {
|
|
76
149
|
switch (message.role) {
|
|
77
150
|
case "system":
|
|
78
|
-
|
|
151
|
+
out.push({ role: "system", content: message.content });
|
|
152
|
+
break;
|
|
153
|
+
|
|
79
154
|
case "user":
|
|
80
|
-
|
|
155
|
+
out.push({ role: "user", content: partsToOpenAI(message.content) });
|
|
156
|
+
break;
|
|
157
|
+
|
|
81
158
|
case "tool":
|
|
82
|
-
|
|
159
|
+
out.push({ role: "tool", tool_call_id: message.toolCallId, content: message.content });
|
|
160
|
+
// This dialect has no image slot on a tool message — a `tool` role takes
|
|
161
|
+
// text and nothing else. A screenshot a tool hands back therefore
|
|
162
|
+
// follows as its own user message, which is the only way the model ever
|
|
163
|
+
// sees it. Dropped instead, the turn reads as a tool that returned
|
|
164
|
+
// words about a picture nobody was shown.
|
|
165
|
+
if (message.images?.length) {
|
|
166
|
+
out.push({
|
|
167
|
+
role: "user",
|
|
168
|
+
content: message.images.map((image) => ({
|
|
169
|
+
type: "image_url",
|
|
170
|
+
image_url: { url: toDataUri(image) },
|
|
171
|
+
})),
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
break;
|
|
175
|
+
|
|
83
176
|
case "assistant": {
|
|
84
|
-
const
|
|
177
|
+
const assistant: Record<string, unknown> = {
|
|
85
178
|
role: "assistant",
|
|
86
179
|
// Nullable content beside tool_calls is what this shape expects, but
|
|
87
180
|
// several gateways reject a bare null — "" satisfies both.
|
|
88
181
|
content: message.content || "",
|
|
89
182
|
};
|
|
90
|
-
if (message.reasoning)
|
|
183
|
+
if (message.reasoning) assistant.reasoning_content = message.reasoning;
|
|
184
|
+
// Verbatim, under the name the gateway gave it. Reshaped or dropped, the
|
|
185
|
+
// model loses its own record of how it reached the tool round it is
|
|
186
|
+
// being asked to continue.
|
|
187
|
+
if (message.reasoningDetails?.length) {
|
|
188
|
+
assistant.reasoning_details = message.reasoningDetails;
|
|
189
|
+
}
|
|
91
190
|
if (message.toolCalls?.length) {
|
|
92
|
-
|
|
191
|
+
assistant.tool_calls = message.toolCalls.map((call) => ({
|
|
93
192
|
id: call.id,
|
|
94
193
|
type: "function",
|
|
95
194
|
function: { name: call.name, arguments: call.arguments },
|
|
96
195
|
}));
|
|
97
196
|
}
|
|
98
|
-
|
|
197
|
+
out.push(assistant);
|
|
198
|
+
break;
|
|
99
199
|
}
|
|
100
200
|
}
|
|
101
|
-
}
|
|
201
|
+
}
|
|
202
|
+
return out;
|
|
102
203
|
}
|
|
103
204
|
|
|
104
205
|
interface OpenAIChunk {
|
|
@@ -107,6 +208,8 @@ interface OpenAIChunk {
|
|
|
107
208
|
content?: string | null;
|
|
108
209
|
reasoning_content?: string | null;
|
|
109
210
|
reasoning?: string | null;
|
|
211
|
+
/** OpenRouter's normalized reasoning payload, on the final delta. */
|
|
212
|
+
reasoning_details?: unknown[];
|
|
110
213
|
tool_calls?: {
|
|
111
214
|
index?: number;
|
|
112
215
|
id?: string;
|
|
@@ -119,6 +222,9 @@ interface OpenAIChunk {
|
|
|
119
222
|
prompt_tokens?: number;
|
|
120
223
|
completion_tokens?: number;
|
|
121
224
|
prompt_tokens_details?: { cached_tokens?: number };
|
|
225
|
+
/** DeepSeek's native API reports the cache-hit count here instead of in
|
|
226
|
+
* `prompt_tokens_details`, and it is absent from every OpenAI SDK type. */
|
|
227
|
+
prompt_cache_hit_tokens?: number;
|
|
122
228
|
} | null;
|
|
123
229
|
/** Present only on the in-band failure below — never beside a choice.
|
|
124
230
|
* `code` is the numeric HTTP status on the gateways, a slug on OpenAI. */
|
|
@@ -151,7 +257,9 @@ export function createOpenAIProvider(config: OpenAIConfig): Provider {
|
|
|
151
257
|
const maxTokens = opts.maxTokens ?? config.maxTokens;
|
|
152
258
|
if (maxTokens !== undefined) request.max_tokens = maxTokens;
|
|
153
259
|
if (opts.temperature !== undefined) request.temperature = opts.temperature;
|
|
154
|
-
if (
|
|
260
|
+
if (opts.topP !== undefined) request.top_p = opts.topP;
|
|
261
|
+
if (opts.stopSequences?.length) request.stop = opts.stopSequences;
|
|
262
|
+
Object.assign(request, effortParams(config.effortDialect ?? dialectFor(id), effort));
|
|
155
263
|
if (tools.length > 0) {
|
|
156
264
|
request.tools = tools.map((tool) => ({
|
|
157
265
|
type: "function",
|
|
@@ -169,10 +277,23 @@ export function createOpenAIProvider(config: OpenAIConfig): Provider {
|
|
|
169
277
|
: { type: "function", function: { name: opts.toolChoice.name } };
|
|
170
278
|
}
|
|
171
279
|
if (opts.json) {
|
|
172
|
-
request.response_format =
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
280
|
+
request.response_format =
|
|
281
|
+
(config.jsonMode ?? (id === "openai" ? "schema" : "object")) === "schema"
|
|
282
|
+
? {
|
|
283
|
+
type: "json_schema",
|
|
284
|
+
json_schema: {
|
|
285
|
+
name: opts.json.name,
|
|
286
|
+
schema: opts.json.schema,
|
|
287
|
+
strict: opts.json.strict ?? isStrictSchema(opts.json.schema),
|
|
288
|
+
},
|
|
289
|
+
}
|
|
290
|
+
: // Everything else gets plain JSON mode. Schema ENFORCEMENT is
|
|
291
|
+
// OpenAI's; the gateways and the vendors behind them offer JSON
|
|
292
|
+
// mode at best, and several answer a flat 400 to a `json_schema`
|
|
293
|
+
// block. The seam's rule makes this safe either way: a provider's
|
|
294
|
+
// "guaranteed" JSON is not one, so the caller validates
|
|
295
|
+
// regardless — this only decides whether the request is accepted.
|
|
296
|
+
{ type: "json_object" };
|
|
176
297
|
}
|
|
177
298
|
if (config.providerOrder?.length) {
|
|
178
299
|
request.provider = { order: config.providerOrder, allow_fallbacks: true };
|
|
@@ -204,7 +325,14 @@ export function createOpenAIProvider(config: OpenAIConfig): Provider {
|
|
|
204
325
|
// A usage-only frame carries no choices — this shape sends it last.
|
|
205
326
|
if (chunk.usage) {
|
|
206
327
|
const input = chunk.usage.prompt_tokens ?? 0;
|
|
207
|
-
|
|
328
|
+
// Two spellings for the same subset. DeepSeek's native endpoint uses
|
|
329
|
+
// its own field, and reading only the standard one bills every cached
|
|
330
|
+
// token at the full input rate — on an agent loop, where the re-sent
|
|
331
|
+
// prefix is overwhelmingly hits, that overstates a run by up to 10×.
|
|
332
|
+
const cached =
|
|
333
|
+
chunk.usage.prompt_tokens_details?.cached_tokens ??
|
|
334
|
+
chunk.usage.prompt_cache_hit_tokens ??
|
|
335
|
+
0;
|
|
208
336
|
yield {
|
|
209
337
|
type: "usage",
|
|
210
338
|
usage: {
|
|
@@ -235,6 +363,10 @@ export function createOpenAIProvider(config: OpenAIConfig): Provider {
|
|
|
235
363
|
out.reasoning = reasoning;
|
|
236
364
|
has = true;
|
|
237
365
|
}
|
|
366
|
+
if (delta.reasoning_details?.length) {
|
|
367
|
+
out.reasoningDetails = delta.reasoning_details;
|
|
368
|
+
has = true;
|
|
369
|
+
}
|
|
238
370
|
if (delta.tool_calls?.length) {
|
|
239
371
|
out.toolCalls = delta.tool_calls.map((call, position) => ({
|
|
240
372
|
// Some gateways omit `index` entirely on single-tool turns.
|
|
@@ -24,6 +24,7 @@ import type {
|
|
|
24
24
|
ToolDefinition,
|
|
25
25
|
} from "../types.ts";
|
|
26
26
|
import { toDataUri } from "../types.ts";
|
|
27
|
+
import { isStrictSchema } from "../schema.ts";
|
|
27
28
|
|
|
28
29
|
export interface ResponsesConfig {
|
|
29
30
|
apiKey: string;
|
|
@@ -250,6 +251,9 @@ export function createResponsesProvider(config: ResponsesConfig): Provider {
|
|
|
250
251
|
const maxTokens = opts.maxTokens ?? config.maxTokens;
|
|
251
252
|
if (maxTokens !== undefined) request.max_output_tokens = maxTokens;
|
|
252
253
|
if (opts.temperature !== undefined) request.temperature = opts.temperature;
|
|
254
|
+
if (opts.topP !== undefined) request.top_p = opts.topP;
|
|
255
|
+
// No stop sequences on this shape — it has no equivalent field, and
|
|
256
|
+
// inventing one would 400 the request rather than shorten the answer.
|
|
253
257
|
if (effort && effort !== "none") {
|
|
254
258
|
// `summary` is what switches the reasoning stream ON. Without it this
|
|
255
259
|
// shape emits no reasoning_summary_text events at all, and a caller
|
|
@@ -279,7 +283,7 @@ export function createResponsesProvider(config: ResponsesConfig): Provider {
|
|
|
279
283
|
type: "json_schema",
|
|
280
284
|
name: opts.json.name,
|
|
281
285
|
schema: opts.json.schema,
|
|
282
|
-
strict:
|
|
286
|
+
strict: opts.json.strict ?? isStrictSchema(opts.json.schema),
|
|
283
287
|
},
|
|
284
288
|
};
|
|
285
289
|
}
|
package/src/schema.ts
CHANGED
|
@@ -65,3 +65,44 @@ export function clampToSchema(value: unknown, node: unknown): unknown {
|
|
|
65
65
|
|
|
66
66
|
return value;
|
|
67
67
|
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Whether OpenAI's `strict` schema mode will accept this schema.
|
|
71
|
+
*
|
|
72
|
+
* Strict is the only JSON mode that actually guarantees the shape, so it is
|
|
73
|
+
* worth having — but it demands more than JSON Schema does: every property an
|
|
74
|
+
* object lists must ALSO be required, and every object must close itself with
|
|
75
|
+
* `additionalProperties: false`, all the way down. A schema with one optional
|
|
76
|
+
* field is not "mostly strict"; it is a flat 400 naming a nested path rather
|
|
77
|
+
* than the rule it broke.
|
|
78
|
+
*
|
|
79
|
+
* That trap is the reason this exists. An agent's response schema grows
|
|
80
|
+
* optional fields naturally — a `data` block only some flows fill, the fields
|
|
81
|
+
* one step collects — and a caller who adds one wants their answer, not a
|
|
82
|
+
* lecture about a mode they never asked for. So the OpenAI-shape adapters ask
|
|
83
|
+
* this and drop to plain (unenforced) schema mode instead of failing the turn.
|
|
84
|
+
*
|
|
85
|
+
* Anything it cannot verify — a `$ref`, a composed `allOf` — answers false:
|
|
86
|
+
* the cost of guessing wrong that way is unenforced output, and the cost of
|
|
87
|
+
* guessing wrong the other way is a request that cannot succeed at all.
|
|
88
|
+
*/
|
|
89
|
+
export function isStrictSchema(node: unknown): boolean {
|
|
90
|
+
if (!node || typeof node !== "object") return false;
|
|
91
|
+
const schema = node as SchemaNode;
|
|
92
|
+
|
|
93
|
+
if (schema.$ref !== undefined || schema.allOf !== undefined) return false;
|
|
94
|
+
|
|
95
|
+
const union = schema.anyOf ?? schema.oneOf;
|
|
96
|
+
if (Array.isArray(union)) return union.every(isStrictSchema);
|
|
97
|
+
|
|
98
|
+
// A leaf (string, number, boolean, enum) carries no strict obligations.
|
|
99
|
+
if (schema.type === "array") return schema.items === undefined || isStrictSchema(schema.items);
|
|
100
|
+
if (schema.properties === undefined) return true;
|
|
101
|
+
|
|
102
|
+
if (schema.additionalProperties !== false) return false;
|
|
103
|
+
const properties = schema.properties as Record<string, unknown>;
|
|
104
|
+
const required = new Set(Array.isArray(schema.required) ? (schema.required as string[]) : []);
|
|
105
|
+
return Object.entries(properties).every(
|
|
106
|
+
([key, value]) => required.has(key) && isStrictSchema(value),
|
|
107
|
+
);
|
|
108
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -71,6 +71,16 @@ export type ChatMessage =
|
|
|
71
71
|
* unsupported. `stripReasoning` below is that rule, once.
|
|
72
72
|
*/
|
|
73
73
|
reasoning?: string;
|
|
74
|
+
/**
|
|
75
|
+
* OpenRouter's normalized reasoning payload, arriving on the stream and
|
|
76
|
+
* riding back UNMODIFIED on the next turn's assistant message.
|
|
77
|
+
*
|
|
78
|
+
* Opaque on purpose — the same contract as Gemini's `thoughtSignature`,
|
|
79
|
+
* and for the same reason: it is the provider's own record of how it got
|
|
80
|
+
* here, and reading, reshaping or dropping it costs the model its
|
|
81
|
+
* continuity across a tool round. Absent on every other dialect.
|
|
82
|
+
*/
|
|
83
|
+
reasoningDetails?: unknown[];
|
|
74
84
|
toolCalls?: ToolCall[];
|
|
75
85
|
}
|
|
76
86
|
| {
|
|
@@ -131,6 +141,9 @@ export interface ProviderChunk {
|
|
|
131
141
|
type: "delta" | "usage" | "finish";
|
|
132
142
|
content?: string;
|
|
133
143
|
reasoning?: string;
|
|
144
|
+
/** OpenRouter's normalized reasoning payload — hand it back on the next
|
|
145
|
+
* turn's assistant message verbatim. See ChatMessage.reasoningDetails. */
|
|
146
|
+
reasoningDetails?: unknown[];
|
|
134
147
|
toolCalls?: ToolCallDelta[];
|
|
135
148
|
usage?: TokenUsage;
|
|
136
149
|
finishReason?: FinishReason;
|
|
@@ -148,6 +161,13 @@ export type ToolChoice = "auto" | "none" | "required" | { name: string };
|
|
|
148
161
|
export interface JsonOutput {
|
|
149
162
|
name: string;
|
|
150
163
|
schema: JsonObjectSchema;
|
|
164
|
+
/**
|
|
165
|
+
* Force OpenAI's strict schema mode on or off. Left unset, the adapters ask
|
|
166
|
+
* `isStrictSchema` and enforce whenever the schema actually qualifies —
|
|
167
|
+
* which is what keeps an optional field from turning a working call into a
|
|
168
|
+
* 400. Set it only to overrule that reading.
|
|
169
|
+
*/
|
|
170
|
+
strict?: boolean;
|
|
151
171
|
}
|
|
152
172
|
|
|
153
173
|
export interface StreamOptions {
|
|
@@ -159,6 +179,20 @@ export interface StreamOptions {
|
|
|
159
179
|
* silently truncated mid-argument. */
|
|
160
180
|
maxTokens?: number;
|
|
161
181
|
temperature?: number;
|
|
182
|
+
/**
|
|
183
|
+
* Nucleus sampling. Set this OR `temperature`, not both — the vendors all
|
|
184
|
+
* document them as alternatives and some reject the pair outright.
|
|
185
|
+
*/
|
|
186
|
+
topP?: number;
|
|
187
|
+
/**
|
|
188
|
+
* Strings that end the turn when generated. The only four-shape sampling
|
|
189
|
+
* field beyond these two; `top_k`, `metadata` and the rest are one vendor's
|
|
190
|
+
* each and stay off the seam, where a caller reaching for them is asking for
|
|
191
|
+
* that vendor rather than for a provider.
|
|
192
|
+
*
|
|
193
|
+
* Not sent on the Responses shape, which has no equivalent.
|
|
194
|
+
*/
|
|
195
|
+
stopSequences?: string[];
|
|
162
196
|
signal?: AbortSignal;
|
|
163
197
|
toolChoice?: ToolChoice;
|
|
164
198
|
json?: JsonOutput;
|
|
@@ -177,6 +211,8 @@ export interface Provider {
|
|
|
177
211
|
export interface Completion {
|
|
178
212
|
text: string;
|
|
179
213
|
reasoning: string;
|
|
214
|
+
/** Present only where the provider sent one. See ChatMessage.reasoningDetails. */
|
|
215
|
+
reasoningDetails?: unknown[];
|
|
180
216
|
usage: TokenUsage;
|
|
181
217
|
finishReason: FinishReason | null;
|
|
182
218
|
model: string;
|
|
@@ -190,19 +226,32 @@ export async function drainStream(
|
|
|
190
226
|
): Promise<Completion> {
|
|
191
227
|
let text = "";
|
|
192
228
|
let reasoning = "";
|
|
229
|
+
// Not concatenated: this half of the record is a payload the provider owns,
|
|
230
|
+
// and it arrives whole on one delta rather than in fragments. Dropped here,
|
|
231
|
+
// a drained turn replays only half its own reasoning on the next round —
|
|
232
|
+
// which is the failure `reasoningDetails` exists to prevent.
|
|
233
|
+
let reasoningDetails: unknown[] | undefined;
|
|
193
234
|
let usage: TokenUsage = EMPTY_USAGE;
|
|
194
235
|
let finishReason: FinishReason | null = null;
|
|
195
236
|
for await (const chunk of stream) {
|
|
196
237
|
if (chunk.type === "delta") {
|
|
197
238
|
if (chunk.content) text += chunk.content;
|
|
198
239
|
if (chunk.reasoning) reasoning += chunk.reasoning;
|
|
240
|
+
if (chunk.reasoningDetails?.length) reasoningDetails = chunk.reasoningDetails;
|
|
199
241
|
} else if (chunk.type === "usage" && chunk.usage) {
|
|
200
242
|
usage = chunk.usage;
|
|
201
243
|
} else if (chunk.type === "finish" && chunk.finishReason) {
|
|
202
244
|
finishReason = chunk.finishReason;
|
|
203
245
|
}
|
|
204
246
|
}
|
|
205
|
-
return {
|
|
247
|
+
return {
|
|
248
|
+
text,
|
|
249
|
+
reasoning,
|
|
250
|
+
...(reasoningDetails ? { reasoningDetails } : {}),
|
|
251
|
+
usage,
|
|
252
|
+
finishReason,
|
|
253
|
+
model,
|
|
254
|
+
};
|
|
206
255
|
}
|
|
207
256
|
|
|
208
257
|
/**
|
|
@@ -219,8 +268,12 @@ export async function drainStream(
|
|
|
219
268
|
*/
|
|
220
269
|
export function stripReasoning(messages: readonly ChatMessage[]): ChatMessage[] {
|
|
221
270
|
return messages.map((message) => {
|
|
222
|
-
if (message.role !== "assistant"
|
|
223
|
-
|
|
271
|
+
if (message.role !== "assistant") return message;
|
|
272
|
+
if (message.reasoning === undefined && message.reasoningDetails === undefined) return message;
|
|
273
|
+
// Both halves go. `reasoningDetails` is the same chain of thought in the
|
|
274
|
+
// provider's own words, so leaving it behind carries into a thinking-off
|
|
275
|
+
// turn exactly what stripping `reasoning` was meant to keep out.
|
|
276
|
+
const { reasoning: _text, reasoningDetails: _payload, ...rest } = message;
|
|
224
277
|
return rest;
|
|
225
278
|
});
|
|
226
279
|
}
|