AI Gateway: metadata.user_id is forwarded verbatim as OpenAI safety_identifier, silently disabling BYOK
Summary
On the Anthropic Messages API-compatible surface (POST /v1/messages), the AI Gateway forwards the
request’s metadata.user_id verbatim as OpenAI’s user / safety_identifier. OpenAI rejects that
field above 64 characters with HTTP 400.
Because the Gateway then falls back to its own credential and returns 200, the failure is
invisible to the client. The practical effect is that a configured BYOK OpenAI key is never
used for any client that sets metadata.user_id to something longer than 64 characters — the
request is billed to Vercel credit instead, after paying for one failed upstream round trip.
metadata.user_id is a standard field of the Anthropic Messages API, so this affects any caller
using the official Anthropic SDK against the Gateway, not just one client.
Reproduction
With an OpenAI BYOK key configured, send two requests differing only in the length of
metadata.user_id — 64 characters versus 65:
# A: exactly 64 characters -> BYOK is used
curl -s https://ai-gateway.vercel.sh/v1/messages \
-H "x-api-key: $AI_GATEWAY_API_KEY" \
-H "anthropic-version: 2023-06-01" \
-H "content-type: application/json" \
-d '{"model":"openai/gpt-5.6-terra","max_tokens":16,
"messages":[{"role":"user","content":"ok"}],
"metadata":{"user_id":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}'
# B: 65 characters -> falls back, billed by Vercel
curl -s https://ai-gateway.vercel.sh/v1/messages \
-H "x-api-key: $AI_GATEWAY_API_KEY" \
-H "anthropic-version: 2023-06-01" \
-H "content-type: application/json" \
-d '{"model":"openai/gpt-5.6-terra","max_tokens":16,
"messages":[{"role":"user","content":"ok"}],
"metadata":{"user_id":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}'
Both return 200. Take each response id and inspect the generation record:
curl -s "https://ai-gateway.vercel.sh/v1/generation?id=<gen_id>" \
-H "Authorization: Bearer $AI_GATEWAY_API_KEY"
Request A reports is_byok: true, total_cost: 0. Request B reports is_byok: false with a
non-zero total_cost.
The dashboard’s per-request Fallback Path for B shows the underlying cause:
400 BYOK Invalid 'safety_identifier': string too long.
Expected a string with maximum length 64, ...
200 System
Measured results
openai/gpt-5.6-terra, single BYOK OpenAI key, 2026-08-01:
metadata.user_id |
is_byok |
|---|---|
| absent | true |
| 10 characters | true |
| exactly 64 characters | true |
| 65 characters | false |
| 158 characters | false |
The boundary sits exactly at OpenAI’s documented limit, which indicates verbatim pass-through with
no truncation or hashing.
Also measured, and not a workaround — both still yield is_byok: false with a long
metadata.user_id:
- the
ai-reporting-user: <short value>header providerOptions: { gateway: { user: "<short value>" } }
Those feed Custom Reporting, which appears to be a separate path from the safety_identifier sent
upstream. Worth noting that ai-reporting-user is itself validated at 256 characters — looser than
the 64 the provider will accept.
Real-world impact
Claude Code attaches metadata.user_id to every request, and the value is a serialized JSON object:
{"device_id":"<uuid>","account_uuid":"<uuid>","session_id":"<uuid>"}
That is ~158 characters, and ~217 for subagent requests, which add parent_session_id. So pointing
Claude Code at the Gateway with a BYOK OpenAI key means 100% of requests fail on BYOK and fall
back, while the user believes their own key is serving them. There is no client-side signal: the
status code, response headers and latency all look like a normal success.
Expected behavior
When forwarding to a provider that constrains this field, the Gateway should not pass an
oversized value straight through. Any of these would resolve it:
- Truncate or hash
metadata.user_idto the provider’s limit before forwarding. - Omit the field when it cannot satisfy the provider’s constraint, rather than guaranteeing a 400.
- At minimum, surface the fallback to the client — a response header naming the reason — so the
condition is detectable without polling/v1/generationper request.
Option 1 seems closest to the field’s intent: the safety identifier only needs to be stable and
unique per end user, so a hash preserves its purpose while fitting the limit.