From eb80d249c6d170bb8d38fefe7f6948e1d541050f Mon Sep 17 00:00:00 2001 From: oxoxDev <164490987+oxoxDev@users.noreply.github.com> Date: Tue, 16 Jun 2026 01:59:34 +0530 Subject: [PATCH] fix(inference): use normalized role for Responses content kind (#3624) (#3626) Co-authored-by: Steven Enamakel --- .../inference/provider/compatible_parse.rs | 12 +++++-- .../inference/provider/compatible_tests.rs | 35 ++++++++++++++++++- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/src/openhuman/inference/provider/compatible_parse.rs b/src/openhuman/inference/provider/compatible_parse.rs index 49c5907d6..6c38c307f 100644 --- a/src/openhuman/inference/provider/compatible_parse.rs +++ b/src/openhuman/inference/provider/compatible_parse.rs @@ -245,10 +245,18 @@ pub(crate) fn build_responses_prompt( continue; } + // Key the content-part kind on the NORMALIZED role, not the raw one. + // `normalize_responses_role` folds `tool` (and any non-user role) into + // `assistant`; the Responses API only accepts `output_text`/`refusal` + // for assistant items, so the kind must track the same normalized value. + // Reading `message.role` here instead let a `tool` turn become an + // assistant item carrying `input_text`, which OpenAI rejects with + // "Invalid value: 'input_text'" (Sentry TAURI-RUST-8FQ / GH #3624). + let role = normalize_responses_role(&message.role); input.push(ResponsesInput { - role: normalize_responses_role(&message.role).to_string(), + role: role.to_string(), content: vec![ResponsesContentPart { - kind: if message.role == "assistant" { + kind: if role == "assistant" { "output_text".to_string() } else { "input_text".to_string() diff --git a/src/openhuman/inference/provider/compatible_tests.rs b/src/openhuman/inference/provider/compatible_tests.rs index edf40c064..44f194489 100644 --- a/src/openhuman/inference/provider/compatible_tests.rs +++ b/src/openhuman/inference/provider/compatible_tests.rs @@ -862,14 +862,47 @@ fn build_responses_prompt_preserves_multi_turn_history() { assert_eq!(input[1].role, "assistant"); assert_eq!(input[1].content[0].kind, "output_text"); assert_eq!(input[1].content[0].text, "ack 1"); + // A `tool` turn normalizes to the `assistant` role, so its content part + // MUST be `output_text` — the Responses API rejects `input_text` on an + // assistant item (Sentry TAURI-RUST-8FQ / GH #3624). assert_eq!(input[2].role, "assistant"); - assert_eq!(input[2].content[0].kind, "input_text"); + assert_eq!(input[2].content[0].kind, "output_text"); assert_eq!(input[2].content[0].text, "{\"result\":\"ok\"}"); assert_eq!(input[3].role, "user"); assert_eq!(input[3].content[0].kind, "input_text"); assert_eq!(input[3].content[0].text, "step 2"); } +/// Regression for Sentry TAURI-RUST-8FQ / GH #3624: the Responses API only +/// accepts `output_text`/`refusal` for assistant items. `normalize_responses_role` +/// folds `tool` into `assistant`, so the content kind must follow the normalized +/// role — never the raw one. No assistant-role item may carry `input_text`. +#[test] +fn build_responses_prompt_tool_role_uses_output_text() { + let messages = vec![ + ChatMessage::assistant("calling a tool"), + ChatMessage::tool("{\"result\":\"ok\"}"), + ChatMessage::user("thanks"), + ]; + + let (_instructions, input) = build_responses_prompt(&messages); + + // The tool turn folds to assistant and must carry output_text. + assert_eq!(input[1].role, "assistant"); + assert_eq!(input[1].content[0].kind, "output_text"); + + // Invariant: an assistant-role item never carries input_text. + for item in &input { + if item.role == "assistant" { + assert_eq!( + item.content[0].kind, "output_text", + "assistant-role item must use output_text, got {}", + item.content[0].kind + ); + } + } +} + #[tokio::test] async fn chat_via_responses_requires_non_system_message() { let provider = make_provider("custom", "https://api.example.com", Some("test-key"));