Chapter 2 of 9 · 14 min read
Unsupported parameters
On this page
The parameters at issue here are mostly sampling controls, which govern how
the model picks each next token from the distribution it has computed:
temperature flattens or sharpens that distribution, top_p keeps the most likely
tokens whose probabilities add up to the given fraction, and top_k keeps the k
most likely candidates and discards the rest. Loosening any of them widens the
pool the model draws from, which is what a team tuning for varied output is
buying. They are the fields that accumulate on a call site over time, they are
the fields a shared internal wrapper passes through as keyword arguments, and
they are the fields whose support differs most between endpoints.
Two of those three do the same kind of work, and knowing which kind explains
what happens when one of them goes missing. top_p and top_k are both
truncations: before a token is drawn, they delete candidates from the list and
the remaining probabilities are rescaled to add up to one again. They differ
only in how they choose what to delete — one counts candidates, the other
accumulates probability until it has enough. Temperature does something else
entirely. It does not delete anything; it flattens or sharpens the whole
distribution before any truncation happens, so a low temperature concentrates
probability on the leaders and a high one spreads it toward the tail.
The consequence that matters for a wrapper is what happens when both truncations are set and one of them silently disappears. Setting both applies both, so the surviving pool is whatever satisfies each — the smaller of the two cuts wins at every step. Remove one and the other is left doing the whole job alone, which means the pool can only get the same size or larger, never smaller. That is a change in the system's behaviour on every single token, arriving without an error, a log line or a failed request, and it is the reason this chapter treats a dropped sampling control as an incident rather than as a lint warning.
Sorting differences into cosmetic and consequential piles, which is how a migration diff is best read, does not catch what this chapter is about, because the parameter names match on both sides. What differs is what each side does with the name.
Three things can happen to a field one provider has never heard of
Take a wrapper of the shape almost every team arrives at when they put two providers behind one function.
def generate(provider: str, prompt: str, **sampling):
if provider == "anthropic":
return anthropic_client.messages.create(
model="claude-sonnet-4-5",
max_tokens=256,
messages=[{"role": "user", "content": prompt}],
**sampling,
)
return openai_client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": prompt}],
**sampling,
)
generate("anthropic", brief, temperature=0.9, top_p=0.95, top_k=40)
generate("openai", brief, temperature=0.9, top_p=0.95, top_k=40)The first call succeeds and samples with all three controls, because top_k is a
documented parameter on the Messages API. The second call has three possible
fates depending on which client and which endpoint sit behind it, and they fail
in decreasingly visible ways.
The loudest fate is a 400 from the server naming the field. If the argument
reaches the wire — through extra_body, through a hand-built request, or through
a client that forwards unknown keys — an endpoint that validates its input
strictly rejects the whole request:
{
"error": {
"message": "Unrecognized request argument supplied: top_k",
"type": "invalid_request_error",
"param": null,
"code": null
}
}This is the good outcome. It happens on the first request, in staging, with the field named in the message, and it costs an afternoon at most.
The quietest fate is that the client never serialises the field at all. Typed
clients have explicit method signatures, so top_k=40 on a method that has no
such parameter raises a local TypeError before any HTTP call happens — also a
loud failure, and also fine. The dangerous variants are the layers people put on
top. Multi-provider libraries generally offer a mode that strips parameters the
target provider does not support, precisely so that one call site can serve many
backends; LiteLLM calls it drop_params, and enabling it converts every
unsupported argument from an exception into a silent omission. That is a sensible
default for a demo and a poor one for a system whose behaviour was tuned with
those arguments present.
The setting is not carelessness on the library's part, and seeing why makes it easier to reason about every layer of this kind. A multi-provider library's contract is that one call site works against many backends. When a field has no counterpart on the target, there are exactly two operations available: fail, or remove. There is no third option, because substituting a value would require knowing what the field means on a provider that does not implement it, and no such value exists. So the library offers you the choice between the two, and the choice it labels as the convenient one is removal.
Your contract is a different one. You do not need the two branches to work; you need them to behave the same, and a field silently removed from one of them breaks that while satisfying the library perfectly. Neither party is wrong. The mismatch is between "make this call succeed" and "make these two calls comparable", and the setting sits exactly on the seam between them.
The middle fate is acceptance without effect. Endpoints that advertise
compatibility with another vendor's schema are usually lenient about extra keys,
and a gateway in front of a self-hosted model may forward an unknown field to a
serving stack that ignores it. You get a 200, a plausible completion, and no
signal anywhere that one of your three sampling controls did nothing. The same
shape appears with fields that are accepted and only best-effort by design, such
as seed, where the contract is explicitly that identical inputs usually
produce identical outputs and no error is raised when they do not.
Leniency there is a design decision with a reason behind it, and the reason predicts where else you will meet it. A compatibility endpoint exists so that code written against somebody else's schema keeps working when pointed at this one. That code was written over years, against several versions, by people who had no obligation to stop sending a field the moment it was deprecated. An endpoint that rejected every key it did not implement would fail on a large share of exactly the traffic it was built to attract, so it parses what it implements and steps over the rest. Strictness and compatibility are in opposition here, and the surface has already chosen. Anywhere you see the word "compatible" on an endpoint, assume unknown fields are ignored rather than refused, and get your evidence from the serialised body rather than from the absence of an error.
| Fate | Where it is decided | What you see | Time to detect |
|---|---|---|---|
| Rejected with a 400 naming the field | Provider's validator | Failed request, field named | Minutes |
Local TypeError on an unknown kwarg | Typed client, before serialisation | Stack trace at the call site | Minutes |
| Dropped by a compatibility layer | Wrapper library, before serialisation | Nothing | Weeks, via output drift |
| Accepted and ignored | Lenient endpoint or serving stack | 200 with a plausible answer | Weeks, via output drift |
The two rows at the bottom share a property worth stating plainly: they produce a system where the same code samples differently depending on which branch it took, and where the difference shows up as a subjective quality complaint rather than as an incident. That is the hardest class of bug to route to the right team, because the first hypothesis anybody forms is that the second model is worse.
The "weeks" in that last column is not pessimism, and the steps that produce it are worth following once so the estimate stops feeling arbitrary. A silent drop raises nothing, so nothing pages anyone. The only detector left is a person judging output, and a person judging output is comparing against a memory rather than a baseline. Sampling changes shift a distribution rather than breaking a case, so the first several affected responses look like ordinary variation, and they are ordinary variation — the population they were drawn from is what changed. Enough of them have to accumulate for somebody to be confident it is not a run of bad luck, and only then does anyone go looking. Every step there is forced by the absence of a signal, which is why the fix in this chapter is always to manufacture one.
One thing that complaint will not tell you is which direction the sampling moved, and expecting it to is how these investigations lose their first week. The word that arrives is an adjective — bland, erratic, off-brand — chosen by somebody describing a feeling about a batch of text, and the same adjective is reached for by different people about outputs that are too similar to each other and about outputs that are individually odd. Treat it as evidence that the system changed and as evidence of nothing else. The diagnostic that does carry direction is the pair of serialised bodies, because a field present in one and absent from the other is a fact rather than an impression.
If you want the drop to be a decision rather than a default, make it explicit at
the call site. Typed clients give you a documented way to send fields their
signatures do not know about — extra_body on both major Python clients merges a
dictionary into the JSON body — so a wrapper can carry provider-specific sampling
deliberately and let the ones that do not apply be absent by construction rather
than by a library setting nobody remembers enabling.
SAMPLING = {
"anthropic": {"temperature": 0.9, "top_p": 0.95, "top_k": 40},
"openai": {"temperature": 0.9, "top_p": 0.95}, # no top_k on this endpoint
}Written that way, the difference between the two paths is visible in a code review, and the question "are these two branches sampling the same way?" has an answer you can read rather than one you have to trace.
Names that match do not mean semantics that match
The unsupported-parameter case is the obvious half. The subtler half is
parameters both providers support, with the same name, whose accepted range or
default differs. Temperature is the standard example: the Messages API accepts
values from 0.0 to 1.0, while Chat Completions accepts 0.0 to 2.0. A wrapper that
passes temperature=1.4 through gets a working request on one side and this on
the other:
{
"type": "error",
"error": {
"type": "invalid_request_error",
"message": "temperature: Input should be less than or equal to 1"
}
}That one is loud. The quiet version of the same problem is that a value inside both ranges does not mean the same thing on both sides, because the ranges are scaled differently — 0.7 sits at seventy percent of one provider's range and thirty-five percent of the other's — and there is no published conversion, because temperature is applied to logits whose distributions are model-specific in the first place. Any team that "kept temperature the same" across a migration has changed the sampling and should expect to retune.
Defaults are the other place where matching names diverge. max_tokens is
required on the Messages API and optional on Chat Completions, which means
removing it during a migration is not removing a constraint, it is substituting
the provider's implicit cap for your explicit one. Interactions differ too:
setting both temperature and top_p is discouraged on one provider and routine
on the other, and stop sequences are stop_sequences as a list on one side and
stop on the other, accepting either a string or a list.
The reference, and the record you keep from it
There is a strong temptation to answer "does this provider support top_k?" with a search, and the top results will be blog posts and forum answers written against an API version that has since changed. Parameter support on these endpoints moves on a timescale of weeks: fields are added behind beta headers, deprecated in favour of replacements, and restricted per model family, and none of that propagates to a tutorial written last spring. The provider's own API reference — Create a Message in Anthropic's documentation is the relevant page for the Messages API — is the only source that is versioned alongside the thing it describes, and it is where the per-parameter constraints actually live: the type, whether it is required, the accepted range, and increasingly a note that a field is unsupported on particular models.
Two details in a reference page repay attention beyond the parameter you came for. The first is which model families a field applies to, because support is increasingly per-model rather than per-endpoint: reasoning-oriented models restrict or ignore sampling controls that ordinary chat models accept, and a request that has worked for a year starts failing the day someone points the same code at a new model identifier. The second is the deprecation notes, which are where you learn that the field you are about to depend on has a successor and a timeline.
Reading the reference tells you what is meant to happen. Running the request tells you what does happen, and the two disagree often enough that the exercise worth institutionalising is a small comparison run: the same logical request against both providers, with the results recorded under four headings.
| What to record | For a tagline generator |
|---|---|
| Schema | top_k present on A, absent on B; system a top-level field on A, an array entry on B |
| Defaults | max_tokens required on A; optional on B with an implicit cap |
| Errors | B returns invalid_request_error naming the argument; A returns a message naming the field and its bound |
| Limits | temperature 0.0–1.0 on A, 0.0–2.0 on B; stop sequences capped per provider |
Four rows per capability, checked into the repository next to the adapter, is enough to keep the next person from re-deriving it during an incident. It is also the artefact that makes a migration reviewable, because a reviewer can compare the adapter against the record instead of against their memory of a documentation page.
What a comparison run does not cover
A comparison run tells you about the request you ran. A provider that accepts
top_k today may restrict it on a new model family tomorrow, which is a change
no test of yours will catch unless you run it against every model you use, and
support being per-model rather than per-endpoint makes that a live risk rather
than a theoretical one.
Nor does it protect against the failure being in your own wrapper. If the layer
strips arguments before serialising, a comparison run made through that same
layer will report agreement, because both sides were stripped identically. That
is the trap the tagline generator was in: any test written against generate()
would have confirmed that both providers received the same arguments, since both
call sites did. Comparison runs are worth making against the raw clients, and the
results are worth treating as a snapshot with a date on it rather than as a
property of the providers.
The rule that survives all of this is that a parameter is not applied because you passed it. It is applied because it reached the wire and the endpoint implemented it, and there are three layers between the call site and that outcome — your wrapper, the client's signature, and the provider's validator — each of which can remove a field without saying so. Two of the three fates are silent, both produce output drift rather than errors, and both are diagnosed the same way: read the serialised body, per branch, and compare.
That still leaves a gap. Everything so far has been about the JSON body — which fields it carries, what each side does with them — on the assumption that if the body is right the request is right. It is not, quite. A body that a client sends successfully can be rejected outright when you send the identical bytes yourself, because the client was contributing more to the request than the part you were looking at.
Provider SDKs and the differences that actually matter — read the current version at https://daviddominguez.dev/learn/en/provider-sdks-and-the-differences-that-actually-matter/unsupported-parameters