Chapter 1 of 9 · 16 min read
Provider SDKs
On this page
A provider SDK is the client library a model vendor publishes for your
language, sitting on top of the HTTPS endpoint that a chat request could be
posted to with curl. The library runs inside your process and the model runs
inside the vendor's, so everything the SDK does happens either before the
request leaves your machine or after the response comes back. In Python the two
you will meet most often are anthropic and openai, each installed from the
package index and each maintained by the vendor whose API it calls; the same
clients exist for TypeScript, Java, Go and several other ecosystems, generated
from the same underlying API description. This topic is about that layer — what
it puts on the wire that you did not write, what it does when a request fails,
and which of its differences from a competitor's client can reach production.
Most differences between two SDKs are wrapper style rather than model behaviour, and the question this chapter answers is the one a reviewer should ask about any migration diff in order to tell the two apart: which of these changes can alter what the model produces? Everything you see when two provider SDKs sit side by side falls into one of two piles. The first is differences a thin mapping layer — the small amount of your own code that turns your internal request shape into one vendor's call and turns that vendor's response back into whatever your service already passes around — absorbs completely. The second is differences that change the request or the interpretation of the response. The first pile is large and boring, the second is small and load-bearing, and sorting a specific diff into them is a skill you build by making the same call twice, through two clients, and reading both results field by field.
Why a provider SDK exists at all
Posting JSON to an HTTPS endpoint is easy once and tedious forever. A production call has to carry the credential and the API version in the headers the endpoint expects, serialise your arguments into exactly the field names it accepts, hold a connection pool open so that every request does not pay for a fresh TLS handshake, wait and try again when the provider is momentarily out of capacity, decode a response that may arrive as a sequence of events rather than as a single document, and turn a failure into something your code can branch on. A provider SDK is that work done once by the people who also change the API, packaged with typed request signatures and response objects your editor can complete. The alternative is visible in most codebases that predate the client: roughly ninety lines of hand-rolled HTTP that the team then owns through every change the vendor makes.
In practice you meet one as a single line in requirements.txt or
package.json, a client object constructed once at start-up, and a method call in
whichever service of yours needs a model. The larger vendor packages also ship
agent machinery layered on top of that client, and the same repository frequently
holds both; this topic is about the layer that makes the request, because that is
the layer whose behaviour differs in ways that reach production.
The same request, twice
Here is a summarisation call through Anthropic's Python client. The message array itself — an ordered list of role-tagged entries, sent in full on every request — is the shape a chat API has always had, so nothing about roles or multi-turn structure is new here.
from anthropic import Anthropic
client = Anthropic() # reads ANTHROPIC_API_KEY from the environment
msg = client.messages.create(
model="claude-sonnet-4-5",
max_tokens=1024,
system="Summarise support tickets in three sentences.",
messages=[{"role": "user", "content": ticket_body}],
)
print(msg.content[0].text)
print(msg.stop_reason) # "end_turn"
print(msg.usage.input_tokens, # 612
msg.usage.output_tokens) # 87And the same task through OpenAI's Python client:
from openai import OpenAI
client = OpenAI() # reads OPENAI_API_KEY from the environment
resp = client.chat.completions.create(
model="gpt-4o",
messages=[
{"role": "system", "content": "Summarise support tickets in three sentences."},
{"role": "user", "content": ticket_body},
],
)
print(resp.choices[0].message.content)
print(resp.choices[0].finish_reason) # "stop"
print(resp.usage.prompt_tokens, # 634
resp.usage.completion_tokens) # 91Read the two snippets as a diff and you can list a dozen differences without
thinking hard: the class name, the environment variable, the method path, the
presence of a system parameter versus a system-role entry in the array, the
container the generated text sits in, the name of the field carrying the stop
reason, and the names of the two usage counters. That list is what a migration
ticket usually contains, and almost all of it is in the first pile.
The criterion that sorts the piles
A difference is cosmetic when a deterministic function of the response can
rewrite it into the other shape without needing any information the response does
not contain. resp.choices[0].message.content and msg.content[0].text are two
paths to the same string; a function that returns text_of(response) erases the
difference in four lines and never has to guess. prompt_tokens and
input_tokens count the same thing under two names. A response object whose
fields are stopReason and maxOutputTokens rather than stop_reason and
max_tokens — which is what Google's Gemini REST payloads look like, because
they follow that ecosystem's own JSON style — is a rename table, and a rename
table is the cheapest code in your repository.
A difference is consequential when the mapping layer would have to invent
something. If one provider requires max_tokens on every request and the other
treats it as optional with an implicit ceiling, then a mapping layer that drops
the field on the way to the second provider has changed the request, and there is
no information in the first response that tells it what value to substitute. That
is a decision, and decisions belong in your code where you can see them, not
inside an adapter that presents itself as a rename.
| Difference | Pile | Why |
|---|---|---|
Anthropic() vs OpenAI(), ANTHROPIC_API_KEY vs OPENAI_API_KEY | cosmetic | Construction, not request content |
messages.create vs chat.completions.create | cosmetic | Method path; same endpoint role |
content[0].text vs choices[0].message.content | cosmetic | Two paths to the same string |
input_tokens vs prompt_tokens | cosmetic | Same quantity, different label |
| snake_case vs camelCase field names | cosmetic | Each ecosystem's own style; a rename table |
max_tokens required vs optional | consequential | Changes the output cap, and therefore truncation |
| Temperature accepted in 0–1 vs 0–2 | consequential | Changes the sampling distribution |
system as top-level field vs array entry | consequential | Changes the tokens sent and can 400 |
stop_reason values vs finish_reason values | consequential | Your code branches on these strings |
| Content as a string vs a list of typed blocks | consequential | Determines what you can send at all |
| Default retry count inside the client | consequential | Changes latency and the number of HTTP calls |
The stop_reason row is the interesting one, because it starts in the first pile
and ends in the second. The field name is a rename. The vocabulary of values it
carries is not: end_turn, max_tokens, stop_sequence and tool_use on one
side, stop, length, content_filter and tool_calls on the other. Those
sets are not in bijection — one has a value for a content filter that the other
reports differently, and mapping length onto max_tokens is a claim about
semantics rather than about spelling. Consider a line reading
if finish_reason == "length": retry_with_bigger_cap(). That is code a naive
rename will break silently, because the string it is comparing against never
appears on the other provider and the branch simply never fires.
The reason those two vocabularies cannot be put in bijection is worth deriving, because it is the general form of every consequential difference in this chapter. A stop reason is a report of the event that ended a generation. Each provider names the events its own stack can produce, and those stacks are not the same: a provider that runs a content classifier able to halt a generation partway has an event for that and needs a word for it, while a provider that handles the same situation by having the model decline within an ordinary reply has no such event and therefore no such word. So the sets are not two spellings of one list. They are two lists, describing two things that happen.
A rename table works when the same fact is being written down twice. That is why
prompt_tokens and input_tokens are interchangeable: there is one quantity
and two labels for it. Mapping length to max_tokens happens to be safe for
the same reason — both name the moment a ceiling was reached. Mapping
content_filter to anything is not, because the destination has no value that
means what it means, and any choice you make is a claim about the other
provider's behaviour that you invented while writing an adapter.
Why the two shapes look the way they do
The divergence is not arbitrary, and knowing where it came from makes it easier
to predict. The choices array exists because the endpoint it belongs to was
designed when returning several independent completions for one prompt was a
normal thing to ask for — hence a request parameter for how many you want, and a
list in the response even when you asked for one. The other shape was designed
around a message being a sequence of typed content blocks, because a single
assistant turn can contain text, a tool call, and an image reference at once, and
each of those needs its own type tag. Both decisions are defensible, and each
produces the awkwardness the other avoids: one gives you a list you index into
for no reason on every single call, the other gives you a list you index into
because there genuinely can be several things in it.
Naming style follows the ecosystem rather than the vendor's taste. Both of the
clients above use snake_case because the underlying JSON does and because Python
does. A client for the same class of API in a different ecosystem may present
camelCase all the way down — Google's REST payloads use maxOutputTokens,
finishReason and usageMetadata — and TypeScript clients vary depending on
whether the generator mirrors the wire format or the language's conventions. The
ceiling on how much trouble a naming convention can cause is a lookup table, and
no property of the model changes when you rename a field.
The one case where a naming difference bites is when it hides a semantic
difference behind an identical name, which is the mirror image. Two providers
both accepting a field called stop where one takes a string and the other a
list, or both accepting temperature over different ranges, are more dangerous
than two providers using different names for the same idea, because the type
checker and the reviewer both see agreement.
The temperature case is worth being precise about, because the tempting objection to calling it consequential is a good one: the number is the same number, so surely the behaviour is the same behaviour. What a sampling control does with the number is indeed arithmetic, and the arithmetic does not know which vendor is performing it. The problem is that the value in your configuration was not chosen as a raw quantity. It was chosen as a position on a dial, by somebody who tried a few settings and kept the one that read well, and the dial's length is part of what they were choosing against. The same figure sits at the top of one provider's range and around the middle of another's, so the setting that meant "as varied as this API will go" arrives at the second provider meaning something considerably milder.
There is a harder edge on the same difference. A value that is legal on the wider scale is out of range on the narrower one, and what happens then is not obvious from the call site: the request may be rejected, or it may be accepted with the value brought inside the range, and those two outcomes look nothing alike from your side even though the diff that caused them is one unchanged line. Which of them you get, and how to find out without guessing, is the whole of the next chapter.
Where the wrapper ends and the model begins
The reason the first pile is so large is that both clients are doing the same small job. Each one serialises a JSON body, sets some headers, opens an HTTPS connection, and deserialises the reply into an object with typed attributes. Everything else — the weights, the sampling, the tokenizer, the rate limits — lives on the far side of the wire and is completely untouched by which language client you chose. Two Postgres drivers for the same language differ in connection-pool API, parameter placeholder syntax and how they hand you a result set, and none of that changes the query plan; provider SDKs stand in a similar relationship to the model.
That is a comparison rather than an identity, and the place it breaks is the place this topic lives. A database driver passes your query through: the text you wrote is the text the planner sees, so "the driver cannot change the plan" holds absolutely. A provider SDK does not pass your call through. It assembles a body, and the body contains fields you did not write — a version pin and a user-agent in the headers, a default for a parameter you left unset, a serialisation of a string argument into the block form the endpoint expects. Held to "the library is on this side of the wire and the model is on the other", the comparison is exact and useful. Carried as far as "so the library cannot affect the output", it manufactures precisely the false confidence that lets a migration ship with a changed cap in it.
That framing tells you where to look when something changes. If output quality moved after a migration, the client library is a poor suspect: it cannot make the model worse, and it cannot make it better. What it can do is send different bytes than you thought, which is the same symptom seen from the other end — a different system-prompt placement, a dropped parameter, a different cap, a different default. The correct instinct on a suspicious migration is to compare the two request bodies at the wire rather than to compare the two SDK call sites, and the next several chapters are largely about acquiring the means to do that.
Why the call site is the wrong thing to compare is a short chain, and following it once removes the urge to skip the step. Every convenience the library offers is a transformation applied between your arguments and the bytes. A bare string becomes a typed block. An omitted parameter becomes whatever the library's default is, or is left out so the endpoint's own default applies — and those are two different behaviours that look the same in your editor. A failed connection becomes a second and a third attempt. A version header you never mentioned is added because the vendor needs a way to change response shapes without breaking old callers. Every one of those is invisible in the source you are reading and visible in the body you are sending, and the diff you are reviewing is written in the first of those languages.
The exercise that teaches the sort faster than reading either reference is to run
the same task through both clients in one script and print the two responses as
raw dictionaries rather than as typed objects. Both Python clients expose the
underlying payload — msg.model_dump() on the Anthropic response,
resp.model_dump() on the OpenAI one, since both define their response types
with Pydantic, the Python validation library whose objects all carry that method
— and reading the two dictionaries side by side puts every field difference in
front of you at once, including the ones the typed attribute you happened to use
had hidden.
import json
a = client_anthropic.messages.create(...)
o = client_openai.chat.completions.create(...)
print(json.dumps(a.model_dump(), indent=2, default=str))
print(json.dumps(o.model_dump(), indent=2, default=str))Two habits make the comparison sharper. Run it with a prompt from your actual traffic rather than "hello", because the fields that differ tend to be the ones that only appear when the response is non-trivial — several content blocks, a tool call, a filtered section, a truncation. And run it twice against each provider with the same input, so you can see which fields are stable and which change per call; identifiers and timestamps move, and knowing which is which saves you from writing a comparison test that fails on every run. Doing this once on a real prompt tends to surface two or three fields nobody on the team knew existed, and it gives you the artefact the rest of this topic works from: a recorded pair of request and response bodies for the same task on both providers.
Where the split moves
"The differences are mostly cosmetic" is not "the providers are interchangeable", and the pile-sorting exercise is not an argument for hiding both behind one internal function and calling the job done. Two things prevent that. The second pile is small but load-bearing, and a wrapper that absorbs the first pile while quietly guessing at the second is worse than no wrapper, because it presents a uniform surface over non-uniform behaviour. And some capabilities exist on one side and simply do not exist on the other — prompt caching directives attached to individual content blocks, provider-specific tool-choice modes, structured-output enforcement — where no amount of renaming produces a feature the other endpoint does not implement.
Nor is the split between piles fixed. It depends on what your code does with the
response. If your service returns the generated text and nothing else,
finish_reason is a field you never read and its value vocabulary is genuinely
cosmetic for you. If you have a retry policy keyed on truncation, an abuse
pipeline keyed on content filtering, and a cost dashboard keyed on usage
counters, then three fields that looked like renames are now three behavioural
dependencies. Sorting the piles is therefore something you do against your own
call sites, not once in the abstract.
The wrapper, then, is a transport with ergonomics, and the useful discipline is to keep asking of any difference whether a deterministic function could rewrite it. When the answer is yes, it belongs in a rename table and deserves no further thought. When the answer is no — when the mapping layer would have to invent a value, choose a scale, or decide what a string means — it is a decision, and it belongs somewhere a reviewer can see it rather than inside an adapter that presents itself as a translation.
What the exercise does not tell you is what happens to parameters that exist on one side and not the other. The two calls above were deliberately conservative — model, messages, a cap, a system instruction — and every field in them has a counterpart. Real call sites carry sampling controls, and a shared internal wrapper tends to accumulate them as pass-through keyword arguments until someone sets one that only half your providers have heard of. That request has three possible fates, and only one of them produces an error you will notice.
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/provider-sdks