fix(transformation): merge system and developer messages into one leading system message (internal copy of #39282) - #39852
mateo-berri wants to merge 16 commits into
Conversation
…for non-OpenAI providers and Responses API
…ries when merging system messages
… pass The tuple accumulator passed to reduce copied the whole prefix on every step, making map_developer_role_to_system_role O(n^2) in the number of messages. Message count is not bounded by default, so a caller could tie up a proxy worker with thousands of small messages before any provider call. Use a list and merge into its last element in place instead.
…em message for non-OpenAI providers A developer message that arrives after the first user turn (Codex CLI's per-turn permissions item, a re-injected instruction after compaction, a mid-conversation policy update) was translated to a system message in place, and system-first chat templates such as Fireworks' qwen3.8 reject any system message that is not at the beginning with 400. The fold that merges consecutive system messages never saw it because a user turn sat in between. Move every developer message that follows the leading system block to the end of that block before the fold, so it merges into the single leading system message; client-authored mid-conversation system messages stay where the client put them.
Greptile SummaryThis PR makes developer-role translation provider-aware and folds applicable developer instructions into one leading system message for OpenAI-compatible chat backends
Confidence Score: 5/5The PR appears safe to merge, with no outstanding previous findings or accepted new issues The current head is unchanged since the previous review. The earlier billing-metadata and source-comment findings are fixed, the Ollama and trailing-instruction findings were withdrawn after provider-specific validation, and the Fireworks Responses thread is resolved with the shared folding behavior present
|
| Filename | Overview |
|---|---|
| litellm/llms/base_llm/base_utils.py | Adds shared developer-message hoisting, system-run merging, closing-item selection, and metadata-preserving content folding |
| litellm/llms/openai/chat/gpt_transformation.py | Selects in-place mapping for hosted OpenAI and hoisting for OpenAI-compatible endpoints |
| litellm/llms/azure/chat/gpt_transformation.py | Keeps Azure OpenAI developer messages in their original positions while translating their role |
| litellm/llms/fireworks_ai/responses/transformation.py | Replaces the route-local instruction-index logic with the shared helper |
| litellm/main.py | Passes provider and API-base context into provider-specific developer-role translation |
| tests/test_litellm/llms/base_llm/test_base_utils.py | Covers message ordering, system folding, closing developer runs, cache control, null content, names, and billing metadata |
Reviews (11): Last reviewed commit: "Merge branch 'litellm_internal_staging' ..." | Re-trigger Greptile
…essage on OpenAI-compatible backends Developer role translation moves onto the provider config. OpenAI's own endpoint, Azure OpenAI, and the o-series keep their current behavior. Every other config inheriting OpenAIGPTConfig (Fireworks, DeepSeek, Together, vLLM, Groq, openai/ with a custom api_base) hoists developer messages that arrive after the leading system block into that block and folds the block into a single system message, which is what system-first chat templates require. Providers with their own message format are untouched. Messages carrying cache_control or Anthropic billing metadata fold as their own text blocks, so breakpoints and provider-side stripping survive the merge. The Responses bridge goes back to prepending instructions as a separate system message and lets the hoist fold it.
PR overviewAll previously flagged issues have been addressed. No open security concerns remain on this pull request. Security reviewNo open security issues remain on this pull request. Fixed/addressed: 1 · PR risk: 0/10 |
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
The pairwise fold recopied the accumulated system text or block list on every merge, so a request carrying many consecutive system or developer messages cost quadratic time in preprocessing. Group consecutive system messages once and build the merged message from the whole run: plain strings join with a blank line, and any cached, billing, or block content keeps one text block per message. The name of the last named message in the run survives, as before.
…for OpenAIConfig backends A developer message that closes the conversation stays where the client put it, translated to system in place, so the request never ends on the assistant's turn. OpenAIConfig and its subclasses (Azure AI Foundry, GitHub Copilot, ChatGPT, RAGFlow) now share the OpenAI-compatible hoist, litellm_proxy passes developer messages through to the downstream proxy, and the Anthropic billing header literals use the shared constant
… an assistant turn A developer message that closes the conversation after a user or tool message hoists like any other, so the request shape the ticket reports keeps working on system-first templates; only a closing run that follows an assistant turn stays in place
…itellm_lit_7019_hoist_developer_messages # Conflicts: # tests/test_litellm/llms/openai/test_openai.py
…t run Codecov flagged the empty-string branch of _text_blocks as the one line the PR left uncovered. The existing empty-string test only hits the plain-text join; this one forces the block path by merging an empty system message with a developer message that carries list content.
…e system fold
- drop the LiteLLMProxyChatConfig passthrough so litellm_proxy/ hoists later developer messages like every other OpenAI-compatible endpoint, whichever backend or LiteLLM version sits downstream
- a system message with null or missing content inside a folded run is dropped instead of raising KeyError('content')
- a message-level cache_control on a list-content member lands on its last block so the breakpoint survives the fold
…gnatures still name
…/litellm into litellm_lit_7019_hoist_developer_messages
…native Responses path The native Fireworks Responses path landed on staging mapping developer input items to system in place, so a developer item after the first input item still hit the same "System message must be at the beginning" 400 on system-first chat templates. The ordering rules now live in one shared helper over item roles, and the native path reorders through it before mapping, keeping a closing developer item after an assistant turn in place exactly like the chat path does
…the responses path Fireworks renders a Responses request through a chat template that only accepts a system message at the very beginning, so a request carrying `instructions`, a developer item, and a replayed reasoning item (the shape Codex CLI sends from its second prompt on) came back 400 with "System message must be at the beginning". The leading system or developer items, and any developer item later in the conversation, now fold their text into top-level `instructions`, joined with blank lines, and leave `input`. A developer item that closes the conversation right after an assistant turn stays where it is as a system item, as does any system or developer item with an image or file part, so those parts still reach Fireworks. Mid-conversation system items stay untouched. Non-string `instructions` pass through unchanged. Folding into `instructions` rather than a leading system item keeps `previous_response_id` chaining working, since Fireworks prepends the stored history to `input` and a leading system item would land after it. This supersedes the leading system item approach from deaadc2 and 4807630 on this branch. The leading and closing block rules match the chat path change in #39852.
…/litellm into litellm_lit_7019_hoist_developer_messages
|
bugbot run |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 8b49a64. Configure here.
Internal copy of #39282 at head 25186b1, commits and authors intact, so CircleCI's provider suites run on a
litellm_branch. The follow-up commits on top narrow the hoist to OpenAI-compatible backends, answer the review comments, merge each run of system messages in a single pass, keep a closing developer message in place, and share the item-picking rule with the native Fireworks Responses route that #39826 and #40268 landed on stagingTLDR
Problem this solves:
developermessage placed after the first user turn (a per-turn permissions item, a re-injected instruction after compaction, a mid-conversation policy update) was translated to asystemmessage in place, so it landed mid-conversationinstructionsplusdeveloperinput produced severalsystemmessages on the chat completions bridgeSystem message must be at the beginning.How it solves it:
openai/with a customapi_base, every config that inheritsOpenAIGPTConfigorOpenAIConfig) hoist developer messages that arrive after the leading system block into that block and fold the block into one leadingsystemmessage. A developer message that closes the conversation right after an assistant turn stays where it is, translated in place as before, so the request never ends on the assistant's turn, and on a system-first template that closing shape still gets the same 400 it got before this PR. Client-authored mid-conversationsystemmessages stay where the client put theminstructions, the leading system and developer items, and every later developer item into top-levelinstructionssince fix(fireworks_ai): fold instructions and developer items into one leading system message on the Responses path #40268 (in this PR's merge base). This PR moves that route's index-picking helper intobase_utilsashoisted_instruction_indices, so the chat hoist and the native route share one closing-item rule instead of two private copieslitellm_proxy/hoists the same way, so a developer message reaches the downstream LiteLLM proxy already folded into the leading system message whichever backend it routes to (and whichever LiteLLM version it runs)nullor missingcontentinside a folded run is dropped instead of raising, and a message-levelcache_controlon a list-content member lands on that member's last block so the breakpoint survives the folddeveloperthrough nativelybedrock/openai/...,vertex_ai/meta/...and the other Vertex model-garden configs) inherit the hoist like every other config built onOpenAIGPTConfigcache_controlor Anthropic billing metadata is folded as its own text block, so each breakpoint stays on its own block and provider-side stripping still finds the metadataTrade-off called out for reviewers: on OpenAI-compatible backends a hoisted developer instruction is applied from the start of the conversation rather than at the position the client placed it. For system-first templates the alternative is a hard 400, and for templates that accept later system messages the instruction content is unchanged, only its position moves
User Flow
Before: a Responses API client that places a developer item after the first input item gets a 400 from a Fireworks qwen3.8 deployment on the chat completions bridge and through the reporter's
custom_openai/prefix, the same messages fail on/v1/chat/completions, and a Codex CLI session on the bridge deployment never gets an answerfireworks-qwen3p8-bridge(Fireworks qwen3.8 withuse_chat_completions_api: true) andqwen3p8-custom-openai(the same model throughcustom_openai/with the Fireworks base URL) to the gateway config and point their agent at the gatewayfireworks-qwen3p8-bridgewithinputof a user itemHi there, then a developer itemAnswer with exactly one word., then the user questionWhat is the capital of France?jinja template rendering failed. System message must be at the beginning.and the client gets no outputqwen3p8-custom-openaifireworks-qwen3p8-bridgebase_url = https://litellm-domain/v1,wire_api = "responses", modelfireworks-qwen3p8-bridge), start a session in a repo, and typeWhat is the capital of France? Answer with exactly one word.System message must be at the beginning., and no reply; the follow-up turnAnd of Germany? Answer with exactly one word.fails the same wayAfter: the same requests are answered with the instruction honored, and the Codex session on the bridge deployment answers both turns
fireworks-qwen3p8-bridge(Fireworks qwen3.8 withuse_chat_completions_api: true) andqwen3p8-custom-openai(the same model throughcustom_openai/with the Fireworks base URL) to the gateway config and point their agent at the gatewayfireworks-qwen3p8-bridgewithinputof a user itemHi there, then a developer itemAnswer with exactly one word., then the user questionWhat is the capital of France?Parisqwen3p8-custom-openaiParisfireworks-qwen3p8-bridgeParisbase_url = https://litellm-domain/v1,wire_api = "responses", modelfireworks-qwen3p8-bridge), start a session in a repo, and typeWhat is the capital of France? Answer with exactly one word.Paris, and the follow-up turnAnd of Germany? Answer with exactly one word.showsBerlinRelevant issues
Fixes #26879 (the stale bot closed it before a fix landed)
Supersedes #26884
Related: #39826 added the native Fireworks Responses route on staging and #40268 folds developer items into
instructionsthere; both are in this PR's merge base, which is why the native route answers 200 on both legs belowLinear ticket
Resolves LIT-7019
Pre-Submission checklist
Please complete all items before asking a LiteLLM maintainer to review your PR
uv run pytest tests/test_litellm/<your_test_file>.py -v. Leave the suites (make test-unit-*,make test-unit) to CI: it finishes in ~15 minutes where a laptop takes an hour or more@greptileaito re-request a review after pushing changes)Delays in PR merge?
If you're seeing a delay in your PR being merged, ping the LiteLLM Team on Slack (#pr-review).
Screenshots / Proof of Fix
Shared setup, identical for both legs: two proxies, one at the merge base db7ca65 (staging head at the time, port 57770) and one at the PR tip 8b49a64 (port 48910), each booted with
--num_workers 2and no database, hitting the real Fireworks and OpenAI APIs (real $$$).fireworks-qwen3p8is the native Fireworks Responses route (the default forfireworks_ai/models since #39826),fireworks-qwen3p8-bridgeis the same model through the chat completions bridge,fireworks-qwen3p8-openai-compatis the same model throughopenai/with the Fireworks base URL,qwen3p8-custom-openaiis the issue reporter's prefix,qwen3p8-hosted-vllmandqwen3p8-hosted-vllm-native-responsesstand in for the second commenter's vLLM stack (Fireworks renders the same Qwen chat template a vLLM server would and rejects a later system message the same way), andgpt-5.4-miniis the control that must keep its current shapeBoth proxies were launched through a short shim that loads the QA
.envand unsetsDATABASE_URLbefore handing those flags toproxy_cli.run_server; it holds no LiteLLM logicCodex CLI 0.145.0
config.toml(base_urldiffers between legs,modelbetween the two Codex cases):additional_drop_params: ["client_metadata"]and the bridge aliases'drop_params: trueare unrelated to this PR and identical on both legs: Codex sendsclient_metadataon every request and Fireworks rejects it (tracked in #28539 and #36268), and the bridge rejects Codex'stool_choiceandreasoningas unsupported for this model withoutdrop_paramsEvery curl below ran from one script in the order shown, once per proxy. The "Observed" block is what that script printed: the HTTP status, then the response's output text (
output_textfor/v1/responses,assistantfor/v1/chat/completionsand/v1/messages) or itserror.messageon a failure. The "Upstream request" step is read from the proxy's--detailed_debuglog so the reviewer can see what the provider received; the verdict rests on the HTTP responses aloneBefore (db7ca65)
/v1/responses, developer item after the first input item, native Fireworks Responses route
Run
Observed
Upstream request to
POST https://api.fireworks.ai/inference/v1/responses:inputroles['user', 'user'],instructions'Answer with exactly one word.'/v1/responses, same input through the chat completions bridge
Run
Observed
Upstream request to
POST https://api.fireworks.ai/inference/v1/chat/completions:messagesroles['user', 'system', 'user']/v1/responses, same input to OpenAI (control)
Run
Observed
Upstream request to
POST https://api.openai.com/v1/responses:inputroles['user', 'developer', 'user']/v1/responses, same input through the reporter's custom_openai/ prefix (chat completions bridge)
Run
Observed
Upstream request to
POST https://api.fireworks.ai/inference/v1/chat/completions:messagesroles['user', 'system', 'user']/v1/responses, same input through hosted_vllm/ on the chat completions bridge
Run
Observed
Upstream request to
POST https://api.fireworks.ai/inference/v1/chat/completions:messagesroles['user', 'system', 'user']/v1/responses, same input through hosted_vllm/ on its native Responses route (pre-existing, this PR leaves it alone)
Run
Observed
Upstream request to
POST https://api.fireworks.ai/inference/v1/responses:inputroles['user', 'developer', 'user']/v1/chat/completions, developer message after the first user turn, Fireworks
Run
Observed
Upstream request to
POST https://api.fireworks.ai/inference/v1/chat/completions:messagesroles['user', 'system', 'user']/v1/chat/completions, same messages through openai/ with the Fireworks base URL
Run
Observed
Upstream request to
POST https://api.fireworks.ai/inference/v1/chat/completions:messagesroles['user', 'system', 'user']/v1/chat/completions, same messages to OpenAI (control)
Run
Observed
Upstream request to
POST https://api.openai.com/v1/chat/completions:messagesroles['user', 'system', 'user']/v1/chat/completions, same messages through custom_openai/
Run
Observed
Upstream request to
POST https://api.fireworks.ai/inference/v1/chat/completions:messagesroles['user', 'system', 'user']/v1/chat/completions, same messages through hosted_vllm/
Run
Observed
Upstream request to
POST https://api.fireworks.ai/inference/v1/chat/completions:messagesroles['user', 'system', 'user']/v1/chat/completions, the second commenter's two leading system messages, custom_openai/
Run
Observed
Upstream request to
POST https://api.fireworks.ai/inference/v1/chat/completions:messagesroles['system', 'system', 'user'], first system content 'You are a terse assistant.'/v1/chat/completions, the same two leading system messages, hosted_vllm/
Run
Observed
Upstream request to
POST https://api.fireworks.ai/inference/v1/chat/completions:messagesroles['system', 'system', 'user'], first system content 'You are a terse assistant.'/v1/messages, system prompt with a multi-turn conversation (control, this endpoint has no developer role)
Run
Observed
Upstream request to
POST https://api.fireworks.ai/inference/v1/chat/completions:messagesroles['system', 'user', 'assistant', 'user'], first system content 'Answer with exactly one word.'/v1/responses, instructions plus a leading system item plus a later developer item, native route
Run
Observed
Upstream request to
POST https://api.fireworks.ai/inference/v1/responses:inputroles['user', 'user'],instructions'You are a terse assistant.\n\nKeep answers short.\n\nAnswer with exactly one word.'Codex CLI, chat completions bridge deployment
model = "fireworks-qwen3p8-bridge"andbase_url = "http://localhost:57770/v1", runLITELLM_QA_KEY=sk-1234 codexin a repo and typeWhat is the capital of France? Answer with exactly one word.jinja template rendering failed. System message must be at the beginning., and no replyAnd of Germany? Answer with exactly one word.POST https://api.fireworks.ai/inference/v1/chat/completions:messagesroles['system', 'system', 'user', 'user']on the first turn (Codex's instructions and its developer item as two separate system messages), then['system', 'system', 'user', 'user', 'user']Codex CLI, native Fireworks Responses deployment
model = "fireworks-qwen3p8"andbase_url = "http://localhost:57770/v1", runLITELLM_QA_KEY=sk-1234 codexin a repo and typeWhat is the capital of France? Answer with exactly one word.ParisAnd of Germany? Answer with exactly one word.BerlinPOST https://api.fireworks.ai/inference/v1/responses:inputroles['user', 'user']with Codex's instructions and developer item folded intoinstructions(29714 chars), then['user', 'user', 'reasoning', 'assistant', 'user']After (8b49a64)
/v1/responses, developer item after the first input item, native Fireworks Responses route
Run
Observed
Upstream request to
POST https://api.fireworks.ai/inference/v1/responses:inputroles['user', 'user'],instructions'Answer with exactly one word.'/v1/responses, same input through the chat completions bridge
Run
Observed
Upstream request to
POST https://api.fireworks.ai/inference/v1/chat/completions:messagesroles['system', 'user', 'user'], first system content 'Answer with exactly one word.'/v1/responses, same input to OpenAI (control)
Run
Observed
Upstream request to
POST https://api.openai.com/v1/responses:inputroles['user', 'developer', 'user']/v1/responses, same input through the reporter's custom_openai/ prefix (chat completions bridge)
Run
Observed
Upstream request to
POST https://api.fireworks.ai/inference/v1/chat/completions:messagesroles['system', 'user', 'user'], first system content 'Answer with exactly one word.'/v1/responses, same input through hosted_vllm/ on the chat completions bridge
Run
Observed
Upstream request to
POST https://api.fireworks.ai/inference/v1/chat/completions:messagesroles['system', 'user', 'user'], first system content 'Answer with exactly one word.'/v1/responses, same input through hosted_vllm/ on its native Responses route (pre-existing, this PR leaves it alone)
Run
Observed
Upstream request to
POST https://api.fireworks.ai/inference/v1/responses:inputroles['user', 'developer', 'user']/v1/chat/completions, developer message after the first user turn, Fireworks
Run
Observed
Upstream request to
POST https://api.fireworks.ai/inference/v1/chat/completions:messagesroles['system', 'user', 'user'], first system content 'Answer with exactly one word.'/v1/chat/completions, same messages through openai/ with the Fireworks base URL
Run
Observed
Upstream request to
POST https://api.fireworks.ai/inference/v1/chat/completions:messagesroles['system', 'user', 'user'], first system content 'Answer with exactly one word.'/v1/chat/completions, same messages to OpenAI (control)
Run
Observed
Upstream request to
POST https://api.openai.com/v1/chat/completions:messagesroles['user', 'system', 'user']/v1/chat/completions, same messages through custom_openai/
Run
Observed
Upstream request to
POST https://api.fireworks.ai/inference/v1/chat/completions:messagesroles['system', 'user', 'user'], first system content 'Answer with exactly one word.'/v1/chat/completions, same messages through hosted_vllm/
Run
Observed
Upstream request to
POST https://api.fireworks.ai/inference/v1/chat/completions:messagesroles['system', 'user', 'user'], first system content 'Answer with exactly one word.'/v1/chat/completions, the second commenter's two leading system messages, custom_openai/
Run
Observed
Upstream request to
POST https://api.fireworks.ai/inference/v1/chat/completions:messagesroles['system', 'user'], first system content 'You are a terse assistant.\n\nAnswer with exactly one word.'/v1/chat/completions, the same two leading system messages, hosted_vllm/
Run
Observed
Upstream request to
POST https://api.fireworks.ai/inference/v1/chat/completions:messagesroles['system', 'user'], first system content 'You are a terse assistant.\n\nAnswer with exactly one word.'/v1/messages, system prompt with a multi-turn conversation (control, this endpoint has no developer role)
Run
Observed
Upstream request to
POST https://api.fireworks.ai/inference/v1/chat/completions:messagesroles['system', 'user', 'assistant', 'user'], first system content 'Answer with exactly one word.'/v1/responses, instructions plus a leading system item plus a later developer item, native route
Run
Observed
Upstream request to
POST https://api.fireworks.ai/inference/v1/responses:inputroles['user', 'user'],instructions'You are a terse assistant.\n\nKeep answers short.\n\nAnswer with exactly one word.'Codex CLI, chat completions bridge deployment
model = "fireworks-qwen3p8-bridge"andbase_url = "http://localhost:48910/v1", runLITELLM_QA_KEY=sk-1234 codexin a repo and typeWhat is the capital of France? Answer with exactly one word.ParisAnd of Germany? Answer with exactly one word.BerlinPOST https://api.fireworks.ai/inference/v1/chat/completions:messagesroles['system', 'user', 'user']on the first turn, the one system message carrying Codex's instructions and developer item as a four-block text list, then['system', 'user', 'user', 'assistant', 'user']Codex CLI, native Fireworks Responses deployment
model = "fireworks-qwen3p8"andbase_url = "http://localhost:48910/v1", runLITELLM_QA_KEY=sk-1234 codexin a repo and typeWhat is the capital of France? Answer with exactly one word.ParisAnd of Germany? Answer with exactly one word.BerlinPOST https://api.fireworks.ai/inference/v1/responses:inputroles['user', 'user']withinstructions(29714 chars), then['user', 'user', 'reasoning', 'assistant', 'user'], byte-for-byte the shapes of the merge baseObservations from the run
hosted_vllm/native Responses still 400s on both legs, left aloneopenai/with a Fireworks base URL hoists too, as intended/v1/messageson qwen3.8 returns a text-less reasoning block, both legsNotes on the branch history
origin/litellm_internal_stagingwas merged in three times, at 1d26ee5 (after the PR went CONFLICTING ontests/test_litellm/llms/openai/test_openai.py, combined with staging's stream-options tests first), at d5d70da (staging head 1009976, which brought the native Fireworks Responses route from feat(fireworks_ai): add native Responses API config #39826), and at 8b49a64 (staging head db7ca65, now the merge base, which brought fix(fireworks_ai): fold instructions and developer items into one leading system message on the Responses path #40268). Merged rather than rebased so the bot reviews and the per-commit QA stay attached to their commitsinstructionsthere first, so the 8b49a64 merge takes staging's transformation and keeps only the shared helper, nowhoisted_instruction_indicesinbase_utils, which both paths importlitellm_proxy/passthrough override is gone, anullor missing systemcontentinside a run folds away instead of raising, and a message-levelcache_controlon a list-content member lands on its last blockTYPE_CHECKINGimport ofAllMessageValuesthat two string annotations still name went out with the removed override); the commit right after it restores the import, nothing else changesDependency walk at 8b49a64 (/live-pr-risk)
Verdict: no breaking or backward-incompatible dependent found. The one contract change, the
translate_developer_role_to_system_rolesignature, has five implementers and one caller, all in the diffBaseConfig.translate_developer_role_to_system_rolegains keyword-onlycustom_llm_providerandapi_baseand returns aSequence.OpenAIGPTConfig,OpenAIConfig,OpenAIOSeriesConfig, andAzureOpenAIConfigoverride it, andgrep -rn "def translate_developer_role_to_system_role"finds exactly those five, with no duck-typed implementer elsewhere. The single caller iscompletion()inlitellm/main.py, which passes both keywords and wraps the result in a list for the legacy handlers._targets_openai_hosted_endpointnow delegates tois_openai_backed_api_base, so its two other consumers (_should_preserve_cache_control_for_endpoint,_flattened_tools_update_for_openai) move with it._hoisted_indicesin the Fireworks Responses transformation is replaced by the sharedhoisted_instruction_indicesinbase_utils. No config key, env var, Pydantic field, DB column, dashboard field, or string-dispatched name changescompletion()(/v1/chat/completions, the Responses bridge,/v1/messages) for the 70-odd configs inheritingOpenAIGPTConfigorOpenAIConfig, verified live on the legs above for fireworks_ai, openai, custom_openai, and hosted_vllm and in the SDK sweep below; (2) the native Fireworks Responses route throughhoisted_instruction_indices, verified live on both legs; (3) cache_control preservation and tool flattening foropenai/with a custom base, unit-tested intest_openai_gpt_transformation.py, with the three base URL shapes that flip listed in Caveats and not driven since none serves the OpenAI API; (4)tests/llm_translation/base_llm_unit_tests.py::test_developer_role_translation, whose both variants send a leading developer message, where in-place mapping and hoisting agreelitellm.completion, base is the main checkout at 2611f64, head is 8b49a64, same venv,litellm.__file__printed per leg):[user, developer with list content, user]and[user, assistant, developer]against deepseek-v4-flash, mistral-small-latest, openrouter llama-3.1-8b, perplexity sonar, xai grok-4.20, databricks qwen3-next-80b, and fireworks qwen3p8. Every later-developer case answers Paris on both legs; the head sends it as a leading system message ([system, user, user]), the base as[user, system, user]. deepseek and mistral flatten the folded list content to a string in their own transforms, the rest forward the block list and the backend accepts it. The closing-developer case stays in place on both legs and answers Berlin everywhere except fireworks, which 400s on both (the closing-run rule in Caveats)git merge --no-commitof that head into 8b49a64 is conflict-free (onlyconstants.pyoverlaps, on different lines), and the sweep on that merged tree matches the head leg for fireworks, mistral, xai, and openrouterType
🐛 Bug Fix
Caveats (if any)
Low
Hoist-and-merge is a no-opt-out behaviour change on every OpenAI-compatible config
OpenAIGPTConfigorOpenAIConfighoists, whether or not its template needs it:custom_openai,hosted_vllm,openrouter,xai,mistral,groq,github_copilot,databricks(Databricks-hosted Claude included),azure_ai,litellm_proxy,fireworks_ai, the Vertex model-garden configs (vertex_ai/meta/...), and Bedrock'sbedrock/openai/...route among them. Adevelopermessage after the first user turn moves to the top and merges into one leading system message (unless it closes the conversation right after an assistant turn), so a mid-session developer instruction, a Codex approval-mode change or an agent framework re-injecting policy, reads as if it had been there since the first turn, with no flag to keep the old in-place mapping. The affected set is exactly the set the merge base already rewrote fromdevelopertosystemin place, only the position changes; the hosted OpenAI passthrough and the Azure in-place mapping are untouched; the per-message opt-out is to sendsystem, which the hoist never movessystemanywhere the request still succeeds and the model still receives the instruction, so the only observable difference is its position, and each of the three ways to remove it costs more than it returns. Alitellm_settingsswitch is a global knob nobody asked for that every OpenAI-compatible user would have to learn about to get the fix. A curated list of template-strict providers goes stale the day a new backend appears and missesopenrouter,custom_openai, andhosted_vllm, whose upstream is whatever the user points them at. A send-then-retry on the jinja 400 doubles the call on the failing path and keys on one backend's error stringopenai/behind a non-openai.com host hoists tooopenai/gpt-4.1through those hoists (['system','user','assistant','user']) even though the upstream is the real OpenAI API that accepts developer anywhere; OpenAI tolerates a trailing assistant, so it is a semantic shift there rather than a 400system) applies here tooA real vLLM backend was not driven
custom_openai/and the second commenter's is vLLM. Both configs were driven live against Fireworks' Qwen3.8, whose jinja template rejects a later system message the same way (400 on the merge base, 200 on the tip, legs above); vLLM's own rendering of that template is unverified. Codex was driven with its developer-first shape, and the ticket's mid-session shape is the ticket's own curlClient-authored mid-conversation
systemmessages still fail on strict templatessystem, user, systemon Fireworks Qwen3.8 is 400 "System message must be at the beginning" on both legs. The hoist is developer-only by design, so clients that already translate tosystemthemselves are not helpedA closing
developerright after an assistant turn still 400s on FireworksThe hosted-endpoint check changed for three base URL shapes
_targets_openai_hosted_endpointnow delegates tois_openai_backed_api_base, so a bareopenai.comhost, anopenai.comsubdomain outsideapi.openai.com, and a scheme-less base count as compatible instead of hosted, which also flips cache_control preservation and tool flattening for them. None of the three serves the OpenAI API (a scheme-less base does not work with httpx at all), and a second hostname rule would bring back the duplicate the consolidation removedOnly the native Fireworks Responses route folds developer items
hosted_vllm,xai,openrouter,litellm_proxy,azure,chatgpt,volcengine,perplexity,manus,github_copilot,openai_like) forward developer items as they arrive;hosted_vllm/on its native route against Fireworks' native Responses API still 400s on both legs (case above). A backend that implements the Responses API owns its own templating, so the bridge is the route for a system-first template there, and fix(fireworks_ai): fold instructions and developer items into one leading system message on the Responses path #40268's fold stays Fireworks-onlyMerged system content becomes a block list whenever any member has list content,
cache_control, or billing metadatainput_textlists, so its first turn yieldscontent: [{type: text, ...}, ...]even when every block is plain text (the four-block system message in the Codex bridge case above). All six live providers accepted array system content, so string-only backends (Groq class, no key available) are unverified rather than reproduced. A message-levelcache_controlis dropped when the run's last block is not a dict or already carries oneFolding keeps only the last
nameof a system runnameof the last named member onlyConsecutive client-authored
systemmessages fold anywhere in the conversation, not only at the top[system, user, system, system, user]becomes[system, user, system, user]with the two middle messages joined by a blank line; they stay in place, so a strict template still rejects themThree non-required CircleCI jobs and osv-scan are red at the tip, all red on staging too
llm_translation_testing(three mocked completion tests) andlocal_testing_part1(test_completion_novita_ai) fail identically on staging's latest pipeline since fix(convert_dict_to_response): handle empty choices list without raising 500 APIError #40294 started raising on a response with nochoices(tracked internally);local_testing_part2'stest_openai_stream_options_call_text_completionwas a mid-stream connection error on a text completion path this PR does not touch and is green on staging;osv-scanflagssmol-tomlin the dashboard lockfile, which this PR does not change, and is red on every recent staging head. Every required check passesNo docs change for the new developer-role semantics
Final Attestation
The tests check the right things, including the edge cases, and regressions in the respective real-world customer use-cases are not possible after this PR
8b49a64 passes /live-pr-risk
Note
Medium Risk
Changes message ordering and merging for many chat completion paths; semantics shift for mid-conversation developer instructions on compatible backends, though behavior is extensively tested.
Overview
Fixes 400 "System message must be at the beginning" on OpenAI-compatible backends when a
developermessage (or multiple leadingsystemmessages) appears after the first user turn.Provider-aware
developerhandling:translate_developer_role_to_system_rolenow receivescustom_llm_providerandapi_base. Hosted OpenAI and Azure OpenAI still only remapdeveloper→systemin place; o-series leavesdeveloperunchanged. OpenAI-compatible routes (Fireworks, vLLM,custom_openai, Azure AI,litellm_proxy, etc.) call new logic inbase_utilsthat hoists mid-conversationdevelopermessages into the leading instruction block, merges consecutivesystem/developerruns into one leadingsystemmessage, and leaves in place adeveloperblock that closes the thread right after an assistant turn.completion()passes the new kwargs through frommain.py.Shared folding rules:
hoisted_instruction_indicesis centralized inbase_utilsand reused by the Fireworks Responses path (replacing a local duplicate). Merging preservescache_controlon text blocks and keeps Anthropic billing header lines as separate blocks viaANTHROPIC_BILLING_METADATA_PREFIX.Tests cover hoist/merge edge cases, Azure vs Azure AI vs OpenAI hosted vs compatible
api_base, proxy passthrough, and Responsesinstructions+developerinput.Reviewed by Cursor Bugbot for commit 8b49a64. Bugbot is set up for automated code reviews on this repo. Configure here.