From afebcb34556db2250f0ab3829d7d8665e947683d Mon Sep 17 00:00:00 2001 From: Nate Armstrong Date: Mon, 19 Oct 2026 12:55:27 -0700 Subject: [PATCH] fix(gateway): translated chat answers carry a chatcmpl id and object (#47366) The provider configs leave id out because Python mints it on its own ModelResponse, so non-streaming Anthropic, Bedrock, Vertex and Gemini answers reached gateway clients with no id and no object. The chat handler now names each translated answer as Python does: a minted chatcmpl- id, or Gemini's responseId, and object chat.completion. It does so before the answer is cached, so a cache hit replays the same id. --- .../crates/gateway/tests/chat_translation.rs | 17 ++++++++++++++ .../crates/inference-chat/src/handler.rs | 15 ++++++++++++ .../vertex_and_google_ai_studio_gemini.rs | 23 +++++++++++++++++-- 3 files changed, 53 insertions(+), 2 deletions(-) diff --git a/litellm-rust/crates/gateway/tests/chat_translation.rs b/litellm-rust/crates/gateway/tests/chat_translation.rs index 3c7f7acf305..cbf3a0ce0b1 100644 --- a/litellm-rust/crates/gateway/tests/chat_translation.rs +++ b/litellm-rust/crates/gateway/tests/chat_translation.rs @@ -474,6 +474,23 @@ async fn bedrock_signs_with_the_keys_region_and_endpoint_in_the_config() { ); } +#[rstest] +#[case::anthropic("claude")] +#[case::bedrock("bedrock")] +#[tokio::test] +async fn a_translated_answer_carries_a_chat_completion_id_and_object(#[case] model: &str) { + let harness = harness().await; + let answer = harness + .json(json!({"model": model, "messages": answered("tooluse_1"), "max_tokens": 16})) + .await; + + assert!( + answer["id"].as_str().unwrap().starts_with("chatcmpl-"), + "{answer}" + ); + assert_eq!(answer["object"], "chat.completion"); +} + #[tokio::test] async fn bedrock_tool_round_trip() { let harness = harness().await; diff --git a/litellm-rust/crates/inference-chat/src/handler.rs b/litellm-rust/crates/inference-chat/src/handler.rs index 95764ccda42..160cc9f9221 100644 --- a/litellm-rust/crates/inference-chat/src/handler.rs +++ b/litellm-rust/crates/inference-chat/src/handler.rs @@ -123,6 +123,7 @@ pub(super) async fn execute( })?; config .transform_response(&model, ProviderChatResponseData { body }, &stream_shape) + .map(named) .map_err(Error::from) .map_err(as_response_error) }, @@ -130,6 +131,20 @@ pub(super) async fn execute( .await } +/// The answer named as Python's `ModelResponse` names it: by the id the config kept (Gemini's +/// `responseId`), else a minted `chatcmpl-` id, with `object: chat.completion`. Named before it +/// is cached, so a cache hit replays the id the answer was first sent with. +fn named(mut response: ChatCompletionsResponse) -> ChatCompletionsResponse { + response + .extra + .entry("id") + .or_insert_with(|| format!("chatcmpl-{}", uuid::Uuid::new_v4()).into()); + response + .extra + .insert("object".into(), "chat.completion".into()); + response +} + /// Re-tag an error raised while normalizing a response the provider already /// returned. /// diff --git a/litellm-rust/crates/llms/src/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.rs b/litellm-rust/crates/llms/src/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.rs index 891fff963bf..40d0c102934 100644 --- a/litellm-rust/crates/llms/src/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.rs +++ b/litellm-rust/crates/llms/src/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.rs @@ -1461,7 +1461,14 @@ pub fn transform_generate_content_response( } let usage = calculate_usage(&body)?; let [grounding, url_context, safety, citations] = metadata; - let extra = Map::from_iter([ + // Python names the answer by Gemini's `responseId` when there is one; the host mints an id + // otherwise. + let response_id = body + .get("responseId") + .and_then(Value::as_str) + .filter(|id| !id.is_empty()) + .map(|id| ("id".to_owned(), Value::from(id))); + let extra = Map::from_iter(response_id.into_iter().chain([ ( "vertex_ai_grounding_metadata".to_owned(), Value::Array(grounding), @@ -1475,7 +1482,7 @@ pub fn transform_generate_content_response( "vertex_ai_citation_metadata".to_owned(), Value::Array(citations), ), - ]); + ])); Ok(ChatCompletionsResponse { created: unix_now(), model, @@ -2060,6 +2067,18 @@ mod tests { .unwrap() } + #[rstest] + #[case::named(json!("resp_1"), Some(json!("resp_1")))] + #[case::unnamed(Value::Null, None)] + fn the_answer_is_named_by_the_response_id(#[case] id: Value, #[case] expected: Option) { + let answer = response(json!({ + "responseId": id, + "candidates": [{"content": {"role": "model", "parts": [{"text": "Hi"}]}, "finishReason": "STOP"}], + "usageMetadata": {"promptTokenCount": 1, "candidatesTokenCount": 1, "totalTokenCount": 2} + })); + assert_eq!(answer.get("id").cloned(), expected); + } + #[rstest] fn text_reasoning_and_usage_come_back_in_openai_shape() { let answer = response(json!({