Skip to content

fix(responses): drop client_metadata and merge system messages for Databricks chat-only models - #42390

Merged
mateo-berri merged 3 commits into
mainfrom
litellm_responses_bridge_drop_client_metadata
Sep 22, 2026
Merged

mateo-berri merged 3 commits into
mainfrom
litellm_responses_bridge_drop_client_metadata

Conversation

@devin-ai-integration

@devin-ai-integration devin-ai-integration Bot commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

  • Codex CLI 400s on every turn against Databricks models without native Responses support
  • Codex's client_metadata leaks into the chat completions body Databricks rejects
  • Codex's instructions plus its leading developer skills message become two consecutive system messages, which Databricks chat-template models reject with System message must be at the beginning

How it solves it:

  • The chat bridge drops Responses-only fields (client_metadata) for every provider, on both of its branches
  • Databricks merges each run of consecutive system messages into one for non-Claude models
  • Deployment-level provider params such as chat_template_kwargs still pass through

User Flow

Before: a Codex CLI user pointed at the proxy with a Databricks model that has no native Responses config gets a 400 on every turn

  1. They set model_provider = "litellm" with base_url = "https://litellm-domain/v1", wire_api = "responses", and model = "databricks-qwen35-122b-a10b" in ~/.codex/config.toml, run codex, and type a prompt
  2. Codex sends POST https://litellm-domain/v1/responses with model, input (led by a developer skills message), instructions, tools, reasoning, stream: true, and its telemetry client_metadata object (turn_id, thread_id, session_id, and so on)
  3. The proxy answers 400 with litellm.BadRequestError: DatabricksException - {"error_code":"BAD_REQUEST","message":"Bad request: json: unknown field \"client_metadata\"\n"} and Codex shows that error in its pane instead of a reply; with client_metadata gone the same turn still 400s with System message must be at the beginning, because the instructions and the developer message reach the model as two consecutive system messages
  4. Every further turn fails the same way, so the model is unusable from Codex

After: the same turn comes back with the model's reply

  1. They set model_provider = "litellm" with base_url = "https://litellm-domain/v1", wire_api = "responses", and model = "databricks-qwen35-122b-a10b" in ~/.codex/config.toml, run codex, and type a prompt
  2. Codex sends POST https://litellm-domain/v1/responses with model, input (led by a developer skills message), instructions, tools, reasoning, stream: true, and its telemetry client_metadata object (turn_id, thread_id, session_id, and so on)
  3. The proxy answers 200 and streams the Responses events back (response.created, response.output_text.delta, response.completed), and Codex shows the model's reply in its pane
  4. Every further turn works the same way

Design decision

Stated as what the value has to mean rather than the field name: a Responses request served through the chat completions bridge has to reach a chat-only provider as a chat request that provider accepts, while deployment-level provider-specific params keep passing through. client_metadata is a Responses-surface field with no chat completions equivalent. No provider accepts it in a chat body, and LiteLLM's native Responses dispatch never forwards it either. So the bridge drops it for every provider before calling completion. For a provider with a native Responses config the bridge already filtered it out through the allowlist #41144 added. For a provider without one the bridge returned the raw kwargs, and that is the branch this PR changes: it now returns the kwargs minus a named set of Responses-only request fields, today just client_metadata, and still forwards everything else. Both branches drop that same set: the native-config branch subtracts it from its allowlist as well, so a deployment whose allowed_openai_params names client_metadata cannot re-add it

Three alternatives were considered and rejected for the client_metadata drop. Applying the native-config allowlist to providers without a config would also drop deployment-level params such as chat_template_kwargs, which #41144 kept on purpose and which the no-native-config-keeps-passthrough test pins. Popping client_metadata inside the Databricks chat transformation would copy the per-provider pop that the anthropic chat, bedrock converse, and mistral chat transformations already carry for this same leak, and every other provider without a native config would keep leaking, so those three existing pops stay and no fourth one is added. Making client_metadata a litellm-level param stripped on every path, chat completions included, would change chat completions behavior nobody asked for, since no chat client sends the field and Codex no longer has a chat wire mode

