@providerkit/core 0.12.2 → 0.13.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.
Files changed (43) hide show
  1. package/README.md +1 -0
  2. package/dist/context.js +2 -2
  3. package/dist/context.js.map +1 -1
  4. package/dist/errors.d.ts +8 -0
  5. package/dist/errors.d.ts.map +1 -1
  6. package/dist/errors.js +14 -0
  7. package/dist/errors.js.map +1 -1
  8. package/dist/jev.d.ts +123 -0
  9. package/dist/jev.d.ts.map +1 -0
  10. package/dist/jev.js +278 -0
  11. package/dist/jev.js.map +1 -0
  12. package/dist/presets.d.ts +3 -2
  13. package/dist/presets.d.ts.map +1 -1
  14. package/dist/presets.js.map +1 -1
  15. package/dist/providers/anthropic.d.ts +5 -4
  16. package/dist/providers/anthropic.d.ts.map +1 -1
  17. package/dist/providers/anthropic.js +137 -16
  18. package/dist/providers/anthropic.js.map +1 -1
  19. package/dist/providers/gemini.d.ts.map +1 -1
  20. package/dist/providers/gemini.js +2 -0
  21. package/dist/providers/gemini.js.map +1 -1
  22. package/dist/providers/openai.d.ts +36 -17
  23. package/dist/providers/openai.d.ts.map +1 -1
  24. package/dist/providers/openai.js +74 -39
  25. package/dist/providers/openai.js.map +1 -1
  26. package/dist/providers/responses.d.ts +1 -1
  27. package/dist/providers/responses.d.ts.map +1 -1
  28. package/dist/providers/responses.js +16 -8
  29. package/dist/providers/responses.js.map +1 -1
  30. package/dist/types.d.ts +47 -2
  31. package/dist/types.d.ts.map +1 -1
  32. package/dist/types.js +84 -1
  33. package/dist/types.js.map +1 -1
  34. package/package.json +5 -1
  35. package/src/context.ts +2 -2
  36. package/src/errors.ts +20 -0
  37. package/src/jev.ts +444 -0
  38. package/src/presets.ts +3 -2
  39. package/src/providers/anthropic.ts +152 -23
  40. package/src/providers/gemini.ts +2 -0
  41. package/src/providers/openai.ts +76 -41
  42. package/src/providers/responses.ts +20 -10
  43. package/src/types.ts +106 -2
@@ -1,5 +1,5 @@
1
1
  // Anthropic-shape adapter — SSE from POST /v1/messages.
2
- import { streamError } from "../errors.ts";
2
+ import { fileRefused, 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";
@@ -55,9 +55,10 @@ export interface AnthropicConfig extends ProviderFallbackConfig {
55
55
  * where an absent field means the MODEL's default, not off — Z.ai's coding
56
56
  * endpoint reads silence as thinking ON for reasoning-mandatory models like
57
57
  * GLM 5.3 Flash (measured 2026-09-13: omit → thinking block; disabled →
58
- * none), and it accepts the marker natively. Native Anthropic has no such
59
- * marker and defaults to off when the field is absent, so leave this unset
60
- * there. */
58
+ * none), and it accepts the marker natively. Ignored for a Claude id that
59
+ * thinks adaptively (4.6 and later): each has its own spelling of none (see
60
+ * `CLAUDE_THINKING`), and several reject this marker outright. Claude 4.5
61
+ * and older accept it, and get it. */
61
62
  explicitNone?: boolean;
62
63
  }
63
64
 
@@ -67,9 +68,10 @@ const DEFAULT_VERSION = "2023-06-01";
67
68
  const DEFAULT_MAX_TOKENS = 8_192;
68
69
 
69
70
  /**
70
- * Thinking budgets, in output tokens. Thinking and the answer SHARE
71
- * `max_tokens`, so a budget is always left below the ceiling — a budget at or
72
- * above it leaves no room to answer, and the turn ends mid-thought.
71
+ * Thinking budgets, in output tokens, for extended thinking — Claude 4.5 and
72
+ * older, and every non-Claude endpoint on this wire. Thinking and the answer
73
+ * SHARE `max_tokens`, so a budget is always left below the ceiling — a budget
74
+ * at or above it leaves no room to answer, and the turn ends mid-thought.
73
75
  */
