@ai-sdk/anthropic drops your temperature when the model id contains claude-
The Anthropic provider decides your max_tokens, whether temperature is sent at all, and the whole shape of a generateObject request, from a case-sensitive substring test on your model id. Reproduced first-party on ai 7.0.113 with a local capture server and no real API key.
On this page
Quick answer
As of September 2026, @ai-sdk/anthropic decides your request's max_tokens, whether your temperature is sent at all, and the entire shape of a generateObject request, by testing whether your model id string contains the lowercase substring claude-. The test is a plain String.prototype.includes call in an ordered if/else chain, so it is case sensitive and it matches anywhere in the string. A gateway deployment called claude-router-v2 gets its temperature silently removed from the outgoing request. The same deployment called router-v2 keeps it. So does Claude-Router, because of the capital C.
Measured first-party on ai 7.0.113, @ai-sdk/anthropic 4.0.62, zod 4.6.5, Node 24.8.0, with a local capture server and no real API key.
If you point the Anthropic provider at your own base URL, check what actually leaves your process before you trust your sampling settings.
What you set, and what leaves the process
Here is the whole finding in one table. Every row sets temperature: 0 and nothing else. The only thing that varies is the model id string.
model id max_tokens temperature warnings
------------------------------------------------------------------------------
claude-sonnet-4-5-20250929 64000 0 (none)
claude-opus-5-20260115 128000 DROPPED temperature
claude-3-5-haiku-20241022 4096 0 maxOutputTokens
claude-router-v2 128000 DROPPED maxOutputTokens,temperature
my-claude-x 128000 DROPPED maxOutputTokens,temperature
Claude-Router 4096 0 maxOutputTokens
myclaude 4096 0 maxOutputTokens
router-v2 4096 0 maxOutputTokens
Read the bottom five rows together. claude-router-v2 and my-claude-x lose their temperature. Claude-Router, myclaude and router-v2 keep it. Nothing about the underlying model changed. Only the spelling of the identifier did.
The max_tokens column moves by a factor of 31 across the same five rows, from 4,096 to 128,000.
Reproduce it in two files, with no API key
The provider needs a base URL and a non-empty apiKey string, and it never checks that the key is real, because your own server is answering. Point it at a local server that records the request body and returns a syntactically valid response, and you can read exactly what the SDK would have sent to Anthropic without ever holding a real key. Omit apiKey entirely and it throws Anthropic API key is missing, so pass any placeholder. I have used this instrument before in how to see the exact request your AI agent sends; here it is again on a different target.
Install the pinned versions:
npm init -y
npm install --save-exact ai@7.0.113 @ai-sdk/anthropic@4.0.62 zod@4.6.5
capture.js writes each request body to disk and answers with a minimal Anthropic-shaped message so the SDK does not throw:
const http = require('http');
const fs = require('fs');
const server = http.createServer(function (req, res) {
let body = '';
req.on('data', function (chunk) { body += chunk; });
req.on('end', function () {
fs.writeFileSync('captured.json', body);
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({
id: 'msg_probe',
type: 'message',
role: 'assistant',
model: 'probe',
content: [{ type: 'text', text: 'ok' }],
stop_reason: 'end_turn',
usage: { input_tokens: 1, output_tokens: 1 }
}));
});
});
server.listen(8787, function () {
console.log('capture server listening on 127.0.0.1:8787');
});
probe.js walks the list of model ids and prints the table above:
const { createAnthropic } = require('@ai-sdk/anthropic');
const { generateText } = require('ai');
const fs = require('fs');
globalThis.AI_SDK_LOG_WARNINGS = false;
const anthropic = createAnthropic({
apiKey: 'no-key-needed',
baseURL: 'http://127.0.0.1:8787/v1'
});
const modelIds = [
'claude-sonnet-4-5-20250929',
'claude-opus-5-20260115',
'claude-3-5-haiku-20241022',
'claude-router-v2',
'my-claude-x',
'Claude-Router',
'myclaude',
'router-v2'
];
async function probe(modelId) {
let warnings = [];
try {
const result = await generateText({
model: anthropic(modelId),
prompt: 'hi',
temperature: 0
});
warnings = (result.warnings || []).map(function (w) { return w.feature || w.type; });
} catch (err) {
warnings = ['request-failed'];
}
const sent = JSON.parse(fs.readFileSync('captured.json', 'utf8'));
return {
modelId: modelId,
maxTokens: sent.max_tokens,
temperature: sent.temperature === undefined ? 'DROPPED' : sent.temperature,
warnings: warnings.join(',') || '(none)'
};
}
async function main() {
console.log('model id'.padEnd(28) + 'max_tokens'.padStart(11) + ' temperature warnings');
console.log('-'.repeat(78));
for (const modelId of modelIds) {
const row = await probe(modelId);
console.log(
row.modelId.padEnd(28) +
String(row.maxTokens).padStart(11) +
' ' + String(row.temperature).padEnd(12) +
row.warnings
);
}
}
main();
Run the server in one terminal and the probe in another. The table is reproduced verbatim.
Where the rule lives
The behaviour is not spread across the SDK. It is one function in the installed provider, getModelCapabilities, and it is a single ordered if/else chain of substring tests on the model id. The chain has fourteen arms. Twelve match specific families. The last two are catch-alls, and those are the ones that matter for anyone using a custom base URL.
The second-to-last arm matches any id containing claude- and returns, among other flags:
maxOutputTokens: 128000
supportsStructuredOutput: true
rejectsSamplingParameters: true
isKnownModel: false
The final else returns:
maxOutputTokens: 4096
supportsStructuredOutput: false
rejectsSamplingParameters: false
isKnownModel: false
Both say isKnownModel: false. Both are guesses. But they are opposite guesses, and which one you land on is decided by a substring.
rejectsSamplingParameters: true is what removes temperature. The intent is clearly to future-proof: a Claude model this build has never heard of is assumed to be a newer generation, and the newest generation is handled elsewhere in the same chain with the same flag. That is a defensible assumption about a real Anthropic model id. It is not a defensible assumption about a routing key you chose yourself.
Three things the substring decides
1. How many output tokens you declare
Nothing in generateText asks for max_tokens, but it is always sent. For recognised models the provider uses that model's ceiling, so claude-sonnet-4-5 declares 64,000 and claude-opus-4-1 declares 32,000. For an unrecognised claude- id it declares 128,000; for anything else, 4,096.
The 4,096 case is the one that bites quietly, because it is a cap rather than a request. claude-3-5-haiku-20241022 is a real model id and the chain classifies it as unknown, so a long generation is capped at 4,096 output tokens with a warning that says the model is unknown.
2. Whether your sampling settings survive
Covered above. The warning fires, but it is a warning on the result object and a console line, not an error, and the request succeeds.
3. The entire shape of a generateObject request
This is the largest difference and the least obvious. Same schema, same prompt, two model ids:
const { createAnthropic } = require('@ai-sdk/anthropic');
const { generateObject } = require('ai');
const { z } = require('zod');
const fs = require('fs');
globalThis.AI_SDK_LOG_WARNINGS = false;
const anthropic = createAnthropic({
apiKey: 'no-key-needed',
baseURL: 'http://127.0.0.1:8787/v1'
});
async function probe(modelId) {
try {
await generateObject({
model: anthropic(modelId),
schema: z.object({ city: z.string(), population: z.number() }),
prompt: 'capital of France'
});
} catch (err) {
// the capture server returns plain text, so parsing fails.
// the request we care about has already been written to captured.json.
}
const sent = JSON.parse(fs.readFileSync('captured.json', 'utf8'));
console.log('model id : ' + modelId);
console.log(' top-level keys: ' + Object.keys(sent).sort().join(', '));
console.log(' max_tokens : ' + sent.max_tokens);
console.log(' tools : ' + (sent.tools ? sent.tools.map(function (t) { return t.name; }).join(',') : '(none)'));
console.log(' tool_choice : ' + JSON.stringify(sent.tool_choice));
console.log('');
}
async function main() {
await probe('claude-router-v2');
await probe('router-v2');
}
main();
Output:
model id : claude-router-v2
top-level keys: max_tokens, messages, model, output_config
max_tokens : 128000
tools : (none)
tool_choice : undefined
model id : router-v2
top-level keys: max_tokens, messages, model, tool_choice, tools
max_tokens : 4096
tools : json
tool_choice : {"type":"any","disable_parallel_tool_use":true}
These are not two variants of one request. They are two different mechanisms. One sends an output_config block and no tools. The other sends a synthetic tool named json and forces the model to call it.
If you sit behind a proxy that implements the classic Messages API surface and not the newer structured-output field, the first of those will be rejected and the second will work. Which one you get depends on whether your deployment name happens to contain claude-.
The two details that make this a trap rather than a quirk
It is case sensitive. claude-router-v2 loses its temperature. Claude-Router does not. Two names for the same endpoint, differing only in letter case, produce different requests. If your gateway normalises display names to title case in one place and lower case in another, you have a real inconsistency to find.
It matches anywhere, not just at the start. my-claude-x triggers it. Naming a deployment after the model family it fronts is the natural thing to do, and it is exactly what switches the behaviour.
What the documentation says, and what it does not
The provider page is thorough, and it explicitly invites the path that trips this.
The Anthropic provider page, verbatim: "The table above lists popular models. You can also pass any available provider model ID as a string if needed."
And on token limits, verbatim: "For known models, the combined value is capped at the model's maximum output token limit."
That second sentence is the only place the concept appears, and the phrase "known models" is never defined on the page. I searched the rendered page, 73,202 visible characters: unknown model occurs zero times, substring zero times, case-sensitive zero times. Nothing tells you that an unrecognised id changes how sampling parameters are handled, or that it changes the request shape for structured output.
You can read both pages yourself at the Anthropic provider reference and the generateText reference, and the chain itself in the vercel/ai repository.
Two things I expected to find and did not
I am recording these because both were plausible and both are wrong, and someone else is going to have the same two ideas.
The high default max_tokens is not a rate-limit problem. My first theory was that declaring 64,000 output tokens on every request would eat an Anthropic output-token-per-minute budget far faster than the actual generation warrants. Anthropic's own documentation says the opposite, verbatim: "OTPM rate limits are evaluated in real time as output tokens are produced, counting only the actual tokens generated. The max_tokens parameter does not factor into OTPM rate limit calculations, so there is no rate limit downside to setting a higher max_tokens value." You can check that on the Anthropic rate limits page. So the provider's choice of a generous default is fine on that axis, and the theory is dead.
Zod descriptions do reach the wire. There is a reported issue about describe() annotations being lost on the way to JSON Schema. On these versions they are not. A tool schema with a description on every field arrives with every description intact, plus enum, default, minimum, maximum and a correct required array. That one is fixed; do not spend an afternoon on it.
The fix, and the fix that does not work
const { createAnthropic } = require('@ai-sdk/anthropic');
const { generateText } = require('ai');
const fs = require('fs');
globalThis.AI_SDK_LOG_WARNINGS = false;
function report(label) {
const sent = JSON.parse(fs.readFileSync('captured.json', 'utf8'));
console.log(
label.padEnd(42) +
'max_tokens=' + String(sent.max_tokens).padEnd(9) +
'temperature=' + String(sent.temperature === undefined ? 'DROPPED' : sent.temperature)
);
}
const plain = createAnthropic({
apiKey: 'no-key-needed',
baseURL: 'http://127.0.0.1:8787/v1'
});
const repaired = createAnthropic({
apiKey: 'no-key-needed',
baseURL: 'http://127.0.0.1:8787/v1',
fetch: function (url, init) {
const body = JSON.parse(init.body);
if (body.temperature === undefined) {
body.temperature = 0;
}
return fetch(url, Object.assign({}, init, { body: JSON.stringify(body) }));
}
});
async function main() {
const id = 'claude-router-v2';
await generateText({ model: plain(id), prompt: 'hi', temperature: 0 });
report('1. nothing set');
await generateText({ model: plain(id), prompt: 'hi', temperature: 0, maxOutputTokens: 1024 });
report('2. maxOutputTokens set explicitly');
await generateText({ model: repaired(id), prompt: 'hi', temperature: 0 });
report('3. custom fetch re-injects temperature');
await generateText({ model: plain('router-v2'), prompt: 'hi', temperature: 0, maxOutputTokens: 1024 });
report('4. id renamed, no "claude-" substring');
}
main();
Output:
1. nothing set max_tokens=128000 temperature=DROPPED
2. maxOutputTokens set explicitly max_tokens=1024 temperature=DROPPED
3. custom fetch re-injects temperature max_tokens=128000 temperature=0
4. id renamed, no "claude-" substring max_tokens=1024 temperature=0
Line 2 is the trap. Setting maxOutputTokens is the obvious response to the warning, it fixes the token count, and it does nothing at all for the temperature. If you stop there you will believe you have handled it.
In order of preference:
- Use a real Anthropic model id wherever you can, and let your gateway map it. This puts you on a recognised arm of the chain and every flag is then a fact rather than a guess.
- If you must use a custom deployment name, keep the lowercase
claude-substring out of it.router-v2andsonnet-prodboth land on the finalelse, which leaves your sampling parameters alone. SetmaxOutputTokensexplicitly, because that arm caps you at 4,096. - If you cannot rename it, repair the body in a custom
fetch.createAnthropicaccepts one, and it runs after the provider has built the request, so it is the last point at which you can put a parameter back. Line 3 above is the whole implementation.
Option 3 is a workaround, not a fix. If you take it, leave a comment saying what it is compensating for, because a future reader will otherwise delete it.
The general shape
This is the second time I have found a framework inferring behaviour from a model name string rather than from a capability declaration. The other was @openai/agents adding reasoning.effort and text.verbosity by matching your model name. Both vendors are solving the same problem, which is that a new model ships and old SDK builds should do something sensible with it. Both solve it by pattern-matching an opaque string that the user is also allowed to choose.
The practical lesson is small and cheap. When you point an SDK at a base URL you control, name the model the way the SDK expects, and spend ten minutes reading the request before you trust any setting you passed. If you are building the loop yourself rather than through a provider, building the agent directly sidesteps this class entirely, at the cost of writing the request assembly yourself.
Written by
Ren OkabeRen builds agent infrastructure and writes copy-paste tutorials for engineers shipping LLM tool-use systems.
Frequently asked questions
Why is my temperature setting ignored when using @ai-sdk/anthropic with a custom baseURL?
Because the provider decided your model is a newer Claude than it knows about. getModelCapabilities in @ai-sdk/anthropic 4.0.62 is an ordered chain of substring tests on the model id. Any id containing the lowercase substring claude- that does not match a specific family lands on a catch-all arm with rejectsSamplingParameters set to true, and temperature, topP and topK are removed from the outgoing request. A warning appears on the result object and on the console, but the request succeeds. Measured on ai 7.0.113 and Node 24.8.0.
Is the model id check case sensitive?
Yes. The test is String.prototype.includes against the lowercase literal claude-, so it is case sensitive. Measured: a model id of claude-router-v2 has its temperature removed, while Claude-Router keeps it and is instead capped at 4096 output tokens. Two names for the same endpoint that differ only in letter case produce different HTTP requests.
Does the substring have to be at the start of the model id?
No. includes matches anywhere in the string. Measured: my-claude-x triggers the same behaviour as claude-router-v2, because claude- appears in the middle. A deployment named after the model family it fronts is enough to switch the behaviour.
Why is max_tokens set to 64000 when I never asked for it?
The Anthropic provider always sends max_tokens and defaults it to the recognised model's own ceiling. Measured: claude-sonnet-4-5 sends 64000, claude-opus-4-1 sends 32000, an unrecognised claude- id sends 128000, and anything else sends 4096. This is not a rate limit problem: Anthropic documents that the max_tokens parameter does not factor into output-token-per-minute rate limit calculations. Set maxOutputTokens explicitly if you want a specific value.
Does setting maxOutputTokens bring my temperature back?
No, and this is the trap. Setting maxOutputTokens is the obvious response to the warning and it fixes only the token count. Measured on the same gateway model id: with maxOutputTokens set to 1024 the request carries max_tokens 1024 and still no temperature field at all. You have to either rename the model id so it does not contain claude-, or re-inject the parameter in a custom fetch passed to createAnthropic.
Does the model id change how generateObject builds its request?
Yes, and it changes the mechanism rather than a parameter. Measured with the same Zod schema and prompt: the id claude-router-v2 produces a request whose top-level keys are max_tokens, messages, model and output_config, with no tools at all, while router-v2 produces a request carrying a synthetic tool named json plus tool_choice of type any with disable_parallel_tool_use set. A proxy that implements only one of those two surfaces will accept one id and reject the other.
Are Zod describe() annotations lost on the way to the model?
Not on these versions. This was a reported issue, so it is worth stating the negative result: on ai 7.0.113 with zod 4.6.5, a tool inputSchema with a description on every field arrives at the wire with every description intact, alongside enum, default, minimum, maximum and a correct required array. Verify it yourself with the capture server in this tutorial rather than assuming either way.
Related tutorials
How to see the exact request your AI agent sends, without an API key
Agent frameworks build the request for you, then show you a summary of it. Here is a 60-line local server that records the exact JSON body your SDK sends, with no API key, no proxy and no certificate, plus the CI assertion a proxy cannot give you.
@openai/agents adds reasoning.effort and text.verbosity by matching your model name
The SDK chooses reasoning.effort and text.verbosity from how your model name is spelled, not from what the model supports. Measured on the wire on 0.18.0, including what it does to a custom gateway deployment name.
Build an AI Agent with the Claude Agent SDK in TypeScript (2026)
A runnable 2026 quickstart: install the Claude Agent SDK, wire a custom tool with tool() and createSdkMcpServer(), and let the agent loop call it for you in about 40 lines of TypeScript.