@providerkit/core 0.12.1 → 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 CHANGED
@@ -9,7 +9,10 @@
9
9
  /**
10
10
  * How hard the model thinks before answering. Absent = the provider's own
11
11
  * default (never sent). Passed through verbatim on OpenAI-shape
12
- * (`reasoning_effort`); mapped to thinking budgets on Anthropic-shape.
12
+ * (`reasoning_effort`). On Anthropic-shape, mapped to adaptive thinking and
13
+ * `output_config.effort` for current Claude models, where `none` is the lowest
14
+ * effort on a model that cannot stop thinking; and to thinking budgets for
15
+ * Claude 4.5 and older and for every other vendor on that wire.
13
16
  * Support varies per model — an unsupported level comes back as a clean 400.
14
17
  *
15
18
  * Ordered least → most; the type derives from the array so the runtime guard
@@ -25,6 +28,86 @@ export const EMPTY_USAGE = {
25
28
  cachedInputTokens: 0,
26
29
  outputTokens: 0,
27
30
  };
31
+ /**
32
+ * Read the JSON out of a model's answer. Models wrap it: a ```json fence, an
33
+ * unmarked fence, a sentence before it, notes after it. This takes the first
34
+ * fenced block marked `json` or not marked at all. With no such block, it
35
+ * takes the text from the first `{` or `[` to the bracket that closes it, and
36
+ * with no bracket either, the whole answer (a bare number, say). Then it
37
+ * parses that.
38
+ *
39
+ * It returns `unknown` on purpose: parsed is not validated, and a model's JSON
40
+ * has the schema's shape only once the caller has checked it.
41
+ *
42
+ * @throws {JsonAnswerError} when that text is not JSON. The error carries the
43
+ * answer, so a log shows what the model actually said.
44
+ */
45
+ export function parseJsonAnswer(text) {
46
+ const candidate = fencedJson(text) ?? bracketed(text);
47
+ try {
48
+ return JSON.parse(candidate);
49
+ }
50
+ catch (error) {
51
+ throw new JsonAnswerError(text, error);
52
+ }
53
+ }
54
+ /** An answer `parseJsonAnswer` found no JSON in. `text` is the answer, cut to
55
+ * 2,000 characters like a provider's error body; `cause` is the SyntaxError. */
56
+ export class JsonAnswerError extends Error {
57
+ text;
58
+ constructor(text, cause) {
59
+ const reason = cause instanceof Error ? cause.message : String(cause);
60
+ super(`The model's answer is not JSON: ${reason}`, { cause });
61
+ this.name = "JsonAnswerError";
62
+ this.text = text.slice(0, 2_000);
63
+ }
64
+ }
65
+ /** A fenced block, whose fences count only at the start of a line. A JSON
66
+ * string can't hold a raw newline, so no line of JSON starts with a fence, and
67
+ * a value carrying one (a code sample) never ends the block early. `[^\S\n]`
68
+ * is a space, a tab, or the `\r` of a CRLF line. */
69
+ const FENCED_BLOCK = /^[^\S\n]*```[^\S\n]*([^\s`]*)[^\n]*\n([\s\S]*?)^[^\S\n]*```/gm;
70
+ function fencedJson(text) {
71
+ for (const [, info = "", body = ""] of text.matchAll(FENCED_BLOCK)) {
72
+ const language = info.toLowerCase();
73
+ if (language === "" || language === "json")
74
+ return body;
75
+ }
76
+ return undefined;
77
+ }
78
+ /** From the first `{` or `[` to the bracket that closes it, skipping brackets
79
+ * inside strings. An answer cut off before it closes keeps its tail, and
80
+ * JSON.parse says what is wrong with it.
81
+ *
82
+ * ponytail: the FIRST opener wins, so prose with a bracket before the JSON
83
+ * ("see [1]: {…}") picks the prose's. The upgrade is to try the next opener
84
+ * when one fails to parse, once a model that writes that turns up. */
85
+ function bracketed(text) {
86
+ const start = text.search(/[[{]/);
87
+ if (start === -1)
88
+ return text;
89
+ let depth = 0;
90
+ let inString = false;
91
+ for (let i = start; i < text.length; i++) {
92
+ const char = text[i];
93
+ if (inString) {
94
+ if (char === "\\")
95
+ i++;
96
+ else if (char === '"')
97
+ inString = false;
98
+ }
99
+ else if (char === '"')
100
+ inString = true;
101
+ else if (char === "{" || char === "[")
102
+ depth++;
103
+ else if (char === "}" || char === "]") {
104
+ depth--;
105
+ if (depth === 0)
106
+ return text.slice(start, i + 1);
107
+ }
108
+ }
109
+ return text.slice(start);
110
+ }
28
111
  /** Drain a no-tools stream into a Completion. Usage chunks are cumulative on
29
112
  * some providers and final-only on others: the LAST one wins. */
30
113
  export async function drainStream(stream, model) {
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":"AAAA,wEAAwE;AACxE,2EAA2E;AAC3E,mEAAmE;AACnE,EAAE;AACF,8EAA8E;AAC9E,8EAA8E;AAC9E,6EAA6E;AAC7E,0BAA0B;AAE1B;;;;;;;;;;;GAWG;AACH,MAAM,CAAC,MAAM,OAAO,GAAG,CAAC,MAAM,EAAE,KAAK,EAAE,QAAQ,EAAE,MAAM,EAAE,KAAK,CAAU,CAAC;AAGzE,2EAA2E;AAC3E,MAAM,UAAU,QAAQ,CAAC,KAAa;IACpC,OAAO,OAAO,CAAC,IAAI,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,KAAK,KAAK,CAAC,CAAC;AACpD,CAAC;AA6ID,MAAM,CAAC,MAAM,WAAW,GAAe;IACrC,WAAW,EAAE,CAAC;IACd,iBAAiB,EAAE,CAAC;IACpB,YAAY,EAAE,CAAC;CAChB,CAAC;AAuCF;;;;;;;;;;;;;GAaG;AACH,MAAM,UAAU,eAAe,CAAC,IAAY;IAC1C,MAAM,SAAS,GAAG,UAAU,CAAC,IAAI,CAAC,IAAI,SAAS,CAAC,IAAI,CAAC,CAAC;IACtD,IAAI,CAAC;QACH,OAAO,IAAI,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC;IAC/B,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,MAAM,IAAI,eAAe,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC;IACzC,CAAC;AACH,CAAC;AAED;iFACiF;AACjF,MAAM,OAAO,eAAgB,SAAQ,KAAK;IAC/B,IAAI,CAAS;IAEtB,YAAY,IAAY,EAAE,KAAc;QACtC,MAAM,MAAM,GAAG,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;QACtE,KAAK,CAAC,mCAAmC,MAAM,EAAE,EAAE,EAAE,KAAK,EAAE,CAAC,CAAC;QAC9D,IAAI,CAAC,IAAI,GAAG,iBAAiB,CAAC;QAC9B,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,KAAK,CAAC,CAAC;IACnC,CAAC;CACF;AAED;;;qDAGqD;AACrD,MAAM,YAAY,GAAG,+DAA+D,CAAC;AAErF,SAAS,UAAU,CAAC,IAAY;IAC9B,KAAK,MAAM,CAAC,EAAE,IAAI,GAAG,EAAE,EAAE,IAAI,GAAG,EAAE,CAAC,IAAI,IAAI,CAAC,QAAQ,CAAC,YAAY,CAAC,EAAE,CAAC;QACnE,MAAM,QAAQ,GAAG,IAAI,CAAC,WAAW,EAAE,CAAC;QACpC,IAAI,QAAQ,KAAK,EAAE,IAAI,QAAQ,KAAK,MAAM;YAAE,OAAO,IAAI,CAAC;IAC1D,CAAC;IACD,OAAO,SAAS,CAAC;AACnB,CAAC;AAED;;;;;;uEAMuE;AACvE,SAAS,SAAS,CAAC,IAAY;IAC7B,MAAM,KAAK,GAAG,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;IAClC,IAAI,KAAK,KAAK,CAAC,CAAC;QAAE,OAAO,IAAI,CAAC;IAC9B,IAAI,KAAK,GAAG,CAAC,CAAC;IACd,IAAI,QAAQ,GAAG,KAAK,CAAC;IACrB,KAAK,IAAI,CAAC,GAAG,KAAK,EAAE,CAAC,GAAG,IAAI,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;QACzC,MAAM,IAAI,GAAG,IAAI,CAAC,CAAC,CAAC,CAAC;QACrB,IAAI,QAAQ,EAAE,CAAC;YACb,IAAI,IAAI,KAAK,IAAI;gBAAE,CAAC,EAAE,CAAC;iBAClB,IAAI,IAAI,KAAK,GAAG;gBAAE,QAAQ,GAAG,KAAK,CAAC;QAC1C,CAAC;aAAM,IAAI,IAAI,KAAK,GAAG;YAAE,QAAQ,GAAG,IAAI,CAAC;aACpC,IAAI,IAAI,KAAK,GAAG,IAAI,IAAI,KAAK,GAAG;YAAE,KAAK,EAAE,CAAC;aAC1C,IAAI,IAAI,KAAK,GAAG,IAAI,IAAI,KAAK,GAAG,EAAE,CAAC;YACtC,KAAK,EAAE,CAAC;YACR,IAAI,KAAK,KAAK,CAAC;gBAAE,OAAO,IAAI,CAAC,KAAK,CAAC,KAAK,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC;QACnD,CAAC;IACH,CAAC;IACD,OAAO,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;AAC3B,CAAC;AAwED;kEACkE;AAClE,MAAM,CAAC,KAAK,UAAU,WAAW,CAC/B,MAAoC,EACpC,KAAa;IAEb,IAAI,IAAI,GAAG,EAAE,CAAC;IACd,IAAI,SAAS,GAAG,EAAE,CAAC;IACnB,4EAA4E;IAC5E,4EAA4E;IAC5E,yEAAyE;IACzE,6DAA6D;IAC7D,IAAI,gBAAuC,CAAC;IAC5C,IAAI,KAAK,GAAe,WAAW,CAAC;IACpC,IAAI,YAAY,GAAwB,IAAI,CAAC;IAC7C,IAAI,MAA+B,CAAC;IACpC,IAAI,KAAK,EAAE,MAAM,KAAK,IAAI,MAAM,EAAE,CAAC;QACjC,IAAI,KAAK,CAAC,MAAM;YAAE,MAAM,GAAG,KAAK,CAAC,MAAM,CAAC;QACxC,IAAI,KAAK,CAAC,IAAI,KAAK,OAAO,EAAE,CAAC;YAC3B,IAAI,KAAK,CAAC,OAAO;gBAAE,IAAI,IAAI,KAAK,CAAC,OAAO,CAAC;YACzC,IAAI,KAAK,CAAC,SAAS;gBAAE,SAAS,IAAI,KAAK,CAAC,SAAS,CAAC;YAClD,IAAI,KAAK,CAAC,gBAAgB,EAAE,MAAM;gBAAE,gBAAgB,GAAG,KAAK,CAAC,gBAAgB,CAAC;QAChF,CAAC;aAAM,IAAI,KAAK,CAAC,IAAI,KAAK,OAAO,IAAI,KAAK,CAAC,KAAK,EAAE,CAAC;YACjD,KAAK,GAAG,KAAK,CAAC,KAAK,CAAC;QACtB,CAAC;aAAM,IAAI,KAAK,CAAC,IAAI,KAAK,QAAQ,IAAI,KAAK,CAAC,YAAY,EAAE,CAAC;YACzD,YAAY,GAAG,KAAK,CAAC,YAAY,CAAC;QACpC,CAAC;IACH,CAAC;IACD,OAAO;QACL,IAAI;QACJ,SAAS;QACT,GAAG,CAAC,gBAAgB,CAAC,CAAC,CAAC,EAAE,gBAAgB,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QACjD,KAAK;QACL,YAAY;QACZ,KAAK,EAAE,MAAM,EAAE,KAAK,IAAI,KAAK;QAC7B,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,QAAQ,EAAE,MAAM,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;KACjD,CAAC;AACJ,CAAC;AAED;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,cAAc,CAAC,QAAgC;IAC7D,OAAO,QAAQ,CAAC,GAAG,CAAC,CAAC,OAAO,EAAE,EAAE;QAC9B,IAAI,OAAO,CAAC,IAAI,KAAK,WAAW;YAAE,OAAO,OAAO,CAAC;QACjD,IAAI,OAAO,CAAC,SAAS,KAAK,SAAS,IAAI,OAAO,CAAC,gBAAgB,KAAK,SAAS;YAAE,OAAO,OAAO,CAAC;QAC9F,yEAAyE;QACzE,yEAAyE;QACzE,iEAAiE;QACjE,MAAM,EAAE,SAAS,EAAE,KAAK,EAAE,gBAAgB,EAAE,QAAQ,EAAE,GAAG,IAAI,EAAE,GAAG,OAAO,CAAC;QAC1E,OAAO,IAAI,CAAC;IACd,CAAC,CAAC,CAAC;AACL,CAAC;AAED,4EAA4E;AAC5E,MAAM,UAAU,SAAS,CAAC,IAAe;IACvC,OAAO,QAAQ,IAAI,CAAC,QAAQ,WAAW,IAAI,CAAC,IAAI,EAAE,CAAC;AACrD,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@providerkit/core",
3
- "version": "0.12.1",
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 {
@@ -151,25 +151,66 @@ export function openRouterHostFor(baseUrl: string, model: string): string | unde
151
151
  * adapter. `off` sends nothing and leaves the model on its own default. */
152
152
  export type EffortDialect = "openai" | "openrouter" | "deepseek" | "off";
153
153
 
154
+ /**
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.
189
+ */
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
+ ]);
195
+
154
196
  /**
155
197
  * Effort → the request fields THIS endpoint accepts.
156
198
  *
157
199
  * One knob, three incompatible spellings, and the differences are not cosmetic:
158
200
  *
159
- * - **On OpenRouter, GLM's off switch is the ABSENT field.** Not setting
160
- * `reasoning` at all disables thinking for GLM 5.3 Flash (measured live
161
- * 2026-09-14). Naming a level raises it — the accepted values are
162
- * `low`, `high` and `max` (`medium` and `xhigh` are accepted too, but our
163
- * vocabulary has no use for them). `none` is NOT sendable there: the model
164
- * answers
165
- * `400 "Reasoning is mandatory for this endpoint and cannot be disabled."`
166
- * to `reasoning.effort: "none"` exactly as it does to
167
- * `reasoning.enabled: false` — so "do not think" on this dialect is said by
168
- * omitting the field, never by naming a level. See `effortParams`, where the
169
- * numbers live.
170
- * - **DeepSeek V4 defaults thinking ON**, so `none` has to be an explicit
171
- * refusal. That is the case proving OpenRouter's absent-field off switch is
172
- * a real dialect fact and not a preference for always thinking.
201
+ * - **On OpenRouter, "do not think" has no spelling that works on every
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).
210
+ * - **DeepSeek V4 defaults thinking ON** on its own API, so `none` has to be an
211
+ * explicit refusal there. The same word is a different request on each
212
+ * dialect, and on OpenRouter on each model: the spelling belongs to the
213
+ * endpoint, never to the caller.
173
214
  * - **OpenAI** takes `reasoning_effort`, and `none` is one of its values — not
174
215
  * the absence of the field. GPT-5.1 both accepted `none` and made it the
175
216
  * default; everything from GPT-5 back still defaults to `medium`. So sending
@@ -178,15 +219,19 @@ export type EffortDialect = "openai" | "openrouter" | "deepseek" | "off";
178
219
  * answer. A capped turn then spends its whole allowance thinking and returns
179
220
  * empty with `finish_reason: "length"`.
180
221
  *
181
- * An absent effort sends nothing on every dialect: the seam's rule is that a
182
- * knob the caller never touched is a knob the provider still owns. On
183
- * OpenRouter that makes `none` and "never asked" the same request — on this
184
- * dialect, not thinking IS the default state, so there is no third spelling
185
- * to invent.
222
+ * An absent effort sends nothing on every dialect and for every model: the
223
+ * seam's rule is that a knob the caller never touched is a knob the provider
224
+ * still owns. On OpenRouter that makes `none` and "never asked" the same
225
+ * request for every model outside `OPENROUTER_NONE`, and a different one for
226
+ * the models in it.
227
+ *
228
+ * `model` is the id the request names. Without it, `none` on OpenRouter sends
229
+ * nothing, as it always has.
186
230
  */
187
231
  export function effortParams(
188
232
  dialect: EffortDialect,
189
233
  effort: Effort | undefined,
234
+ model?: string,
190
235
  ): Record<string, unknown> {
191
236
  if (!effort) return {};
192
237
  const level = effort === "max" ? "high" : effort === "none" ? null : effort;
@@ -202,19 +247,22 @@ export function effortParams(
202
247
  ? { thinking: { type: "enabled" } }
203
248
  : { thinking: { type: "enabled" }, reasoning_effort: level };
204
249
  case "openrouter":
205
- // GLM's off switch here is the ABSENT field (measured live 2026-09-14:
206
- // omit `reasoning` → thinking disabled for glm-5.3-flash), so `none`
207
- // sends nothing — the same request as a caller who never asked, which on
208
- // this dialect is the same state. Naming a level RAISES it: the accepted
209
- // values are low, high and max (`medium` and `xhigh` exist on the wire
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).
210
255
  // The refusal that once looked like "OpenRouter cannot be told not to
211
- // think" was `reasoning.enabled: false` — a different field, never sent
212
- // (pinned by the test below).
256
+ // think" was `reasoning.enabled: false` — never sent (pinned by the test
257
+ // below).
213
258
  //
214
259
  // `max` rides verbatim here — the enum clamp below is DeepSeek/OpenAI's
215
260
  // shape (DeepSeek auto-bumps past its own top, OpenAI's top IS high);
216
261
  // OpenRouter takes `max` as named (measured live 2026-09-14).
217
- return effort === "none" ? {} : { reasoning: { effort } };
262
+ if (effort !== "none") return { reasoning: { effort } };
263
+ return model !== undefined && OPENROUTER_NONE.has(model)
264
+ ? { reasoning: { effort: OPENROUTER_NONE.get(model) } }
265
+ : {};
218
266
  case "openai":
219
267
  return { reasoning_effort: level ?? "none" };
220
268
  case "off":
@@ -246,13 +294,13 @@ function mapFinishReason(reason: string | null | undefined): FinishReason | unde
246
294
  }
247
295
  }
248
296
 
249
- function partsToOpenAI(content: string | ContentPart[]): unknown {
297
+ function partsToOpenAI(content: string | ContentPart[], provider: string): unknown {
250
298
  if (typeof content === "string") return content;
251
- return content.map((part) =>
252
- part.type === "text"
253
- ? { type: "text", text: part.text }
254
- : { type: "image_url", image_url: { url: toDataUri(part) } },
255
- );
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
+ });
256
304
  }
257
305
 
258
306
  /**
@@ -261,7 +309,7 @@ function partsToOpenAI(content: string | ContentPart[]): unknown {
261
309
  * made a tool call. A caller running a turn with thinking OFF must strip it
262
310
  * first (`stripReasoning`); the two cannot be mixed.
263
311
  */
264
- export function toOpenAIMessages(messages: readonly ChatMessage[]): unknown[] {
312
+ export function toOpenAIMessages(messages: readonly ChatMessage[], provider = "openai"): unknown[] {
265
313
  const out: unknown[] = [];
266
314
  for (const message of messages) {
267
315
  switch (message.role) {
@@ -270,7 +318,7 @@ export function toOpenAIMessages(messages: readonly ChatMessage[]): unknown[] {
270
318
  break;
271
319
 
272
320
  case "user":
273
- out.push({ role: "user", content: partsToOpenAI(message.content) });
321
+ out.push({ role: "user", content: partsToOpenAI(message.content, provider) });
274
322
  break;
275
323
 
276
324
  case "tool":
@@ -404,7 +452,7 @@ export function createOpenAIProvider(config: OpenAIConfig): Provider {
404
452
  const effort = opts.effort ?? config.effort;
405
453
  const model = opts.model ?? config.model;
406
454
 
407
- const body = toOpenAIMessages(messages);
455
+ const body = toOpenAIMessages(messages, id);
408
456
  const request: Record<string, unknown> = {
409
457
  model,
410
458
  messages: body,
@@ -418,7 +466,7 @@ export function createOpenAIProvider(config: OpenAIConfig): Provider {
418
466
  if (opts.temperature !== undefined) request.temperature = opts.temperature;
419
467
  if (opts.topP !== undefined) request.top_p = opts.topP;
420
468
  if (opts.stopSequences?.length) request.stop = opts.stopSequences;
421
- Object.assign(request, effortParams(config.effortDialect ?? dialectFor(id), effort));
469
+ Object.assign(request, effortParams(config.effortDialect ?? dialectFor(id), effort, model));
422
470
  if (tools.length > 0) {
423
471
  request.tools = tools.map((tool) => ({
424
472
  type: "function",