74
76
  const THINKING_BUDGET: Record<Exclude<Effort, "none">, number> = {
75
77
  low: 2_048,
@@ -78,6 +80,98 @@ const THINKING_BUDGET: Record<Exclude<Effort, "none">, number> = {
78
80
  max: 32_768,
79
81
  };
80
82
 
83
+ /** How one Claude model that thinks adaptively takes `effort`. */
84
+ interface ClaudeThinking {
85
+ /** How `effort: "none"` is said: nothing, an explicit off, or the lowest
86
+ * effort where there is no off to ask for. */
87
+ none: "omit" | "disabled" | "low";
88
+ /** Whether a non-default temperature or top_p survives while thinking is off. */
89
+ sampling: boolean;
90
+ }
91
+
92
+ const ALWAYS_ON: ClaudeThinking = { none: "low", sampling: false };
93
+
94
+ /**
95
+ * Claude models that take adaptive thinking, keyed by id. A graded effort is
96
+ * said the same way on all of them — `thinking: { type: "adaptive" }` plus
97
+ * `output_config.effort` — which every one accepts, and which the docs say to
98
+ * use wherever a model also takes `budget_tokens`. What differs is `none`,
99
+ * because what each model does when told nothing differs. Documented (Anthropic per-model table, read
100
+ * 2026-09-27: platform.claude.com/docs/en/build-with-claude/thinking-troubleshooting);
101
+ * none of it was measured live.
102
+ *
103
+ * `sampling: false` marks the models where a non-default temperature, top_p or
104
+ * top_k is a 400 on every request, thinking or not. Documented on the same date
105
+ * (…/build-with-claude/thinking, "Sampling parameters").
106
+ */
107
+ const CLAUDE_THINKING: ReadonlyMap<string, ClaudeThinking> = new Map([
108
+ // Always on: `enabled` and `disabled` are both 400s (Mythos Preview still
109
+ // takes `enabled`), so there is no off to ask for. `none` is the lowest
110
+ // effort rather than silence, because silence runs the model's own default —
111
+ // `high`, or `medium` on Opus 5.5 — which is the request `none` exists to
112
+ // refuse (invariant 12).
113
+ ["claude-fable-5-1", ALWAYS_ON],
114
+ ["claude-mythos-5-1", ALWAYS_ON],
115
+ ["claude-fable-5", ALWAYS_ON],
116
+ ["claude-mythos-5", ALWAYS_ON],
117
+ ["claude-mythos-preview", ALWAYS_ON],
118
+ ["claude-opus-5-5", ALWAYS_ON],
119
+ // On by default, and `disabled` is accepted at effort high or below. `none`
120
+ // still says `low`: with thinking disabled, Opus 5 occasionally writes a tool
121
+ // call into its visible text instead of a tool_use block, most often on
122
+ // tool-heavy work (documented, same page). That call never runs and nothing
123
+ // errors, so the turn reads as a clean answer — a failure on the happy path,
124
+ // in exactly the loop this package sits under. Anthropic's own advice is to
125
+ // keep thinking on and lower effort; at `low` the model can still skip
126
+ // thinking on a turn it judges simple.
127
+ ["claude-opus-5", ALWAYS_ON],
128
+ // On by default; `disabled` is accepted, with no such warning attached.
129
+ ["claude-sonnet-5", { none: "disabled", sampling: false }],
130
+ // 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 }],
133
+ // These still take `budget_tokens`, deprecated. Adaptive is the mode the docs
134
+ // say to use where both exist, and it drops extended mode's rule that the
135
+ // final assistant turn open with a thinking block — which this adapter,
136
+ // replaying no thinking, can never satisfy.
137
+ ["claude-opus-4-6", { none: "omit", sampling: true }],
138
+ ["claude-sonnet-4-6", { none: "omit", sampling: true }],
139
+ ]);
140
+
141
+ /** Claude 4.5 and everything before it: extended thinking only (`adaptive` is
142
+ * a 400), off by default. The budget dialect below was written for these. */
143
+ const CLAUDE_EXTENDED_ONLY = /^claude-(3-|(opus|sonnet|haiku)-4(-[0-5])?$)/;
144
+
145
+ /**
146
+ * The adaptive-thinking rule for a Claude id, or `undefined` for every id that
147
+ * keeps the budget dialect: Claude 4.5 and older, and every other vendor's
148
+ * model on this wire (their endpoints' dialects are their own).
149
+ *
150
+ * An id that starts with `claude-` but is in neither list is a Claude newer
151
+ * than this table, and it is treated as always on. Every Claude since Opus 4.7
152
+ * rejects `enabled`; the newest reject `disabled` too; and `output_config.effort`
153
+ * is accepted by every model that thinks adaptively. So an unknown id gets the
154
+ * 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. A model that
156
+ * turns out to differ will say so with a 400 of its own, and earn a row.
157
+ */
158
+ function claudeThinking(model: string): ClaudeThinking | undefined {
159
+ if (!model.startsWith("claude-")) return undefined;
160
+ const family = model.replace(/-\d{8}$/, ""); // a dated snapshot is its family
161
+ if (CLAUDE_EXTENDED_ONLY.test(family)) return undefined;
162
+ return CLAUDE_THINKING.get(family) ?? ALWAYS_ON;
163
+ }
164
+
165
+ /** Whether a request continues a tool loop: its last assistant message made
166
+ * tool calls, so the request carries their results inside the same turn. */
167
+ function continuesToolLoop(messages: readonly ChatMessage[]): boolean {
168
+ for (let i = messages.length - 1; i >= 0; i--) {
169
+ const message = messages[i];
170
+ if (message?.role === "assistant") return (message.toolCalls?.length ?? 0) > 0;
171
+ }
172
+ return false;
173
+ }
174
+
81
175
  function mapStopReason(reason: string | undefined): FinishReason | undefined {
82
176
  switch (reason) {
83
177
  case "end_turn":
@@ -94,16 +188,16 @@ function mapStopReason(reason: string | undefined): FinishReason | undefined {
94
188
  }
95
189
  }
96
190
 
97
- function partsToAnthropic(content: string | ContentPart[]): unknown[] {
191
+ function partsToAnthropic(content: string | ContentPart[], provider: string): unknown[] {
98
192
  if (typeof content === "string") return [{ type: "text", text: content }];
99
- return content.map((part) =>
100
- part.type === "text"
101
- ? { type: "text", text: part.text }
102
- : {
103
- type: "image",
104
- source: { type: "base64", media_type: part.mimeType, data: part.data },
105
- },
106
- );
193
+ return content.map((part) => {
194
+ if (part.type === "text") return { type: "text", text: part.text };
195
+ if (part.type === "file") throw fileRefused(provider, "Anthropic", part);
196
+ return {
197
+ type: "image",
198
+ source: { type: "base64", media_type: part.mimeType, data: part.data },
199
+ };
200
+ });
107
201
  }
108
202
 
109
203
  /**
@@ -145,7 +239,10 @@ function jsonBlock(json: JsonOutput): unknown {
145
239
  return { type: "text", text: schemaPrompt(json.schema) };
146
240
  }
147
241
 
148
- export function toAnthropicMessages(messages: readonly ChatMessage[]): {
242
+ export function toAnthropicMessages(
243
+ messages: readonly ChatMessage[],
244
+ provider = "anthropic",
245
+ ): {
149
246
  system?: unknown[];
150
247
  messages: unknown[];
151
248
  } {
@@ -164,7 +261,7 @@ export function toAnthropicMessages(messages: readonly ChatMessage[]): {
164
261
  for (const message of messages) {
165
262
  if (message.role === "system") continue;
166
263
  if (message.role === "user") {
167
- pushBlocks("user", partsToAnthropic(message.content));
264
+ pushBlocks("user", partsToAnthropic(message.content, provider));
168
265
  continue;
169
266
  }
170
267
  if (message.role === "tool") {
@@ -243,8 +340,8 @@ export function createAnthropicProvider(config: AnthropicConfig): Provider {
243
340
  ): AsyncIterable<ProviderChunk> {
244
341
  const model = opts.model ?? config.model;
245
342
  const maxTokens = opts.maxTokens ?? config.maxTokens ?? DEFAULT_MAX_TOKENS;
246
- const effort = opts.effort ?? config.effort ?? "none";
247
- const { system, messages: body } = toAnthropicMessages(messages);
343
+ const effort = opts.effort ?? config.effort;
344
+ const { system, messages: body } = toAnthropicMessages(messages, id);
248
345
 
249
346
  const request: Record<string, unknown> = {
250
347
  model,
@@ -272,15 +369,47 @@ export function createAnthropicProvider(config: AnthropicConfig): Provider {
272
369
  ? { type: "any" }
273
370
  : { type: "tool", name: opts.toolChoice.name };
274
371
  }
275
- if (effort !== "none") {
372
+ const claude = claudeThinking(model);
373
+ // Extended mode, which is all Claude 4.5 and older have, can't think on
374
+ // two kinds of request this adapter sends, so those run without thinking.
375
+ // Documented (Anthropic Thinking page, read 2026-09-27):
376
+ // - One that continues a tool loop. A thinking request's final assistant
377
+ // turn must open with a thinking block, and this adapter sends none back
378
+ // (see toAnthropicMessages). Turning thinking off mid-turn is documented
379
+ // not to error, and the next user turn thinks again.
380
+ // - One whose tool choice forces a tool: `any` or `tool` "results in an
381
+ // error because these options force tool use, which is incompatible with
382
+ // manual extended thinking." The caller asked for the tool, so it wins.
383
+ // ponytail: every loop step after the first goes unthought on these
384
+ // models. The upgrade is replaying the signed thinking blocks.
385
+ const forcesTool = opts.toolChoice === "required" || typeof opts.toolChoice === "object";
386
+ const unthought =
387
+ !claude && model.startsWith("claude-") && (forcesTool || continuesToolLoop(messages));
388
+ if (claude) {
389
+ // An effort the caller never set sends nothing: the model keeps its own
390
+ // default, which on most of these is thinking ON.
391
+ if (effort === "none") {
392
+ if (claude.none === "disabled") request.thinking = { type: "disabled" };
393
+ if (claude.none === "low") request.output_config = { effort: "low" };
394
+ } else if (effort) {
395
+ request.thinking = { type: "adaptive" };
396
+ request.output_config = { effort };
397
+ }
398
+ const thinking = effort !== undefined && effort !== "none";
399
+ if (thinking || !claude.sampling) {
400
+ delete request.temperature;
401
+ delete request.top_p;
402
+ }
403
+ } else if (effort && effort !== "none" && !unthought) {
276
404
  const budget = Math.min(THINKING_BUDGET[effort], Math.floor(maxTokens * 0.8));
277
405
  request.thinking = { type: "enabled", budget_tokens: budget };
278
406
  // Thinking and sampling are mutually exclusive on this shape.
279
407
  delete request.temperature;
280
408
  delete request.top_p;
281
409
  } else if (config.explicitNone) {
282
- // The one dialect where silence means the model default — which is ON
283
- // for a reasoning-mandatory model — so "none" has to be said out loud.
410
+ // Z.ai's coding endpoint: silence means the model default — which is
411
+ // ON for a reasoning-mandatory model — so "none" has to be said out
412
+ // loud. An effort nobody set reads as none here, as it always has.
284
413
  request.thinking = { type: "disabled" };
285
414
  }
286
415
 
@@ -147,6 +147,8 @@ export function toGeminiContents(messages: readonly ChatMessage[]): {
147
147
  break;
148
148
 
149
149
  case "user":
150
+ // An image and a file ride the same way, as bytes and their media
151
+ // type. This is the one wire that takes a file.
150
152
  contents.push({
151
153
  role: "user",
152
154
  parts:
@@ -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 { streamError } from "../errors.ts";
6
+ import { fileRefused, streamError } from "../errors.ts";
7
7
  import { streamSse, apiUrl } from "../transport.ts";
8
8
  import { attributionHeaders } from "../attribution.ts";
9
9
  import type {
@@ -152,12 +152,46 @@ export function openRouterHostFor(baseUrl: string, model: string): string | unde
152
152
  export type EffortDialect = "openai" | "openrouter" | "deepseek" | "off";
153
153
 
154
154
  /**
155
- * OpenRouter model ids that refuse every explicit reasoning off and, on most
156
- * of the hosts that serve them, think when `reasoning` is omitted. `none` is
157
- * said to them as `low`. Add an id only after measuring both halves on it:
158
- * what omitting the field does, and what `low` does.
155
+ * How `none` is said on OpenRouter, per model id: the `reasoning.effort` that
156
+ * turns the model's thinking off, or the lowest measured one where every off
157
+ * is refused.
158
+ *
159
+ * An id that is not listed sends nothing, and that default is the point. An
160
+ * explicit off is a 400 on a model whose reasoning is mandatory: GLM 5.3 Flash
161
+ * and gpt-5-mini both answer "Reasoning is mandatory for this endpoint and
162
+ * cannot be disabled." So a universal off would break every call to one. An
163
+ * omitted field only costs reasoning tokens, and only on a model that thinks by
164
+ * default. So an id gets a row once it is measured both ways: with the field
165
+ * omitted, and with the spelling in its row.
166
+ *
167
+ * An adopter's matrix, 2026-09-27: one pinned host per model
168
+ * (`allow_fallbacks: false`), two runs per cell, reasoning tokens with the
169
+ * field omitted and with each of three offs (top-level `reasoning_effort:
170
+ * "none"`, `reasoning.effort: "none"`, `reasoning.enabled: false`).
171
+ *
172
+ * - GLM 5.3 Flash @Together: 516, and 2,000 (cut off at `max_tokens`), with
173
+ * the field omitted. Every off was a 400. In the 2026-09-26 sweep (a
174
+ * production classify body sent to each of 28 hosts alone), 18 hosts thought
175
+ * with the field omitted (a 2026-09-14 reading, host unknown, saw no
176
+ * thinking), and `low` gave 0 reasoning tokens on every host that answered
177
+ * it but Sail Research (1) and Wafer (inverted: about 420 with `low`, 1
178
+ * omitted).
179
+ * - gpt-5-mini @OpenAI: 320 and 320 with the field omitted, and every off was
180
+ * a 400. A follow-up the same day, same host, two runs: `minimal` gave 0 and
181
+ * 0 (48 and 50 output tokens), `low` 64 and 64. So `minimal` is its floor.
182
+ * Only this exact id: gpt-5 and gpt-5-nano were not measured.
183
+ * - MiMo v2.6 Flash @Xiaomi: 57 and 47 with the field omitted (103–151
184
+ * @DeepInfra in the 2026-09-26 sweep), 0 with every off. Its row names
185
+ * `none`, so the table varies one field.
186
+ * - Not listed, on purpose. DeepSeek V4 Flash @DeepInfra: 0 in every cell, and
187
+ * the 2026-09-26 sweep saw it think at `low`. Gemini 2.5 Flash and Flash
188
+ * Lite @AI Studio: 0 in every cell.
159
189
  */
160
- const OPENROUTER_REASONING_MANDATORY: ReadonlySet<string> = new Set(["z-ai/glm-5.3-flash"]);
190
+ const OPENROUTER_NONE: ReadonlyMap<string, "low" | "minimal" | "none"> = new Map([
191
+ ["z-ai/glm-5.3-flash", "low"],
192
+ ["openai/gpt-5-mini", "minimal"],
193
+ ["xiaomi/mimo-v2.6-flash", "none"],
194
+ ]);
161
195
 
162
196
  /**
163
197
  * Effort → the request fields THIS endpoint accepts.
@@ -165,20 +199,14 @@ const OPENROUTER_REASONING_MANDATORY: ReadonlySet<string> = new Set(["z-ai/glm-5
165
199
  * One knob, three incompatible spellings, and the differences are not cosmetic:
166
200
  *
167
201
  * - **On OpenRouter, "do not think" has no spelling that works on every
168
- * model.** GLM 5.3 Flash answers
169
- * `400 "Reasoning is mandatory for this endpoint and cannot be disabled."`
170
- * to `reasoning.effort: "none"` and to `reasoning.enabled: false` alike, and
171
- * whether it thinks with the field omitted depends on the host. Sending a
172
- * production classify body to each host alone on 2026-09-26, 18 of 28 hosts
173
- * thought with the field omitted (a 2026-09-14 reading, host unknown, saw no
174
- * thinking), while `reasoning.effort: "low"` gave 0 reasoning tokens on
175
- * every host that answered it but Sail Research (1) and Wafer (inverted:
176
- * about 420 with `low`, 1 omitted). So for the ids in
177
- * `OPENROUTER_REASONING_MANDATORY`, `none` sends `low`. DeepSeek V4 Flash
178
- * measured the reverse the same day (0 omitted, thinking at `low`), so every
179
- * other model keeps the omitted field. Naming a level raises it — the
180
- * accepted values are `low`, `high` and `max` (`medium` and `xhigh` are
181
- * accepted too, but our vocabulary has no use for them).
202
+ * model.** GLM 5.3 Flash and gpt-5-mini answer every explicit off with
203
+ * `400 "Reasoning is mandatory for this endpoint and cannot be disabled."`,
204
+ * MiMo v2.6 Flash takes one, and DeepSeek V4 Flash thinks at `low` and not
205
+ * with the field omitted. So `none` is said per model, from
206
+ * `OPENROUTER_NONE`, which also says why an unlisted model gets nothing.
207
+ * Naming a level raises it — the accepted values are `low`, `high` and
208
+ * `max` (`medium` and `xhigh` are accepted too, but our vocabulary has no
209
+ * use for them).
182
210
  * - **DeepSeek V4 defaults thinking ON** on its own API, so `none` has to be an
183
211
  * explicit refusal there. The same word is a different request on each
184
212
  * dialect, and on OpenRouter on each model: the spelling belongs to the
@@ -194,8 +222,8 @@ const OPENROUTER_REASONING_MANDATORY: ReadonlySet<string> = new Set(["z-ai/glm-5
194
222
  * An absent effort sends nothing on every dialect and for every model: the
195
223
  * seam's rule is that a knob the caller never touched is a knob the provider
196
224
  * still owns. On OpenRouter that makes `none` and "never asked" the same
197
- * request for every model outside `OPENROUTER_REASONING_MANDATORY`, and a
198
- * different one for the models in it.
225
+ * request for every model outside `OPENROUTER_NONE`, and a different one for
226
+ * the models in it.
199
227
  *
200
228
  * `model` is the id the request names. Without it, `none` on OpenRouter sends
201
229
  * nothing, as it always has.
@@ -219,20 +247,21 @@ export function effortParams(
219
247
  ? { thinking: { type: "enabled" } }
220
248
  : { thinking: { type: "enabled" }, reasoning_effort: level };
221
249
  case "openrouter":
222
- // `none` is said per model (see above): the cheapest named level for a
223
- // model that thinks when the field is omitted and refuses every explicit
224
- // off, nothing for the rest. Naming a level RAISES it: the accepted
225
- // values are low, high and max (`medium` and `xhigh` exist on the wire
226
- // too). The refusal that once looked like "OpenRouter cannot be told not
227
- // to think" was `reasoning.enabled: false` — never sent (pinned by the
228
- // test below).
250
+ // `none` is said per model (see OPENROUTER_NONE): an explicit off where
251
+ // one was measured to work, the lowest measured level where every off is
252
+ // a 400, and nothing for a model nobody measured. Naming a level RAISES
253
+ // it: the accepted values are low, high and max (`medium`, `xhigh` and
254
+ // `minimal` exist on the wire too; `minimal` rides only as a row's none).
255
+ // The refusal that once looked like "OpenRouter cannot be told not to
256
+ // think" was `reasoning.enabled: false` — never sent (pinned by the test
257
+ // below).
229
258
  //
230
259
  // `max` rides verbatim here — the enum clamp below is DeepSeek/OpenAI's
231
260
  // shape (DeepSeek auto-bumps past its own top, OpenAI's top IS high);
232
261
  // OpenRouter takes `max` as named (measured live 2026-09-14).
233
262
  if (effort !== "none") return { reasoning: { effort } };
234
- return model !== undefined && OPENROUTER_REASONING_MANDATORY.has(model)
235
- ? { reasoning: { effort: "low" } }
263
+ return model !== undefined && OPENROUTER_NONE.has(model)
264
+ ? { reasoning: { effort: OPENROUTER_NONE.get(model) } }
236
265
  : {};
237
266
  case "openai":
238
267
  return { reasoning_effort: level ?? "none" };
@@ -265,13 +294,13 @@ function mapFinishReason(reason: string | null | undefined): FinishReason | unde
265
294
  }
266
295
  }
267
296
 
268
- function partsToOpenAI(content: string | ContentPart[]): unknown {
297
+ function partsToOpenAI(content: string | ContentPart[], provider: string): unknown {
269
298
  if (typeof content === "string") return content;
270
- return content.map((part) =>
271
- part.type === "text"
272
- ? { type: "text", text: part.text }
273
- : { type: "image_url", image_url: { url: toDataUri(part) } },
274
- );
299
+ return content.map((part) => {
300
+ if (part.type === "text") return { type: "text", text: part.text };
301
+ if (part.type === "file") throw fileRefused(provider, "OpenAI", part);
302
+ return { type: "image_url", image_url: { url: toDataUri(part) } };
303
+ });
275
304
  }
276
305
 
277
306
  /**
@@ -280,7 +309,7 @@ function partsToOpenAI(content: string | ContentPart[]): unknown {
280
309
  * made a tool call. A caller running a turn with thinking OFF must strip it
281
310
  * first (`stripReasoning`); the two cannot be mixed.
282
311
  */
283
- export function toOpenAIMessages(messages: readonly ChatMessage[]): unknown[] {
312
+ export function toOpenAIMessages(messages: readonly ChatMessage[], provider = "openai"): unknown[] {
284
313
  const out: unknown[] = [];
285
314
  for (const message of messages) {
286
315
  switch (message.role) {
@@ -289,7 +318,7 @@ export function toOpenAIMessages(messages: readonly ChatMessage[]): unknown[] {
289
318
  break;
290
319
 
291
320
  case "user":
292
- out.push({ role: "user", content: partsToOpenAI(message.content) });
321
+ out.push({ role: "user", content: partsToOpenAI(message.content, provider) });
293
322
  break;
294
323
 
295
324
  case "tool":
@@ -395,8 +424,14 @@ function price(value: unknown): number | undefined {
395
424
  * `cost_details.upstream_inference_cost`, so the call cost the two together.
396
425
  * Without that second figure there is no whole bill to report, and the
397
426
  * caller's rate prices the call instead.
427
+ *
428
+ * Exported for the other OpenRouter wire that bills the same way: Jev's.
398
429
  */
399
- function openRouterCostUsd(usage: NonNullable<OpenAIChunk["usage"]>): number | undefined {
430
+ export function openRouterCostUsd(usage: {
431
+ cost?: unknown;
432
+ is_byok?: boolean;
433
+ cost_details?: { upstream_inference_cost?: unknown } | null;
434
+ }): number | undefined {
400
435
  const cost = price(usage.cost);
401
436
  if (cost === undefined || usage.is_byok !== true) return cost;
402
437
  const upstream = price(usage.cost_details?.upstream_inference_cost);
@@ -423,7 +458,7 @@ export function createOpenAIProvider(config: OpenAIConfig): Provider {
423
458
  const effort = opts.effort ?? config.effort;
424
459
  const model = opts.model ?? config.model;
425
460
 
426
- const body = toOpenAIMessages(messages);
461
+ const body = toOpenAIMessages(messages, id);
427
462
  const request: Record<string, unknown> = {
428
463
  model,
429
464
  messages: body,
@@ -10,7 +10,7 @@
10
10
  // The event names arrive on the SSE `event:` line and are repeated inside each
11
11
  // payload's own `type`. The transport yields only `data:` payloads, so this
12
12
  // adapter reads `type` — which is what survives, and what gateways agree on.
13
- import { streamError } from "../errors.ts";
13
+ import { fileRefused, streamError } from "../errors.ts";
14
14
  import { streamSse, apiUrl } from "../transport.ts";
15
15
  import type {
16
16
  ChatMessage,
@@ -76,13 +76,16 @@ type ResponsesInputItem =
76
76
  | { type: "function_call"; call_id: string; name: string; arguments: string }
77
77
  | { type: "function_call_output"; call_id: string; output: string | ResponsesContentPart[] };
78
78
 
79
- function partsToResponses(content: string | ContentPart[]): ResponsesContentPart[] {
79
+ function partsToResponses(
80
+ content: string | ContentPart[],
81
+ provider: string,
82
+ ): ResponsesContentPart[] {
80
83
  if (typeof content === "string") return [{ type: "input_text", text: content }];
81
- return content.map((part): ResponsesContentPart =>
82
- part.type === "text"
83
- ? { type: "input_text", text: part.text }
84
- : { type: "input_image", image_url: toDataUri(part) },
85
- );
84
+ return content.map((part): ResponsesContentPart => {
85
+ if (part.type === "text") return { type: "input_text", text: part.text };
86
+ if (part.type === "file") throw fileRefused(provider, "Responses", part);
87
+ return { type: "input_image", image_url: toDataUri(part) };
88
+ });
86
89
  }
87
90
 
88
91
  /** A tool result is a bare string unless it carried images — then the content-
@@ -106,7 +109,10 @@ function toolOutput(
106
109
  * become several items (its text, then one `function_call` per tool it asked
107
110
  * for), which is why a message maps to a list rather than to one item.
108
111
  */
109
- export function toResponsesInput(messages: readonly ChatMessage[]): {
112
+ export function toResponsesInput(
113
+ messages: readonly ChatMessage[],
114
+ provider = "openai-responses",
115
+ ): {
110
116
  instructions?: string;
111
117
  input: unknown[];
112
118
  } {
@@ -122,7 +128,11 @@ export function toResponsesInput(messages: readonly ChatMessage[]): {
122
128
  break; // lifted into `instructions` above
123
129
 
124
130
  case "user":
125
- input.push({ type: "message", role: "user", content: partsToResponses(message.content) });
131
+ input.push({
132
+ type: "message",
133
+ role: "user",
134
+ content: partsToResponses(message.content, provider),
135
+ });
126
136
  break;
127
137
 
128
138
  case "tool":
@@ -237,7 +247,7 @@ export function createResponsesProvider(config: ResponsesConfig): Provider {
237
247
  opts: StreamOptions = {},
238
248
  ): AsyncIterable<ProviderChunk> {
239
249
  const effort = opts.effort ?? config.effort;
240
- const { instructions, input } = toResponsesInput(messages);
250
+ const { instructions, input } = toResponsesInput(messages, id);
241
251
 
242
252
  const request: Record<string, unknown> = {
243
253
  model: opts.model ?? config.model,
package/src/types.ts CHANGED
@@ -10,7 +10,10 @@
10
10
  /**
11
11
  * How hard the model thinks before answering. Absent = the provider's own
12
12
  * default (never sent). Passed through verbatim on OpenAI-shape
13
- * (`reasoning_effort`); mapped to thinking budgets on Anthropic-shape.
13
+ * (`reasoning_effort`). On Anthropic-shape, mapped to adaptive thinking and
14
+ * `output_config.effort` for current Claude models, where `none` is the lowest
15
+ * effort on a model that cannot stop thinking; and to thinking budgets for
16
+ * Claude 4.5 and older and for every other vendor on that wire.
14
17
  * Support varies per model — an unsupported level comes back as a clean 400.
15
18
  *
16
19
  * Ordered least → most; the type derives from the array so the runtime guard
@@ -39,7 +42,31 @@ export interface ImagePart {
39
42
  data: string;
40
43
  }
41
44
 
42
- export type ContentPart = TextPart | ImagePart;
45
+ /** A file's media type: anything but an image's, because an image is an
46
+ * `ImagePart`, which every adapter can send. */
47
+ export type FileMimeType =
48
+ `application/${string}` | `audio/${string}` | `video/${string}` | `text/${string}`;
49
+
50
+ /**
51
+ * A file the model reads — a PDF, a recording, a video — as base64 bytes,
52
+ * never a URL. Only the Gemini adapter sends one, as `inlineData`, the way it
53
+ * sends an image. Every other adapter refuses it with an `invalid`
54
+ * ProviderError before any request goes out, rather than drop it: a dropped
55
+ * attachment is a model answering about a file it never saw.
56
+ *
57
+ * Gemini takes inline data up to 100 MB per request, 50 MB for a PDF (Gemini
58
+ * API "File input methods", read 2026-09-27). The request carries the base64
59
+ * text, which is a third larger than the file. Past that limit the documented
60
+ * route is Gemini's File API, which this package does not wrap. Nothing here
61
+ * enforces the limit; Gemini answers an oversized request itself.
62
+ */
63
+ export interface FilePart {
64
+ type: "file";
65
+ mimeType: FileMimeType;
66
+ data: string;
67
+ }
68
+
69
+ export type ContentPart = TextPart | ImagePart | FilePart;
43
70
 
44
71
  /**
45
72
  * A tool the model asked to run. `arguments` is the RAW JSON string, not a
@@ -182,6 +209,83 @@ export interface JsonOutput {
182
209
  strict?: boolean;
183
210
  }
184
211
 
212
+ /**
213
+ * Read the JSON out of a model's answer. Models wrap it: a ```json fence, an
214
+ * unmarked fence, a sentence before it, notes after it. This takes the first
215
+ * fenced block marked `json` or not marked at all. With no such block, it
216
+ * takes the text from the first `{` or `[` to the bracket that closes it, and
217
+ * with no bracket either, the whole answer (a bare number, say). Then it
218
+ * parses that.
219
+ *
220
+ * It returns `unknown` on purpose: parsed is not validated, and a model's JSON
221
+ * has the schema's shape only once the caller has checked it.
222
+ *
223
+ * @throws {JsonAnswerError} when that text is not JSON. The error carries the
224
+ * answer, so a log shows what the model actually said.
225
+ */
226
+ export function parseJsonAnswer(text: string): unknown {
227
+ const candidate = fencedJson(text) ?? bracketed(text);
228
+ try {
229
+ return JSON.parse(candidate);
230
+ } catch (error) {
231
+ throw new JsonAnswerError(text, error);
232
+ }
233
+ }
234
+
235
+ /** An answer `parseJsonAnswer` found no JSON in. `text` is the answer, cut to
236
+ * 2,000 characters like a provider's error body; `cause` is the SyntaxError. */
237
+ export class JsonAnswerError extends Error {
238
+ readonly text: string;
239
+
240
+ constructor(text: string, cause: unknown) {
241
+ const reason = cause instanceof Error ? cause.message : String(cause);
242
+ super(`The model's answer is not JSON: ${reason}`, { cause });
243
+ this.name = "JsonAnswerError";
244
+ this.text = text.slice(0, 2_000);
245
+ }
246
+ }
247
+
248
+ /** A fenced block, whose fences count only at the start of a line. A JSON
249
+ * string can't hold a raw newline, so no line of JSON starts with a fence, and
250
+ * a value carrying one (a code sample) never ends the block early. `[^\S\n]`
251
+ * is a space, a tab, or the `\r` of a CRLF line. */
252
+ const FENCED_BLOCK = /^[^\S\n]*```[^\S\n]*([^\s`]*)[^\n]*\n([\s\S]*?)^[^\S\n]*```/gm;
253
+
254
+ function fencedJson(text: string): string | undefined {
255
+ for (const [, info = "", body = ""] of text.matchAll(FENCED_BLOCK)) {
256
+ const language = info.toLowerCase();
257
+ if (language === "" || language === "json") return body;
258
+ }
259
+ return undefined;
260
+ }
261
+
262
+ /** From the first `{` or `[` to the bracket that closes it, skipping brackets
263
+ * inside strings. An answer cut off before it closes keeps its tail, and
264
+ * JSON.parse says what is wrong with it.
265
+ *
266
+ * ponytail: the FIRST opener wins, so prose with a bracket before the JSON
267
+ * ("see [1]: {…}") picks the prose's. The upgrade is to try the next opener
268
+ * when one fails to parse, once a model that writes that turns up. */
269
+ function bracketed(text: string): string {
270
+ const start = text.search(/[[{]/);
271
+ if (start === -1) return text;
272
+ let depth = 0;
273
+ let inString = false;
274
+ for (let i = start; i < text.length; i++) {
275
+ const char = text[i];
276
+ if (inString) {
277
+ if (char === "\\") i++;
278
+ else if (char === '"') inString = false;
279
+ } else if (char === '"') inString = true;
280
+ else if (char === "{" || char === "[") depth++;
281
+ else if (char === "}" || char === "]") {
282
+ depth--;
283
+ if (depth === 0) return text.slice(start, i + 1);
284
+ }
285
+ }
286
+ return text.slice(start);
287
+ }
288
+
185
289
  /**
186
290
  * How the schema rides on a call that ALSO carries tools.
187
291
  *