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.
This commit is contained in:
Nate Armstrong 2026-10-19 12:55:27 -07:00 • committed by GitHub
parent 28feeab36c
commit afebcb3455
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 53 additions and 2 deletions

View file

@ -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;

View file

@ -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.
///

View file

@ -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<Value>) {
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!({