The second failure has the same shape. Codex sends instructions and leads input with a developer skills message. The bridge turns instructions into a system message and LiteLLM's developer-to-system translation turns the developer message into another one, so Databricks chat-template models such as Qwen receive [system, system, user] and reject it, since their chat template accepts one system message at the front. What the messages have to mean is one system prompt in the same order the client wrote it, so Databricks now merges each run of consecutive system messages into one before building the request, next to its other message rewrites for non-Claude models (_split_parallel_tool_calls). All-string runs are joined with a blank line; a run holding list content becomes one list of text parts with the original parts kept as they are, so a cache_control marker survives. A system message whose content Databricks already stripped as empty is skipped in the merge. Claude models on Databricks go through the Anthropic path, which already folds system messages into the system field, so they are left alone

Two alternatives were considered and rejected for the merge. Merging in the Responses bridge would fix Codex only: the same [system, developer, user] history reaches Databricks through /chat/completions directly from any OpenAI SDK client that sends a developer message, and the bridge has no way to know which providers accept a single system message, so the rewrite belongs with the provider. Overriding translate_developer_role_to_system_role on DatabricksConfig to fold the developer message into the preceding system message would only cover the developer-derived case and miss two literal system messages, which the same clients produce, and it would put a message rewrite in the translation hook instead of next to Databricks' other message rewrites

Relevant issues

Fixes #42347

The issue reports two failure modes on the same route. Its headline reasoning_effort symptom no longer reproduces on main (the bridge already sends the effort as a plain string), so the client_metadata leak from the issue's second comment is the failure mode left, and this PR fixes it

Affected release

Linear ticket

Resolves LIT-8304

Pre-Submission checklist

