Skip to content

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.

python
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)         # 87

And the same task through OpenAI's Python client:

python
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)    # 91

Read 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.

DifferencePileWhy
Anthropic() vs OpenAI(), ANTHROPIC_API_KEY vs OPENAI_API_KEYcosmeticConstruction, not request content
messages.create vs chat.completions.createcosmeticMethod path; same endpoint role
content[0].text vs choices[0].message.contentcosmeticTwo paths to the same string
input_tokens vs prompt_tokenscosmeticSame quantity, different label
snake_case vs camelCase field namescosmeticEach ecosystem's own style; a rename table
max_tokens required vs optionalconsequentialChanges the output cap, and therefore truncation
Temperature accepted in 0–1 vs 0–2consequentialChanges the sampling distribution
system as top-level field vs array entryconsequentialChanges the tokens sent and can 400
stop_reason values vs finish_reason valuesconsequentialYour code branches on these strings
Content as a string vs a list of typed blocksconsequentialDetermines what you can send at all
Default retry count inside the clientconsequentialChanges 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.

python
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

Back to top