From 6e15d602d0375d3bd281a176ad70b243f9b650a0 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Mon, 23 Feb 2026 10:27:24 -0500 Subject: [PATCH] Fix OpenAI Responses API function call ID prefix mismatch The Responses API returns two distinct IDs on function_call items: - `id` (item-level, starts with `fc_`) - `call_id` (call-level, starts with `call_`) When sending function calls back as input, the `id` field must start with `fc_`. Previously we used `call_id` for both fields, causing: "Invalid 'input[1].id': Expected an ID that begins with 'fc'." Now we preserve the item-level `id` in provider_metadata and use it for the `id` field, while `call_id` continues to link tool results. Co-Authored-By: Claude Opus 4.6 --- crates/llm/src/providers/openai.rs | 131 +++++++++++++++++++++++++++-- 1 file changed, 123 insertions(+), 8 deletions(-) diff --git a/crates/llm/src/providers/openai.rs b/crates/llm/src/providers/openai.rs index 75f74c02d..a7d544903 100644 --- a/crates/llm/src/providers/openai.rs +++ b/crates/llm/src/providers/openai.rs @@ -249,9 +249,17 @@ fn translate_input(messages: &[Message]) -> (Option, Vec (Vec, bool) { } Some("function_call") => { has_tool_calls = true; - let id = item - .get("call_id") - .or_else(|| item.get("id")) + let item_id = item + .get("id") .and_then(serde_json::Value::as_str) - .unwrap_or("") + .unwrap_or(""); + let call_id = item + .get("call_id") + .and_then(serde_json::Value::as_str) + .unwrap_or(item_id) .to_string(); let name = item .get("name") @@ -435,8 +446,12 @@ fn parse_output(output: &[serde_json::Value]) -> (Vec, bool) { .unwrap_or("{}"); let arguments = serde_json::from_str(args_str) .unwrap_or_else(|_| serde_json::json!({})); - let mut tc = ToolCall::new(id, name, arguments); + let mut tc = ToolCall::new(call_id, name, arguments); tc.raw_arguments = Some(args_str.to_string()); + // Preserve item-level ID (fc_xxx) for Responses API round-trip + if !item_id.is_empty() { + tc.provider_metadata = Some(serde_json::json!({"id": item_id})); + } parts.push(ContentPart::ToolCall(tc)); } _ => {} @@ -682,6 +697,10 @@ fn handle_tool_call_delta( .to_string(); let mut tc = ToolCall::new(lookup_id, name, serde_json::json!({})); tc.raw_arguments = Some(delta.to_string()); + // Preserve item-level ID (fc_xxx) for Responses API round-trip + if !item_id.is_empty() && item_id != *lookup_id { + tc.provider_metadata = Some(serde_json::json!({"id": item_id})); + } state.tool_calls.push(tc.clone()); events.push(StreamEvent::ToolCallStart { tool_call: tc }); } @@ -721,11 +740,14 @@ fn handle_output_item_done( } Some("function_call") => { let item = json.get("item").unwrap_or(json); + let item_id = item + .get("id") + .and_then(serde_json::Value::as_str) + .unwrap_or(""); let call_id = item .get("call_id") - .or_else(|| item.get("id")) .and_then(serde_json::Value::as_str) - .unwrap_or("") + .unwrap_or(item_id) .to_string(); let name = item .get("name") @@ -741,11 +763,16 @@ fn handle_output_item_done( let mut tc = ToolCall::new(&call_id, &name, arguments); tc.raw_arguments = Some(args_str.to_string()); + // Preserve item-level ID (fc_xxx) for Responses API round-trip + if !item_id.is_empty() { + tc.provider_metadata = Some(serde_json::json!({"id": item_id})); + } if let Some(existing) = state.tool_calls.iter_mut().find(|t| t.id == call_id) { existing.name.clone_from(&name); existing.arguments = tc.arguments.clone(); existing.raw_arguments.clone_from(&tc.raw_arguments); + existing.provider_metadata.clone_from(&tc.provider_metadata); } else { state.tool_calls.push(tc.clone()); } @@ -1186,6 +1213,94 @@ mod tests { assert_eq!(content[0]["text"], "[Document content not supported by this provider]"); } + #[test] + fn parse_output_preserves_both_ids_on_function_call() { + let output = vec![serde_json::json!({ + "type": "function_call", + "id": "fc_abc123", + "call_id": "call_xyz789", + "name": "get_weather", + "arguments": "{\"location\":\"NYC\"}" + })]; + let (parts, has_tool_calls) = parse_output(&output); + assert!(has_tool_calls); + assert_eq!(parts.len(), 1); + match &parts[0] { + ContentPart::ToolCall(tc) => { + // call_id is used as the ToolCall.id (links to tool results) + assert_eq!(tc.id, "call_xyz789"); + // item-level id (fc_xxx) is preserved in provider_metadata + let meta = tc.provider_metadata.as_ref().expect("provider_metadata should be set"); + assert_eq!(meta["id"], "fc_abc123"); + } + other => panic!("expected ToolCall, got {other:?}"), + } + } + + #[test] + fn translate_input_uses_item_id_for_id_field() { + let mut tc = ToolCall::new("call_xyz789", "get_weather", serde_json::json!({"location": "NYC"})); + tc.provider_metadata = Some(serde_json::json!({"id": "fc_abc123"})); + + let msg = Message { + role: Role::Assistant, + content: vec![ContentPart::ToolCall(tc)], + name: None, + tool_call_id: None, + }; + let (_, input) = translate_input(&[msg]); + let fc = &input[0]; + assert_eq!(fc["type"], "function_call"); + // id field uses the fc_ prefixed item ID + assert_eq!(fc["id"], "fc_abc123"); + // call_id field uses the call_ prefixed call ID + assert_eq!(fc["call_id"], "call_xyz789"); + } + + #[test] + fn translate_input_falls_back_to_tc_id_without_metadata() { + let tc = ToolCall::new("call_xyz789", "get_weather", serde_json::json!({})); + + let msg = Message { + role: Role::Assistant, + content: vec![ContentPart::ToolCall(tc)], + name: None, + tool_call_id: None, + }; + let (_, input) = translate_input(&[msg]); + let fc = &input[0]; + // Without provider_metadata, both fields use tc.id + assert_eq!(fc["id"], "call_xyz789"); + assert_eq!(fc["call_id"], "call_xyz789"); + } + + #[test] + fn parse_output_round_trips_function_call_ids() { + // Simulate a response from the Responses API + let output = vec![serde_json::json!({ + "type": "function_call", + "id": "fc_item1", + "call_id": "call_001", + "name": "search", + "arguments": "{\"q\":\"test\"}" + })]; + let (parts, _) = parse_output(&output); + + // Now translate back to input format + let msg = Message { + role: Role::Assistant, + content: parts, + name: None, + tool_call_id: None, + }; + let (_, input) = translate_input(&[msg]); + let fc = &input[0]; + + // The round-tripped function call should have correct IDs + assert_eq!(fc["id"], "fc_item1"); + assert_eq!(fc["call_id"], "call_001"); + } + #[test] fn build_request_body_includes_stop_sequences() { let mut request = minimal_request();