@providerkit/core 0.12.2 → 0.12.3

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/dist/types.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@providerkit/core",
3
- "version": "0.12.2",
3
+ "version": "0.12.3",
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",
package/src/context.ts CHANGED
@@ -26,8 +26,8 @@ export function estimateTokens(text: string): number {
26
26
 
27
27
  function textOf(message: ChatMessage): string {
28
28
  if (message.role === "user" && typeof message.content !== "string") {
29
- // Only the words are countable; an image's cost is the provider's own
30
- // arithmetic and no character count approximates it.
29
+ // Only the words are countable; an image's or a file's cost is the
30
+ // provider's own arithmetic and no character count approximates it.
31
31
  return message.content.map((part) => (part.type === "text" ? part.text : "")).join("");
32
32
  }
33
33
  return typeof message.content === "string" ? message.content : "";
package/src/errors.ts CHANGED
@@ -11,6 +11,7 @@
11
11
  // empty balance, "check your key" for a plan that never included the API).
12
12
 
13
13
  import { parseUsageLimitBody, type RateLimitWindow } from "./rate-limit.ts";
14
+ import type { FilePart } from "./types.ts";
14
15
 
15
16
  /** What kind of failure this is, named by what actually fixes it. */
16
17
  export type ErrorKind =
@@ -691,3 +692,18 @@ export function describeProviderError(err: unknown): Record<string, unknown> {
691
692
  }
692
693
  return { error: messageOf(err), kind: classify(err) };
693
694
  }
695
+
696
+ /**
697
+ * A file part on a wire that cannot carry one. Refused, never dropped: a
698
+ * dropped attachment is a model answering about a file it never saw, on the
699
+ * happy path, where nothing retries and nothing logs. `adapter` names the
700
+ * wire, because `provider` is often a gateway's id rather than the shape's.
701
+ */
702
+ export function fileRefused(provider: string, adapter: string, part: FilePart): ProviderError {
703
+ return new ProviderError(
704
+ provider,
705
+ "invalid",
706
+ `${provider}: the ${adapter} adapter can't send a file (${part.mimeType}). Only the Gemini ` +
707
+ "adapter takes files. Send this request to a Gemini model, or leave the file out.",
708
+ );
709
+ }
package/src/presets.ts CHANGED
@@ -56,8 +56,9 @@ export interface ProviderPreset {
56
56
  * `thinking: { type: "disabled" }` marker when effort is none. For
57
57
  * endpoints where an ABSENT field means the model's default — thinking ON
58
58
  * for reasoning-mandatory models (measured on Z.ai's coding endpoint:
59
- * omit → thinking block; disabled → none). Native Anthropic defaults to
60
- * off when the field is absent and must stay unset. */
59
+ * omit → thinking block; disabled → none). Leave it unset for native
60
+ * Anthropic: the adapter spells none per Claude model, and ignores this
61
+ * flag for every Claude from 4.6 on. */
61
62
  explicitNone?: boolean;
62
63
  /** Output ceiling when the caller sets none. Thinking and the answer share
63
64
  * it, so a model that reasons past the adapter's default ends the turn with
@@ -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":
@@ -423,7 +452,7 @@ export function createOpenAIProvider(config: OpenAIConfig): Provider {
423
452
  const effort = opts.effort ?? config.effort;
424
453
  const model = opts.model ?? config.model;
425
454
 
426
- const body = toOpenAIMessages(messages);
455
+ const body = toOpenAIMessages(messages, id);
427
456
  const request: Record<string, unknown> = {
428
457
  model,
429
458
  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,