Skip to content

Mapping Response ID and Request Metadata Fields Between APIs

9 min read · updated August 11, 2026

Every provider returns an identifier you can quote back to support, a statement of which model actually served the request, and a count of what you will be billed for. No two of them agree on where those live, and one of the three does not survive a provider change at all.

Where the identity lives

On an OpenAI chat completion the response body carries id, a string prefixed chatcmpl-, alongside object (the literal chat.completion) and created, a Unix timestamp in whole seconds. On the newer Responses API the same idea is spelled id with a resp_ prefix and the timestamp is created_at. On Anthropic’s Messages API it is id with a msg_ prefix, there is no timestamp field in the body at all, and object is replaced by type holding the literal message.

Three consequences follow immediately for anyone writing a shared log table. The prefix is not stable across shapes from the same vendor, so any code that infers a provider from the prefix will be wrong the first time somebody switches endpoint. The timestamp is absent on at least one shape, so if your table has a non-null provider_timestamp column you will be filling it with your own clock and calling it theirs. And the id is a string of unspecified length and character set: give the column room and do not parse it.

The id’s only real job is to be quoted in a support ticket. That is worth saying because it changes what you do with it: it belongs in the log line and in the error you surface to your own on-call, and it does not belong as a primary key. Generate your own request id before the call, log both, and correlate on yours.

The model field is not the model you asked for

Both shapes return model in the response body, and on both it is the resolved identifier rather than the alias you sent. Send an alias that points at a moving target and the response tells you which concrete build answered. This is the single most useful field in the whole envelope and the one most often dropped from logs, because it is the only evidence you will have when output quality changes on a day you deployed nothing.

OpenAI additionally documents system_fingerprint on chat completions, a backend configuration identifier intended to be compared across requests: two responses with different fingerprints were not necessarily served by identical infrastructure, which matters when you are relying on seed for reproducibility. There is no equivalent field on the Anthropic shape. This is the first genuinely lossy piece of the mapping, and the honest handling is a nullable column rather than a synthesised value. The library already covers what the fingerprint means in its own page; the point here is only that a cross-provider schema must tolerate its absence.

Usage fields, which were renamed

This is where a naive mapping quietly produces wrong invoices. OpenAI’s chat completions object reports usage with prompt_tokens, completion_tokens and total_tokens. The Responses API reports the same quantities as input_tokens, output_tokens and total_tokens. Anthropic’s Messages API uses input_tokens and output_tokens and does not report a total, because it also reports cache accounting separately and a single sum would hide it.

So “input tokens” has two spellings within one vendor’s own product line, and the code that reads usage.prompt_tokens against a Responses-shaped payload does not throw — it reads undefined, coerces to zero somewhere, and reports a request that cost real money as free. A missing field is more dangerous than a wrong one precisely because arithmetic on undefined tends to succeed.

The nested detail objects diverge further. OpenAI reports cached input under a details object inside usage, and reasoning tokens under an output-details object. Anthropic reports cache accounting as sibling fields on usage cache_creation_input_tokens and cache_read_input_tokens — which are billed at different rates from ordinary input and are not included in input_tokens. Summing naively across shapes therefore undercounts on one side and double-counts on the other. If you take one thing from this section: write the mapping per shape, assert that every field you read exists, and fail loudly when it does not.

The metadata that is not in the body

Some of what you want to log is only in the HTTP response headers, and headers are the first thing an SDK abstraction throws away. Anthropic’s documentation names a request-id response header as the value to quote when reporting a problem. OpenAI returns request identification and rate-limit state in headers as well, on the conventional x-ratelimit- family covering remaining requests and remaining tokens along with their reset intervals.

The practical rule is that if your client library hands you a parsed object and no way to reach the raw response, you cannot log rate-limit headroom, and rate-limit headroom is the metric that tells you a 429 is coming before it arrives. Most SDKs expose a raw-response accessor for exactly this; find it once, at the point where you construct the client, rather than discovering during an incident that the number was available all along.

Header names and the exact set of usage sub-fields are the fastest moving part of any provider’s surface. Treat every identifier on this page as the shape documented at the time of writing, and check the vendor’s reference before you rely on a field being present.

What does not survive the mapping

  • The backend fingerprint. One shape has it, the other does not. Nothing you can compute reconstructs it.
  • The provider’s own timestamp. Present on OpenAI shapes, absent on the Anthropic body. Record your own send and receive times and derive latency from those, on every provider, so the number means the same thing everywhere.
  • The stop reason vocabulary. OpenAI returns finish_reason per choice; Anthropic returns stop_reason on the message, plus stop_sequence naming which sequence fired. The values are not a renaming of each other, and flattening them into one column loses the distinction between “hit the token cap” and “matched a stop string” unless you keep the raw value alongside your normalised one.
  • Choice cardinality. The OpenAI shape nests output in choices, an array, because it can return more than one completion. Anthropic returns a single message with a content array of blocks. Code that hard-codes choices[0] is not portable, and code that assumes a single text block is not portable in the other direction.

A logging schema that survives a provider change

The shape that has held up is a narrow normalised table plus the untouched original. Store your own request id, the provider name, the endpoint shape, the requested model string, the resolved model string, input tokens, output tokens, a normalised stop reason, your measured latency — and then the entire raw response body in a JSON column.

-- one row per upstream call
request_id        text primary key,   -- yours, generated before the call
provider          text not null,      -- 'openai' | 'anthropic' | 'self-hosted'
shape             text not null,      -- 'chat.completions' | 'responses' | 'messages'
provider_id       text,               -- chatcmpl-… / resp_… / msg_…
model_requested   text not null,
model_resolved    text,
input_tokens      integer,
output_tokens     integer,
cached_tokens     integer,            -- null where the shape does not report it
stop_reason_raw   text,               -- finish_reason or stop_reason, verbatim
stop_reason       text,               -- your own small vocabulary
latency_ms        integer not null,   -- measured by you, on every provider
raw               jsonb not null      -- the whole body, unmodified

The raw column is what makes the schema survivable. Every normalisation you write is a guess about which fields matter, and the guess is wrong roughly once per provider migration. Keeping the original means the next migration is a backfill query rather than a gap in the history. It costs storage, and storage is cheaper than the question “what did the cache hit rate look like before we switched” being unanswerable.

Keep stop_reason_raw distinct from stop_reason for the same reason. The moment you have two providers you will want to count truncations across both, and the moment you have an incident you will want the exact string the provider sent.