ADR 016 - The developer role and upstream error detail in the log
- Status: accepted
- Date: 2026-10-05
- Amends: ADR 014 (the upstream error body was “only searched, never logged”)
Context
OpenAI added a developer message role for instructions; for its models it
is equivalent to system (each is mapped to the other depending on the
model). Clients now send it: Meilisearch chat sends its system prompt as a
developer message for the openAi source with any model not starting with
gpt-3.5/gpt-4. ChatMessage.role is a free string, so the gateway parsed
the role but every translated provider only recognised system: Anthropic
received developer as a message role and answered 400, surfaced as
LM-3003 (reproduced on 2026-10-05 against lumen.meilisearch.com with
claude-sonnet-5-5). OpenAI-compatible hosts without the role (vLLM chat
templates, Mistral, the Azure GA api-version) fail the same way.
The failure took long to diagnose because the log carried only
request failed with the status: ADR 014 reads the error body to classify
context-length and content-policy refusals, but never logs it.
Decision
developerissystemwherever the upstream has nodeveloperrole.ChatMessage::is_system_role()(systemordeveloper) is the only test a translator may use for instructions:- Anthropic and Bedrock hoist both into the top-level
system, Gemini and Vertex intosystemInstruction, in message order. - Cohere keeps instructions inline and sends
developerassystem. - OpenAI-compatible pass-through (Mistral, Azure, Ollama chat, Groq,
Together, vLLM and every other compatible host) rewrites
developertosystemin place (developer_role_as_system). - Only OpenAI itself gets
developerverbatim: theopenaikind with its built-in URL or abase_urlwhose host isapi.openai.com(OpenAiProvider::with_native_developer_role).kind = "openai"is also the documented way to reach unlisted compatible servers (LiteLLM, llama.cpp), so any otherbase_urlgets the rewrite. - Virtual-model presets treat a client
developermessage as a client system prompt (replaceremoves it,if_absentsees it).
- Anthropic and Bedrock hoist both into the top-level
- The upstream’s error message is logged, bounded and redacted. The body
of every upstream client error but 429 is read (still at most 16 KiB and
500 ms; 5xx bodies stay unread so a stalled body never delays failover).
classify_errorlogs onewarnline,upstream returned an error, withprovider,statusandupstream_error. That field is built only from the vendor’s message string (error.message,message,error_description, stringerror,detail, or the same in the first item of a JSON array). With no such string nothing is logged: never the raw or structured body, which can quote the request (FastAPI/pydanticdetaillists carry aninputfield). The string is cut at the first request echo ('input',"input",input_value,input=), whitespace collapsed, probable credentials replaced by<redacted>(known key prefixes such assk-,AIza,AKIA,gsk_, a 20+ character segment mixing letters and digits, or the value an upstream prints after a credential word such askey,tokenorpassword), then cut to 512 characters. It sits under the request span, so it shares therequest_idof therequest failedline. The body is still never returned to a client.
Consequences
- Meilisearch chat with
source: openAiworks against any LUMEN model; thevLlmsource workaround is no longer needed. - A provider added later must use
is_system_role(), notrole == "system", or the regression returns; the shared tests cover each existing translator. - Vendor error messages describe the request (field paths, limits, model
ids). Known request-echo shapes are cut and bodies are never logged, but
a vendor could still phrase a message around a fragment of the input; the
512-character cut bounds that. An operator who cannot accept it filters
the event (target
lumen_providers::mapping) at the subscriber, asdocs/operations/logging.mdshows. - A client that keeps sending requests the upstream rejects now produces
two lines per request (
warndetail plusrequest failed). - Rewriting to
systemloses nothing: OpenAI defines the two roles as equivalent, and no other upstream distinguishes them.