Please complete all items before asking a LiteLLM maintainer to review your PR

  • I have added meaningful tests
  • The handful of test files covering my change pass locally, e.g. 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
  • My PR passes all required CI/CD checks (e.g., lint, schema.d.ts sync check, etc.)
  • My PR's scope is as isolated as possible; it only solves 1 specific problem
  • I have received a Greptile Confidence Score of at least 4/5 before requesting a maintainer review (Greptile reviews automatically once the PR is opened; only comment @greptileai to 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

Live before and after against the real Databricks workspace (real spend), each leg a proxy booted with --num_workers 2 from the QA config below, driven first with curl and then with the real OpenAI Codex CLI 0.155.1 TUI under tmux (wire_api = "responses", model = "databricks-qwen35-122b-a10b"). Before: the merge base d22a6806df (PYTHONPATH=<base worktree>, python -c 'import litellm; print(litellm.__file__)' printed the base worktree's litellm/__init__.py). After: this PR's tip 3d4dc9cf7a

model_list:
  - model_name: databricks-qwen35-122b-a10b
    litellm_params:
      model: databricks/databricks-qwen35-122b-a10b
      api_base: os.environ/DATABRICKS_API_BASE
      api_key: os.environ/DATABRICKS_API_KEY
litellm_settings:
  drop_params: false

Before (d22a6806df)

curl -s http://127.0.0.1:51249/v1/responses -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' -d '{"model":"databricks-qwen35-122b-a10b","input":"Reply with the single word pong","reasoning":{"summary":"auto"},"client_metadata":{"turn_id":"t1","thread_id":"th1","session_id":"s1","root_turn_id":"t1","x-codex-installation-id":"i1"}}'
{"error":{"message":"litellm.BadRequestError: DatabricksException - {\"error_code\":\"BAD_REQUEST\",\"message\":\"Bad request: json: unknown field \\\"client_metadata\\\"\\n\"}. Received Model Group=databricks-qwen35-122b-a10b\nAvailable Model Group Fallbacks=None","type":"invalid_request_error","param":null,"code":"400"}}

Codex TUI, same proxy, prompt Reply with the single word pong and nothing else:

› Reply with the single word pong and nothing else
■ {"error":{"message":"litellm.BadRequestError: DatabricksException - {\"error_code\":\"BAD_REQUEST\",\"message\":\"Bad request: json:
unknown field \\\"client_metadata\\\"\\n\"}. Received Model Group=databricks-qwen35-122b-a10b\nAvailable Model Group
Fallbacks=None","type":"invalid_request_error","param":null,"code":"400"}}

pr42390-3d4dc9cf7a-codex_before.png

With only the first commit of this PR (064506c9d9, the client_metadata drop), the curl above already returned 200, but the same Codex turn still failed, which is why the second commit exists:

› Reply with the single word pong and nothing else
⚠ Model metadata for `databricks-qwen35-122b-a10b` not found. Defaulting to fallback metadata; this can degrade performance and cause issues.
■ {"error":{"message":"litellm.BadRequestError: DatabricksException - {\"error_code\":\"BAD_REQUEST\",\"message\":\"Failed to render the request with the model's chat template: System message must be at the beginning.\n\"}. Received Model Group=databricks-qwen35-122b-a10b\nAvailable Model Group Fallbacks=None","type":"invalid_request_error","param":null,"code":"400"}}

Direct curls to the Databricks endpoint (no LiteLLM) pin the model behavior: [system, user] 200, [system, system, user] 400 System message must be at the beginning, [system, developer, user] 400 Unexpected message role, while databricks-llama-4-maverick accepts all three

After (3d4dc9cf7a)

Same curl, port of the after proxy:

curl -s http://127.0.0.1:37637/v1/responses -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' -d '{"model":"databricks-qwen35-122b-a10b","input":"Reply with the single word pong","reasoning":{"summary":"auto"},"client_metadata":{"turn_id":"t1","thread_id":"th1","session_id":"s1","root_turn_id":"t1","x-codex-installation-id":"i1"}}'

HTTP 200, output trimmed to the fields that matter:

{"status": "completed", "model": "databricks-qwen35-122b-a10b", "output": [{"type": "reasoning"}, {"type": "message", "content": [{"type": "output_text", "text": "pong"}]}], "error": null}

The Codex wire shape on record too, instructions plus a leading developer item with input_text parts and client_metadata, the exact combination that 400'd twice before:

curl -s http://127.0.0.1:37637/v1/responses -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' -d '{"model":"databricks-qwen35-122b-a10b","instructions":"You are a terse assistant.","input":[{"role":"developer","content":[{"type":"input_text","text":"Answer in lowercase."}]},{"role":"user","content":[{"type":"input_text","text":"Reply with the single word pong"}]}],"reasoning":{"summary":"auto"},"client_metadata":{"turn_id":"t2","thread_id":"th1","session_id":"s1","root_turn_id":"t2","x-codex-installation-id":"i1"}}'
{"status": "completed", "output_text": ["pong"], "error": null}

Codex TUI, same proxy, same prompt:

› Reply with the single word pong and nothing else
⚠ Model metadata for `databricks-qwen35-122b-a10b` not found. Defaulting to fallback metadata; this can degrade performance and cause issues.
• pong
  done 7:37 PM

pr42390-3d4dc9cf7a-codex_after.png

Observations from the run:

  • Codex retries a failed turn once before showing the error
  • Databricks Llama accepts the unmerged shapes, Qwen does not
  • Codex warns about missing model metadata for any non-OpenAI name
  • Databricks' native /responses is not offered for these endpoints

Type

🐛 Bug Fix

Caveats (if any)

Risk summary from a live base-vs-tip A/B of the changed symbols' dependents:

  • Breaking: none. _bridge_kwargs is private with one caller in litellm/responses/main.py, merge_consecutive_system_messages is new with one caller, and no public signature or config key changes
  • Backward incompatible: a bridged Responses request no longer forwards client_metadata to any chat provider, even when a deployment's allowed_openai_params names it (no chat provider accepts the field). Databricks non-Claude chat requests carrying consecutive system messages now arrive as one system message with the same text in the same order
  • Regression risk: Databricks non-Claude models that tolerated two system messages (Llama did in direct curls) now get one merged message, string runs joined with a blank line, list runs kept as their original parts so cache_control survives. Claude models on Databricks take the Anthropic path and are untouched
  • Dependency graph: DatabricksConfig._transform_messages serves both the sync and async Databricks chat paths and is covered by the new transformation test plus the existing tests/unit/llms/databricks/chat suite (224 pass). The bridge flag tests pin no-native-config-keeps-passthrough next to the two new drop tests
  • Not verified live: a direct /v1/chat/completions request with [system, developer, user] to Databricks through the proxy (unit test only), Llama through the proxy after the merge (direct curls only), a deployment with allowed_openai_params: [client_metadata] (unit test only), and Claude on Databricks (code path untouched, not run)
  • CI: six CircleCI jobs are red at the tip (unit, llm_translation_testing, local_testing_part1, integration-cost, integration-providers, e2e_ui_testing) on exactly the tests that fail on main pipelines 90021 and 90023 (secret-detection timing, Fireworks inline images, Bedrock model-info drift, cost breakdown persistence, fal video wire, the MCP tools Playwright spec), none in a file this PR touches. mcp-integration / Run tests passed 422 tests and failed only on the coverage-artifact upload (403), so it was rerun. Every required check is green

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

Note

Medium Risk
Changes request shaping on the Responses-to-chat bridge and Databricks message transforms; behavior is intentional but alters forwarded kwargs and how multi-system histories are sent to non-Claude Databricks models.

Overview
Fixes Codex-style Responses → chat completions bridging for Databricks chat-template models by stripping Responses-only payload and normalizing system prompts before the upstream call.

The Responses bridge now always removes client_metadata when forwarding to chat completions—on both the allowlisted native-config path and the passthrough path for providers without a Responses config—so it cannot leak even if allowed_openai_params names it. Provider-specific fields such as chat_template_kwargs still pass through.

For Databricks non-Claude chat, consecutive system messages are merged into one (strings joined with blank lines; mixed or list content flattened to text parts while preserving markers like cache_control) immediately before existing parallel tool-call splitting, so patterns like instructions plus a leading developer message no longer trigger “system message must be at the beginning.”

Reviewed by Cursor Bugbot for commit 3d4dc9c. Bugbot is set up for automated code reviews on this repo. Configure here.

Codex CLI sends client_metadata on every /v1/responses call. For a
provider with no native Responses config the chat-completions bridge
forwarded the raw kwargs, so client_metadata reached the provider as a
chat body field and Databricks rejected the request with an unknown
field 400. The bridge now drops the Responses-only request fields
before calling completion while still passing every other kwarg
through, so deployment-level params such as chat_template_kwargs keep
reaching providers without a native config.
@devin-ai-integration

devin-ai-integration Bot commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor Author

I'll fix CI failures and address comments from users with write access. I'll skip comments containing "(aside)".

  • Disable automatic comment, CI, and merge conflict monitoring

@greptile-apps

greptile-apps Bot commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 5/5

The PR appears safe to merge; the latest revision fixes the contentless-system-message crash without introducing a new actionable issue.

Findings

  1. P2 Regression test is misplaced ▶

Summary

This PR prevents Responses-only client_metadata from leaking through the chat-completions bridge and merges consecutive Databricks system messages for non-Claude models. The latest changes safely handle system messages whose content was removed during transformation and add focused regression coverage.

  • Drops client_metadata on both bridge-filtering paths while retaining provider deployment parameters.
  • Merges consecutive Databricks system messages without changing the Claude path.
  • Handles partially or entirely contentless system-message runs without raising an exception.
  • Adds bridge, utility, and provider-transformation regression tests.

Reviews (3) · Last reviewed commit: "fix(databricks): skip empty system messa..."

@greptile-apps

This comment has been minimized.

@codspeed

codspeed Bot commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing litellm_responses_bridge_drop_client_metadata (3d4dc9c) with main (5d3d99e)

Open in CodSpeed

@codecov

codecov Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

…models

Codex sends instructions plus a leading developer item, which the Responses
bridge and the developer-to-system translation turn into two consecutive
system messages that Databricks chat-template models reject with "System
message must be at the beginning". Each run of consecutive system messages
is now merged into one before the request is built for non-Claude models.

Also keep client_metadata out of the bridged chat request even when
allowed_openai_params names it, so both bridge branches drop the same set.
@mateo-berri mateo-berri changed the title fix(responses): drop client_metadata before bridging to chat completions fix(responses): drop client_metadata and merge system messages for Databricks chat-only models Sep 22, 2026
@mateo-berri

Copy link
Copy Markdown
Contributor

@greptileai

Comment thread litellm/litellm_core_utils/prompt_templates/common_utils.py
Comment on lines +1 to +45
import json
from typing import Final

import httpx
import respx

import litellm


def test_completion_merges_leading_system_and_developer_messages_for_chat_template_models(
respx_mock: respx.MockRouter,
):
upstream: Final = respx_mock.post("https://example.databricks.test/serving-endpoints/chat/completions").mock(
return_value=httpx.Response(
status_code=200,
json={
"id": "chatcmpl-123",
"object": "chat.completion",
"created": 1677652288,
"model": "my-custom-model",
"choices": [{"index": 0, "message": {"role": "assistant", "content": "Answer"}, "finish_reason": "stop"}],
"usage": {"prompt_tokens": 9, "completion_tokens": 1, "total_tokens": 10},
},
)
)

response: Final = litellm.completion(
model="databricks/my-custom-model",
messages=[
{"role": "system", "content": "You are terse."},
{"role": "developer", "content": "Skills: none."},
{"role": "user", "content": "Hello"},
],
api_base="https://example.databricks.test/serving-endpoints",
api_key="fake-databricks-api-key",
num_retries=0,
)

assert upstream.call_count == 1
request_body: Final = json.loads(upstream.calls[0].request.read())
assert request_body["messages"] == [
{"role": "system", "content": "You are terse.\n\nSkills: none."},
{"role": "user", "content": "Hello"},
]
assert response.choices[0].message.content == "Answer"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Regression test is misplaced

This bug fix creates a new Databricks test file instead of extending the existing mapped transformation test. Repository instructions require bug-fix regressions to extend that file, so this must be moved before merging

Context Used: AGENTS.md (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The mirror path tests/test_litellm/llms/databricks/chat/ had no test file for this module; tests/unit is the legacy tree, so this is the mapped file

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You’re right. The tests/test_litellm/llms/databricks/chat/ tree had no pre-existing transformation test, while tests/unit/llms/databricks/chat/ is the legacy suite. Given the repository’s mapped test layout, adding test_databricks_chat_transformation.py at the mirror path is appropriate; there is no existing mapped file to extend. Please disregard my earlier placement comment.

@mateo-berri mateo-berri added run-ci and removed run-ci labels Sep 22, 2026
…ones

Databricks drops empty content before the merge, so a system message in a
run could carry no content key and the merge iterated None. Those messages
are now skipped; a run with no content at all keeps its first message.
@mateo-berri

Copy link
Copy Markdown
Contributor

@greptileai

@mateo-berri mateo-berri added run-ci and removed run-ci labels Sep 22, 2026
@mateo-berri

Copy link
Copy Markdown
Contributor

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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 3d4dc9c. Configure here.

@mateo-berri mateo-berri left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@mateo-berri
mateo-berri merged commit 5cf17f9 into main Sep 22, 2026
173 of 182 checks passed
@mateo-berri
mateo-berri deleted the litellm_responses_bridge_drop_client_metadata branch September 22, 2026 03:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Databricks non-GPT models 400 with reasoning_effort must be a string when reasoning.summary is set

1 participant