From cdc06649fc24e15562d4e76fcbcbd4e8a123f45c Mon Sep 17 00:00:00 2001 From: cunninghamcard-bit Date: Tue, 20 Oct 2026 06:19:46 +0000 Subject: [PATCH 01/10] test(huggingface): mark the old tests with their upstream cases Each test that ports an @ai-sdk/huggingface@2.0.62 case carries the full describe chain as an `/// upstream:` marker and sits in nested describe mods. Tests that assert only part of an upstream case say so and point at the full port; aimux's own tests stay unmarked. Test bodies are unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01RoxcKU4zBD64Lri3e4Lrq5 --- .../tests/huggingface_responses_test.rs | 1276 +++++++++-------- 1 file changed, 642 insertions(+), 634 deletions(-) diff --git a/aimux-providers/tests/huggingface_responses_test.rs b/aimux-providers/tests/huggingface_responses_test.rs index 8bb5c913e..b0aaca871 100644 --- a/aimux-providers/tests/huggingface_responses_test.rs +++ b/aimux-providers/tests/huggingface_responses_test.rs @@ -153,11 +153,608 @@ fn basic_response_body() -> Value { }) } -// ════════════════════════════════════════════════════════════════════════════ -// doGenerate — basic text response -// ════════════════════════════════════════════════════════════════════════════ +/// Helper: mount a stub response so do_generate succeeds and we can inspect +/// the request body. +async fn stub_empty_response(server: &MockServer) { + mock_json( + server, + json!({ + "id": "resp_test", + "model": "moonshotai/Kimi-K2-Instruct", + "object": "response", + "created_at": 1741257730, + "status": "completed", + "error": null, + "instructions": null, + "max_output_tokens": null, + "metadata": null, + "tool_choice": "auto", + "tools": [], + "temperature": 1.0, + "top_p": 1.0, + "incomplete_details": null, + "usage": null, + "output": [], + "output_text": "Test response" + }), + ) + .await; +} + +const PNG_BASE64: &str = "iVBORw0KGgo="; + +mod hugging_face_responses_language_model { + use super::*; + + mod do_stream { + use super::*; + + /// upstream: @ai-sdk/huggingface src/responses/huggingface-responses-language-model.test.ts › HuggingFaceResponsesLanguageModel › doStream › should handle non-message item types + #[tokio::test] + async fn should_handle_non_message_item_types() { + let server = MockServer::start().await; + let chunks = sse_body(&[ + sse_event( + r#"{"type":"response.output_item.added","output_index":0,"item":{"id":"mcp_test","type":"mcp_list_tools","server_label":"test"},"sequence_number":1}"#, + ), + sse_event( + r#"{"type":"response.output_item.done","output_index":0,"item":{"id":"mcp_test","type":"mcp_list_tools","server_label":"test"},"sequence_number":2}"#, + ), + sse_event( + r#"{"type":"response.completed","response":{"id":"resp_test","status":"completed","incomplete_details":null},"sequence_number":3}"#, + ), + ]); + mock_sse(&server, chunks).await; + + let provider = make_provider(&server); + let model = provider.responses("deepseek-ai/DeepSeek-V3-0324"); + + let result = model + .do_stream(&default_options(test_prompt())) + .await + .expect("should succeed"); + + let parts = collect_stream(result).await; + + // Should only have stream-start and finish events (no text events for + // non-message items). + let types: Vec<&str> = parts + .iter() + .map(|p| match p { + StreamPart::StreamStart { .. } => "stream-start", + StreamPart::Finish { .. } => "finish", + _ => "other", + }) + .collect(); + assert_eq!(types, vec!["stream-start", "finish"]); + } + } + + mod message_conversion { + use super::*; + + /// upstream: @ai-sdk/huggingface src/responses/huggingface-responses-language-model.test.ts › HuggingFaceResponsesLanguageModel › message conversion › should convert user messages with images + #[tokio::test] + async fn should_convert_user_messages_with_images() { + let server = MockServer::start().await; + stub_empty_response(&server).await; + + let provider = make_provider(&server); + let model = provider.responses("deepseek-ai/DeepSeek-V3-0324"); + + let prompt = vec![LanguageModelMessage::User { + content: vec![ + UserPart::Text(TextPart { + text: "What do you see?".into(), + provider_options: None, + }), + UserPart::File(FilePart { + data: FileData::Data { + data: FileBytes::Base64("AQIDBA==".into()), + }, + media_type: "image/jpeg".into(), + filename: None, + provider_options: None, + }), + ], + provider_options: None, + }]; + + let result = model + .do_generate(&default_options(prompt)) + .await + .expect("ok"); + let body = result + .request + .and_then(|request| request.body) + .expect("body"); + + assert_eq!( + body["input"][0]["content"], + json!([ + { "text": "What do you see?", "type": "input_text" }, + { "image_url": "data:image/jpeg;base64,AQIDBA==", "type": "input_image" } + ]) + ); + } + + /// upstream: @ai-sdk/huggingface src/responses/huggingface-responses-language-model.test.ts › HuggingFaceResponsesLanguageModel › message conversion › should handle assistant messages + #[tokio::test] + async fn should_handle_assistant_messages() { + let server = MockServer::start().await; + stub_empty_response(&server).await; + + let provider = make_provider(&server); + let model = provider.responses("deepseek-ai/DeepSeek-V3-0324"); + + let prompt = vec![ + LanguageModelMessage::user_text("Hello"), + LanguageModelMessage::Assistant { + content: vec![AssistantPart::Text(TextPart { + text: "Hi there!".into(), + provider_options: None, + })], + provider_options: None, + }, + LanguageModelMessage::user_text("How are you?"), + ]; + + let result = model + .do_generate(&default_options(prompt)) + .await + .expect("ok"); + let body = result + .request + .and_then(|request| request.body) + .expect("body"); + + assert_eq!( + body["input"], + json!([ + { "content": [{ "text": "Hello", "type": "input_text" }], "role": "user" }, + { "content": [{ "text": "Hi there!", "type": "output_text" }], "role": "assistant" }, + { "content": [{ "text": "How are you?", "type": "input_text" }], "role": "user" } + ]) + ); + } + + /// Tool-call, tool-result, and reasoning in assistant messages produce NO + /// warnings (they are silently skipped). + /// upstream: @ai-sdk/huggingface src/responses/huggingface-responses-language-model.test.ts › HuggingFaceResponsesLanguageModel › message conversion › should warn about unsupported assistant content types + #[tokio::test] + async fn should_not_warn_about_assistant_content_types() { + let server = MockServer::start().await; + stub_empty_response(&server).await; + + let provider = make_provider(&server); + let model = provider.responses("deepseek-ai/DeepSeek-V3-0324"); + + let prompt = vec![LanguageModelMessage::Assistant { + content: vec![ + AssistantPart::ToolCall(ToolCallPart { + tool_call_id: "test".into(), + tool_name: "test".into(), + input: json!({}), + provider_executed: None, + provider_options: None, + }), + AssistantPart::ToolResult(ToolResultPart { + tool_call_id: "test".into(), + tool_name: "test".into(), + output: ToolResultOutput::Text { + value: "test".into(), + provider_options: None, + }, + + provider_options: None, + }), + AssistantPart::Reasoning(ReasoningPart { + text: "thinking...".into(), + + provider_options: None, + }), + ], + provider_options: None, + }]; + + let result = model + .do_generate(&default_options(prompt)) + .await + .expect("ok"); + + // TS expects no warnings (tool calls/results/reasoning are silently + // skipped, reasoning is included as output_text). + assert!( + result.warnings.is_empty(), + "expected no warnings, got: {:?}", + result.warnings + ); + } + } + + mod structured_output { + use super::*; + + /// upstream: @ai-sdk/huggingface src/responses/huggingface-responses-language-model.test.ts › HuggingFaceResponsesLanguageModel › structured output › should handle structured output with custom name and description + #[tokio::test] + async fn should_handle_structured_output_with_custom_name_and_description() { + let server = MockServer::start().await; + mock_json( + &server, + json!({ + "id": "resp_structured", + "model": "moonshotai/Kimi-K2-Instruct", + "object": "response", + "created_at": 1741257730, + "status": "completed", + "error": null, + "instructions": null, + "max_output_tokens": null, + "metadata": null, + "tool_choice": "auto", + "tools": [], + "temperature": 1.0, + "top_p": 1.0, + "incomplete_details": null, + "usage": null, + "output": [], + "output_text": "{}" + }), + ) + .await; + + let provider = make_provider(&server); + let model = provider.responses("moonshotai/Kimi-K2-Instruct"); + + let mut options = default_options(test_prompt()); + options.response_format = Some(ResponseFormat::Json { + schema: Some( + json!({ "type": "object", "properties": { "name": { "type": "string" } } }), + ), + name: Some("person_profile".to_string()), + description: Some("A person profile with basic information".to_string()), + }); + + let result = model.do_generate(&options).await.expect("should succeed"); + let body = result + .request + .and_then(|request| request.body) + .expect("body"); + + assert_eq!(body["text"]["format"]["name"], json!("person_profile")); + assert_eq!( + body["text"]["format"]["description"], + json!("A person profile with basic information") + ); + } + } + + mod provider_options { + use super::*; + + /// upstream: @ai-sdk/huggingface src/responses/huggingface-responses-language-model.test.ts › HuggingFaceResponsesLanguageModel › provider options › should send provider-specific options + #[tokio::test] + async fn should_send_provider_specific_options() { + let server = MockServer::start().await; + mock_json( + &server, + json!({ + "id": "resp_provider_options", + "model": "deepseek-ai/DeepSeek-V3-0324", + "object": "response", + "created_at": 1741257730, + "status": "completed", + "error": null, + "instructions": null, + "max_output_tokens": null, + "metadata": null, + "tool_choice": "auto", + "tools": [], + "temperature": 1.0, + "top_p": 1.0, + "incomplete_details": null, + "usage": null, + "output": [], + "output_text": "Test" + }), + ) + .await; + + let provider = make_provider(&server); + let model = provider.responses("deepseek-ai/DeepSeek-V3-0324"); + + let mut options = default_options(test_prompt()); + let mut po = aimux_core::shared::SharedProviderOptions::new(); + po.insert( + "huggingface".to_string(), + serde_json::from_value(json!({ + "metadata": { "key": "value" }, + "instructions": "Be concise", + "strictJsonSchema": true + })) + .unwrap(), + ); + options.provider_options = Some(po); + options.response_format = Some(ResponseFormat::Json { + schema: Some(json!({ "type": "object" })), + name: None, + description: None, + }); + + let result = model.do_generate(&options).await.expect("should succeed"); + let body = result + .request + .and_then(|request| request.body) + .expect("body"); + + assert_eq!(body["metadata"], json!({ "key": "value" })); + assert_eq!(body["instructions"], json!("Be concise")); + assert_eq!(body["text"]["format"]["strict"], json!(true)); + } + } + + mod tool_preparation { + use super::*; + + /// upstream: @ai-sdk/huggingface src/responses/huggingface-responses-language-model.test.ts › HuggingFaceResponsesLanguageModel › tool preparation › should prepare tools correctly + #[tokio::test] + async fn should_prepare_tools_correctly() { + let server = MockServer::start().await; + mock_json( + &server, + json!({ + "id": "resp_tools", + "model": "deepseek-ai/DeepSeek-V3-0324", + "object": "response", + "created_at": 1741257730, + "status": "completed", + "error": null, + "instructions": null, + "max_output_tokens": null, + "metadata": null, + "tool_choice": "auto", + "tools": [], + "temperature": 1.0, + "top_p": 1.0, + "incomplete_details": null, + "usage": null, + "output": [], + "output_text": "Test" + }), + ) + .await; + + let provider = make_provider(&server); + let model = provider.responses("deepseek-ai/DeepSeek-V3-0324"); + + let mut options = default_options(test_prompt()); + options.tools = Some(vec![Tool::from( + FunctionTool::new( + "getWeather", + json!({ + "type": "object", + "properties": { "location": { "type": "string" } }, + "required": ["location"] + }), + ) + .with_description("Get weather information"), + )]); + options.tool_choice = Some(LanguageModelV4ToolChoice::Tool { + tool_name: "getWeather".to_string(), + }); + + let result = model.do_generate(&options).await.expect("should succeed"); + let body = result + .request + .and_then(|request| request.body) + .expect("body"); + + assert_eq!( + body["tools"], + json!([{ + "type": "function", + "name": "getWeather", + "description": "Get weather information", + "parameters": { + "type": "object", + "properties": { "location": { "type": "string" } }, + "required": ["location"] + } + }]) + ); + assert_eq!( + body["tool_choice"], + json!({ "type": "function", "function": { "name": "getWeather" } }) + ); + } + + /// upstream: @ai-sdk/huggingface src/responses/huggingface-responses-language-model.test.ts › HuggingFaceResponsesLanguageModel › tool preparation › should handle auto and required tool choices + #[tokio::test] + async fn should_handle_auto_and_required_tool_choices() { + let server = MockServer::start().await; + mock_json( + &server, + json!({ + "id": "resp_tools", + "model": "deepseek-ai/DeepSeek-V3-0324", + "object": "response", + "created_at": 1741257730, + "status": "completed", + "error": null, + "instructions": null, + "max_output_tokens": null, + "metadata": null, + "tool_choice": "auto", + "tools": [], + "temperature": 1.0, + "top_p": 1.0, + "incomplete_details": null, + "usage": null, + "output": [], + "output_text": "Test" + }), + ) + .await; + + let provider = make_provider(&server); + let model = provider.responses("deepseek-ai/DeepSeek-V3-0324"); + + // Test auto + let mut options = default_options(test_prompt()); + options.tools = Some(vec![Tool::from(FunctionTool::new( + "test", + json!({ "type": "object" }), + ))]); + options.tool_choice = Some(LanguageModelV4ToolChoice::Auto); + + let result = model.do_generate(&options).await.expect("should succeed"); + let body = result + .request + .and_then(|request| request.body) + .expect("body"); + assert_eq!(body["tool_choice"], json!("auto")); + + // Test required + let mut options = default_options(test_prompt()); + options.tools = Some(vec![Tool::from(FunctionTool::new( + "test", + json!({ "type": "object" }), + ))]); + options.tool_choice = Some(LanguageModelV4ToolChoice::Required); + + let result = model.do_generate(&options).await.expect("should succeed"); + let body = result + .request + .and_then(|request| request.body) + .expect("body"); + assert_eq!(body["tool_choice"], json!("required")); + } + } + + mod top_level_only_media_type_resolution { + use super::*; + + /// upstream: @ai-sdk/huggingface src/responses/huggingface-responses-language-model.test.ts › HuggingFaceResponsesLanguageModel › top-level-only media type resolution › passes full image/png through unchanged for inline data + #[test] + fn passes_full_image_png_through_unchanged_for_inline_data() { + let prompt = vec![LanguageModelMessage::User { + content: vec![UserPart::File(FilePart { + data: FileData::Data { + data: FileBytes::Base64(PNG_BASE64.into()), + }, + media_type: "image/png".into(), + filename: None, + provider_options: None, + })], + provider_options: None, + }]; + + let (input, warnings) = + convert_to_huggingface_responses_messages(&prompt).expect("should succeed"); + + assert!(warnings.is_empty()); + let content = &input[0]["content"][0]; + assert_eq!( + content, + &json!({ + "type": "input_image", + "image_url": format!("data:image/png;base64,{}", PNG_BASE64) + }) + ); + } + + /// upstream: @ai-sdk/huggingface src/responses/huggingface-responses-language-model.test.ts › HuggingFaceResponsesLanguageModel › top-level-only media type resolution › detects image subtype from inline bytes for top-level "image" + #[test] + fn detects_image_subtype_from_inline_bytes_for_top_level_image() { + let prompt = vec![LanguageModelMessage::User { + content: vec![UserPart::File(FilePart { + data: FileData::Data { + data: FileBytes::Base64(PNG_BASE64.into()), + }, + media_type: "image".into(), + filename: None, + provider_options: None, + })], + provider_options: None, + }]; + + let (input, warnings) = + convert_to_huggingface_responses_messages(&prompt).expect("should succeed"); -/// TS: doGenerate › basic text response › "should generate text" + assert!(warnings.is_empty()); + let content = &input[0]["content"][0]; + assert_eq!( + content, + &json!({ + "type": "input_image", + "image_url": format!("data:image/png;base64,{}", PNG_BASE64) + }) + ); + } + + /// upstream: @ai-sdk/huggingface src/responses/huggingface-responses-language-model.test.ts › HuggingFaceResponsesLanguageModel › top-level-only media type resolution › passes through URL source for top-level-only image + #[test] + fn passes_through_url_source_for_top_level_only_image() { + let prompt = vec![LanguageModelMessage::User { + content: vec![UserPart::File(FilePart { + data: FileData::Url { + original_url: None, + url: "https://example.com/x.png".into(), + }, + media_type: "image".into(), + filename: None, + provider_options: None, + })], + provider_options: None, + }]; + + let (input, warnings) = + convert_to_huggingface_responses_messages(&prompt).expect("should succeed"); + + assert!(warnings.is_empty()); + let content = &input[0]["content"][0]; + assert_eq!( + content, + &json!({ + "type": "input_image", + "image_url": "https://example.com/x.png" + }) + ); + } + + /// upstream: @ai-sdk/huggingface src/responses/huggingface-responses-language-model.test.ts › HuggingFaceResponsesLanguageModel › top-level-only media type resolution › normalizes image/* wildcard via detection + #[test] + fn normalizes_image_wildcard_via_detection() { + let prompt = vec![LanguageModelMessage::User { + content: vec![UserPart::File(FilePart { + data: FileData::Data { + data: FileBytes::Base64(PNG_BASE64.into()), + }, + media_type: "image/*".into(), + filename: None, + provider_options: None, + })], + provider_options: None, + }]; + + let (input, warnings) = + convert_to_huggingface_responses_messages(&prompt).expect("should succeed"); + + assert!(warnings.is_empty()); + let content = &input[0]["content"][0]; + assert_eq!( + content, + &json!({ + "type": "input_image", + "image_url": format!("data:image/png;base64,{}", PNG_BASE64) + }) + ); + } + } +} + +/// Partial port of upstream `should generate text`: checks the text but not +/// the providerMetadata itemId. Full case: src/huggingface/tests/responses/responses_language_model.rs. #[tokio::test] async fn should_generate_text() { let server = MockServer::start().await; @@ -180,7 +777,8 @@ async fn should_generate_text() { } } -/// TS: doGenerate › basic text response › "should extract usage" +/// Partial port of upstream `should extract usage`: checks the token counts +/// but not the raw usage object. Full case: src/huggingface/tests/responses/responses_language_model.rs. #[tokio::test] async fn should_extract_usage() { let server = MockServer::start().await; @@ -204,8 +802,8 @@ async fn should_extract_usage() { assert_eq!(result.usage.output_tokens.reasoning, Some(0)); } -/// TS: doGenerate › basic text response › "should extract text from output -/// array when output_text is missing" +/// Partial port of upstream `should extract text from output array when +/// output_text is missing`: checks the text but not the providerMetadata itemId. #[tokio::test] async fn should_extract_text_from_output_array_when_output_text_missing() { let server = MockServer::start().await; @@ -260,8 +858,8 @@ async fn should_extract_text_from_output_array_when_output_text_missing() { } } -/// TS: doGenerate › basic text response › "should handle missing usage -/// gracefully" +/// Partial port of upstream `should handle missing usage gracefully`: checks +/// the token counts but not the raw usage (undefined upstream). #[tokio::test] async fn should_handle_missing_usage_gracefully() { let server = MockServer::start().await; @@ -307,8 +905,8 @@ async fn should_handle_missing_usage_gracefully() { assert_eq!(result.usage.output_tokens.reasoning, None); } -/// TS: doGenerate › basic text response › "should send model id, settings, and -/// input" +/// Partial port of upstream `should send model id, settings, and input`: +/// checks each body field, not the whole body (upstream uses toStrictEqual). #[tokio::test] async fn should_send_model_id_settings_and_input() { let server = MockServer::start().await; @@ -349,8 +947,8 @@ async fn should_send_model_id_settings_and_input() { ); } -/// TS: doGenerate › basic text response › "should handle unsupported settings -/// with warnings" +/// Partial port of upstream `should handle unsupported settings with warnings`: +/// compares only the feature names of Unsupported warnings, not the array. #[tokio::test] async fn should_handle_unsupported_settings_with_warnings() { let server = MockServer::start().await; @@ -389,8 +987,8 @@ async fn should_handle_unsupported_settings_with_warnings() { ); } -/// TS: doGenerate › basic text response › "should generate text and sources -/// from annotations" +/// Different input from upstream `should generate text and sources from +/// annotations` (shorter text, no markdown links). Upstream case: src/huggingface/tests/responses/responses_language_model.rs. #[tokio::test] async fn should_generate_text_and_sources_from_annotations() { let server = MockServer::start().await; @@ -469,8 +1067,8 @@ async fn should_generate_text_and_sources_from_annotations() { } } -/// TS: doGenerate › basic text response › "should handle MCP tools with -/// annotations" +/// Different input from upstream `should handle MCP tools with annotations` +/// (shorter text). Upstream case: src/huggingface/tests/responses/responses_language_model.rs. #[tokio::test] async fn should_handle_mcp_tools_with_annotations() { let server = MockServer::start().await; @@ -586,11 +1184,8 @@ async fn should_handle_mcp_tools_with_annotations() { assert_eq!(call.input, json!({ "query": "San Francisco tech events" })); } -// ════════════════════════════════════════════════════════════════════════════ -// doStream -// ════════════════════════════════════════════════════════════════════════════ - -/// TS: doStream › "should stream text deltas" +/// Partial port of upstream `should stream text deltas`: checks part kinds, +/// ids, deltas and token counts, not the timestamp, metadata or raw usage. #[tokio::test] async fn should_stream_text_deltas() { let server = MockServer::start().await; @@ -680,7 +1275,8 @@ async fn should_stream_text_deltas() { } } -/// TS: doStream › "should handle streaming without usage" +/// Partial port of upstream `should handle streaming without usage`: checks +/// only the total token counts, not every usage field. #[tokio::test] async fn should_handle_streaming_without_usage() { let server = MockServer::start().await; @@ -723,47 +1319,9 @@ async fn should_handle_streaming_without_usage() { } } -/// TS: doStream › "should handle non-message item types" -#[tokio::test] -async fn should_handle_non_message_item_types() { - let server = MockServer::start().await; - let chunks = sse_body(&[ - sse_event( - r#"{"type":"response.output_item.added","output_index":0,"item":{"id":"mcp_test","type":"mcp_list_tools","server_label":"test"},"sequence_number":1}"#, - ), - sse_event( - r#"{"type":"response.output_item.done","output_index":0,"item":{"id":"mcp_test","type":"mcp_list_tools","server_label":"test"},"sequence_number":2}"#, - ), - sse_event( - r#"{"type":"response.completed","response":{"id":"resp_test","status":"completed","incomplete_details":null},"sequence_number":3}"#, - ), - ]); - mock_sse(&server, chunks).await; - - let provider = make_provider(&server); - let model = provider.responses("deepseek-ai/DeepSeek-V3-0324"); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("should succeed"); - - let parts = collect_stream(result).await; - - // Should only have stream-start and finish events (no text events for - // non-message items). - let types: Vec<&str> = parts - .iter() - .map(|p| match p { - StreamPart::StreamStart { .. } => "stream-start", - StreamPart::Finish { .. } => "finish", - _ => "other", - }) - .collect(); - assert_eq!(types, vec!["stream-start", "finish"]); -} - -/// TS: doStream › "should handle streaming errors" +/// aimux's own: a malformed frame followed by more events. Asserts the stream +/// keeps going and ends with a Stop finish. Upstream's case ends with an error +/// finish; full case: src/huggingface/tests/responses/responses_language_model.rs. #[tokio::test] async fn should_handle_streaming_errors() { let server = MockServer::start().await; @@ -813,7 +1371,8 @@ async fn should_handle_streaming_errors() { ))); } -/// TS: doStream › "should send correct streaming request" +/// Partial port of upstream `should send correct streaming request`: checks +/// each body field, not the whole body (upstream uses toStrictEqual). #[tokio::test] async fn should_send_correct_streaming_request() { let server = MockServer::start().await; @@ -843,85 +1402,8 @@ async fn should_send_correct_streaming_request() { ); } -// ════════════════════════════════════════════════════════════════════════════ -// Message conversion -// ════════════════════════════════════════════════════════════════════════════ - -/// Helper: mount a stub response so do_generate succeeds and we can inspect -/// the request body. -async fn stub_empty_response(server: &MockServer) { - mock_json( - server, - json!({ - "id": "resp_test", - "model": "moonshotai/Kimi-K2-Instruct", - "object": "response", - "created_at": 1741257730, - "status": "completed", - "error": null, - "instructions": null, - "max_output_tokens": null, - "metadata": null, - "tool_choice": "auto", - "tools": [], - "temperature": 1.0, - "top_p": 1.0, - "incomplete_details": null, - "usage": null, - "output": [], - "output_text": "Test response" - }), - ) - .await; -} - -/// TS: message conversion › "should convert user messages with images" -#[tokio::test] -async fn should_convert_user_messages_with_images() { - let server = MockServer::start().await; - stub_empty_response(&server).await; - - let provider = make_provider(&server); - let model = provider.responses("deepseek-ai/DeepSeek-V3-0324"); - - let prompt = vec![LanguageModelMessage::User { - content: vec![ - UserPart::Text(TextPart { - text: "What do you see?".into(), - provider_options: None, - }), - UserPart::File(FilePart { - data: FileData::Data { - data: FileBytes::Base64("AQIDBA==".into()), - }, - media_type: "image/jpeg".into(), - filename: None, - provider_options: None, - }), - ], - provider_options: None, - }]; - - let result = model - .do_generate(&default_options(prompt)) - .await - .expect("ok"); - let body = result - .request - .and_then(|request| request.body) - .expect("body"); - - assert_eq!( - body["input"][0]["content"], - json!([ - { "text": "What do you see?", "type": "input_text" }, - { "image_url": "data:image/jpeg;base64,AQIDBA==", "type": "input_image" } - ]) - ); -} - -/// TS: message conversion › "should throw for file parts with provider -/// references" +/// Partial port of upstream `should throw for file parts with provider +/// references`: checks the error and the feature name, not the full message. #[tokio::test] async fn should_throw_for_file_parts_with_provider_references() { let server = MockServer::start().await; @@ -953,100 +1435,8 @@ async fn should_throw_for_file_parts_with_provider_references() { ); } -/// TS: message conversion › "should handle assistant messages" -#[tokio::test] -async fn should_handle_assistant_messages() { - let server = MockServer::start().await; - stub_empty_response(&server).await; - - let provider = make_provider(&server); - let model = provider.responses("deepseek-ai/DeepSeek-V3-0324"); - - let prompt = vec![ - LanguageModelMessage::user_text("Hello"), - LanguageModelMessage::Assistant { - content: vec![AssistantPart::Text(TextPart { - text: "Hi there!".into(), - provider_options: None, - })], - provider_options: None, - }, - LanguageModelMessage::user_text("How are you?"), - ]; - - let result = model - .do_generate(&default_options(prompt)) - .await - .expect("ok"); - let body = result - .request - .and_then(|request| request.body) - .expect("body"); - - assert_eq!( - body["input"], - json!([ - { "content": [{ "text": "Hello", "type": "input_text" }], "role": "user" }, - { "content": [{ "text": "Hi there!", "type": "output_text" }], "role": "assistant" }, - { "content": [{ "text": "How are you?", "type": "input_text" }], "role": "user" } - ]) - ); -} - -/// TS: message conversion › "should warn about unsupported assistant content -/// types" — tool-call, tool-result, and reasoning in assistant messages -/// produce NO warnings (they are silently skipped). -#[tokio::test] -async fn should_not_warn_about_assistant_content_types() { - let server = MockServer::start().await; - stub_empty_response(&server).await; - - let provider = make_provider(&server); - let model = provider.responses("deepseek-ai/DeepSeek-V3-0324"); - - let prompt = vec![LanguageModelMessage::Assistant { - content: vec![ - AssistantPart::ToolCall(ToolCallPart { - tool_call_id: "test".into(), - tool_name: "test".into(), - input: json!({}), - provider_executed: None, - provider_options: None, - }), - AssistantPart::ToolResult(ToolResultPart { - tool_call_id: "test".into(), - tool_name: "test".into(), - output: ToolResultOutput::Text { - value: "test".into(), - provider_options: None, - }, - - provider_options: None, - }), - AssistantPart::Reasoning(ReasoningPart { - text: "thinking...".into(), - - provider_options: None, - }), - ], - provider_options: None, - }]; - - let result = model - .do_generate(&default_options(prompt)) - .await - .expect("ok"); - - // TS expects no warnings (tool calls/results/reasoning are silently - // skipped, reasoning is included as output_text). - assert!( - result.warnings.is_empty(), - "expected no warnings, got: {:?}", - result.warnings - ); -} - -/// TS: message conversion › "should warn about tool messages" +/// Partial port of upstream `should warn about tool messages`: compares only +/// the Unsupported feature names, not the warnings array. #[tokio::test] async fn should_warn_about_tool_messages() { let server = MockServer::start().await; @@ -1086,11 +1476,9 @@ async fn should_warn_about_tool_messages() { assert_eq!(features, vec!["tool messages"]); } -// ════════════════════════════════════════════════════════════════════════════ -// Tool calls -// ════════════════════════════════════════════════════════════════════════════ - -/// TS: tool calls › "should handle function_call tool responses" +/// Not the upstream expectation: for a function_call with an output, asserts +/// two content items and no tool-result. Upstream expects a tool-result too; +/// full case: src/huggingface/tests/responses/responses_language_model.rs. #[tokio::test] async fn should_handle_function_call_tool_responses() { let server = MockServer::start().await; @@ -1168,7 +1556,8 @@ async fn should_handle_function_call_tool_responses() { } } -/// TS: tool calls › "should stream tool calls" +/// Partial port of upstream `should stream tool calls`: checks ids, names, +/// input and token counts, not the tool-result name, timestamp or metadata. #[tokio::test] async fn should_stream_tool_calls() { let server = MockServer::start().await; @@ -1270,11 +1659,8 @@ async fn should_stream_tool_calls() { } } -// ════════════════════════════════════════════════════════════════════════════ -// Structured output -// ════════════════════════════════════════════════════════════════════════════ - -/// TS: structured output › "should send text.format for structured output" +/// Partial port of upstream `should send text.format for structured output`: +/// checks text.format, not the whole text object. #[tokio::test] async fn should_send_text_format_for_structured_output() { let server = MockServer::start().await; @@ -1358,63 +1744,8 @@ async fn should_send_text_format_for_structured_output() { ); } -/// TS: structured output › "should handle structured output with custom name -/// and description" -#[tokio::test] -async fn should_handle_structured_output_with_custom_name_and_description() { - let server = MockServer::start().await; - mock_json( - &server, - json!({ - "id": "resp_structured", - "model": "moonshotai/Kimi-K2-Instruct", - "object": "response", - "created_at": 1741257730, - "status": "completed", - "error": null, - "instructions": null, - "max_output_tokens": null, - "metadata": null, - "tool_choice": "auto", - "tools": [], - "temperature": 1.0, - "top_p": 1.0, - "incomplete_details": null, - "usage": null, - "output": [], - "output_text": "{}" - }), - ) - .await; - - let provider = make_provider(&server); - let model = provider.responses("moonshotai/Kimi-K2-Instruct"); - - let mut options = default_options(test_prompt()); - options.response_format = Some(ResponseFormat::Json { - schema: Some(json!({ "type": "object", "properties": { "name": { "type": "string" } } })), - name: Some("person_profile".to_string()), - description: Some("A person profile with basic information".to_string()), - }); - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result - .request - .and_then(|request| request.body) - .expect("body"); - - assert_eq!(body["text"]["format"]["name"], json!("person_profile")); - assert_eq!( - body["text"]["format"]["description"], - json!("A person profile with basic information") - ); -} - -// ════════════════════════════════════════════════════════════════════════════ -// Reasoning -// ════════════════════════════════════════════════════════════════════════════ - -/// TS: reasoning › "should handle reasoning content in responses" +/// Partial port of upstream `should handle reasoning content in responses`: +/// the text part's providerMetadata itemId is not checked. #[tokio::test] async fn should_handle_reasoning_content_in_responses() { let server = MockServer::start().await; @@ -1493,7 +1824,8 @@ async fn should_handle_reasoning_content_in_responses() { } } -/// TS: reasoning › "should stream reasoning content" +/// Partial port of upstream `should stream reasoning content`: checks part +/// kinds, ids and deltas, not the timestamp, text-start or finish metadata. #[tokio::test] async fn should_stream_reasoning_content() { let server = MockServer::start().await; @@ -1632,333 +1964,9 @@ async fn should_stream_reasoning_content() { } } -// ════════════════════════════════════════════════════════════════════════════ -// Provider options -// ════════════════════════════════════════════════════════════════════════════ - -/// TS: provider options › "should send provider-specific options" -#[tokio::test] -async fn should_send_provider_specific_options() { - let server = MockServer::start().await; - mock_json( - &server, - json!({ - "id": "resp_provider_options", - "model": "deepseek-ai/DeepSeek-V3-0324", - "object": "response", - "created_at": 1741257730, - "status": "completed", - "error": null, - "instructions": null, - "max_output_tokens": null, - "metadata": null, - "tool_choice": "auto", - "tools": [], - "temperature": 1.0, - "top_p": 1.0, - "incomplete_details": null, - "usage": null, - "output": [], - "output_text": "Test" - }), - ) - .await; - - let provider = make_provider(&server); - let model = provider.responses("deepseek-ai/DeepSeek-V3-0324"); - - let mut options = default_options(test_prompt()); - let mut po = aimux_core::shared::SharedProviderOptions::new(); - po.insert( - "huggingface".to_string(), - serde_json::from_value(json!({ - "metadata": { "key": "value" }, - "instructions": "Be concise", - "strictJsonSchema": true - })) - .unwrap(), - ); - options.provider_options = Some(po); - options.response_format = Some(ResponseFormat::Json { - schema: Some(json!({ "type": "object" })), - name: None, - description: None, - }); - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result - .request - .and_then(|request| request.body) - .expect("body"); - - assert_eq!(body["metadata"], json!({ "key": "value" })); - assert_eq!(body["instructions"], json!("Be concise")); - assert_eq!(body["text"]["format"]["strict"], json!(true)); -} - -// ════════════════════════════════════════════════════════════════════════════ -// Tool preparation -// ════════════════════════════════════════════════════════════════════════════ - -/// TS: tool preparation › "should prepare tools correctly" -#[tokio::test] -async fn should_prepare_tools_correctly() { - let server = MockServer::start().await; - mock_json( - &server, - json!({ - "id": "resp_tools", - "model": "deepseek-ai/DeepSeek-V3-0324", - "object": "response", - "created_at": 1741257730, - "status": "completed", - "error": null, - "instructions": null, - "max_output_tokens": null, - "metadata": null, - "tool_choice": "auto", - "tools": [], - "temperature": 1.0, - "top_p": 1.0, - "incomplete_details": null, - "usage": null, - "output": [], - "output_text": "Test" - }), - ) - .await; - - let provider = make_provider(&server); - let model = provider.responses("deepseek-ai/DeepSeek-V3-0324"); - - let mut options = default_options(test_prompt()); - options.tools = Some(vec![Tool::from( - FunctionTool::new( - "getWeather", - json!({ - "type": "object", - "properties": { "location": { "type": "string" } }, - "required": ["location"] - }), - ) - .with_description("Get weather information"), - )]); - options.tool_choice = Some(LanguageModelV4ToolChoice::Tool { - tool_name: "getWeather".to_string(), - }); - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result - .request - .and_then(|request| request.body) - .expect("body"); - - assert_eq!( - body["tools"], - json!([{ - "type": "function", - "name": "getWeather", - "description": "Get weather information", - "parameters": { - "type": "object", - "properties": { "location": { "type": "string" } }, - "required": ["location"] - } - }]) - ); - assert_eq!( - body["tool_choice"], - json!({ "type": "function", "function": { "name": "getWeather" } }) - ); -} - -/// TS: tool preparation › "should handle auto and required tool choices" -#[tokio::test] -async fn should_handle_auto_and_required_tool_choices() { - let server = MockServer::start().await; - mock_json( - &server, - json!({ - "id": "resp_tools", - "model": "deepseek-ai/DeepSeek-V3-0324", - "object": "response", - "created_at": 1741257730, - "status": "completed", - "error": null, - "instructions": null, - "max_output_tokens": null, - "metadata": null, - "tool_choice": "auto", - "tools": [], - "temperature": 1.0, - "top_p": 1.0, - "incomplete_details": null, - "usage": null, - "output": [], - "output_text": "Test" - }), - ) - .await; - - let provider = make_provider(&server); - let model = provider.responses("deepseek-ai/DeepSeek-V3-0324"); - - // Test auto - let mut options = default_options(test_prompt()); - options.tools = Some(vec![Tool::from(FunctionTool::new( - "test", - json!({ "type": "object" }), - ))]); - options.tool_choice = Some(LanguageModelV4ToolChoice::Auto); - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result - .request - .and_then(|request| request.body) - .expect("body"); - assert_eq!(body["tool_choice"], json!("auto")); - - // Test required - let mut options = default_options(test_prompt()); - options.tools = Some(vec![Tool::from(FunctionTool::new( - "test", - json!({ "type": "object" }), - ))]); - options.tool_choice = Some(LanguageModelV4ToolChoice::Required); - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result - .request - .and_then(|request| request.body) - .expect("body"); - assert_eq!(body["tool_choice"], json!("required")); -} - -// ════════════════════════════════════════════════════════════════════════════ -// Top-level-only media type resolution -// ════════════════════════════════════════════════════════════════════════════ - -const PNG_BASE64: &str = "iVBORw0KGgo="; - -/// TS: "passes full image/png through unchanged for inline data" -#[test] -fn passes_full_image_png_through_unchanged_for_inline_data() { - let prompt = vec![LanguageModelMessage::User { - content: vec![UserPart::File(FilePart { - data: FileData::Data { - data: FileBytes::Base64(PNG_BASE64.into()), - }, - media_type: "image/png".into(), - filename: None, - provider_options: None, - })], - provider_options: None, - }]; - - let (input, warnings) = - convert_to_huggingface_responses_messages(&prompt).expect("should succeed"); - - assert!(warnings.is_empty()); - let content = &input[0]["content"][0]; - assert_eq!( - content, - &json!({ - "type": "input_image", - "image_url": format!("data:image/png;base64,{}", PNG_BASE64) - }) - ); -} - -/// TS: "detects image subtype from inline bytes for top-level 'image'" -#[test] -fn detects_image_subtype_from_inline_bytes_for_top_level_image() { - let prompt = vec![LanguageModelMessage::User { - content: vec![UserPart::File(FilePart { - data: FileData::Data { - data: FileBytes::Base64(PNG_BASE64.into()), - }, - media_type: "image".into(), - filename: None, - provider_options: None, - })], - provider_options: None, - }]; - - let (input, warnings) = - convert_to_huggingface_responses_messages(&prompt).expect("should succeed"); - - assert!(warnings.is_empty()); - let content = &input[0]["content"][0]; - assert_eq!( - content, - &json!({ - "type": "input_image", - "image_url": format!("data:image/png;base64,{}", PNG_BASE64) - }) - ); -} - -/// TS: "passes through URL source for top-level-only image" -#[test] -fn passes_through_url_source_for_top_level_only_image() { - let prompt = vec![LanguageModelMessage::User { - content: vec![UserPart::File(FilePart { - data: FileData::Url { - original_url: None, - url: "https://example.com/x.png".into(), - }, - media_type: "image".into(), - filename: None, - provider_options: None, - })], - provider_options: None, - }]; - - let (input, warnings) = - convert_to_huggingface_responses_messages(&prompt).expect("should succeed"); - - assert!(warnings.is_empty()); - let content = &input[0]["content"][0]; - assert_eq!( - content, - &json!({ - "type": "input_image", - "image_url": "https://example.com/x.png" - }) - ); -} - -/// TS: "normalizes image/* wildcard via detection" -#[test] -fn normalizes_image_wildcard_via_detection() { - let prompt = vec![LanguageModelMessage::User { - content: vec![UserPart::File(FilePart { - data: FileData::Data { - data: FileBytes::Base64(PNG_BASE64.into()), - }, - media_type: "image/*".into(), - filename: None, - provider_options: None, - })], - provider_options: None, - }]; - - let (input, warnings) = - convert_to_huggingface_responses_messages(&prompt).expect("should succeed"); - - assert!(warnings.is_empty()); - let content = &input[0]["content"][0]; - assert_eq!( - content, - &json!({ - "type": "input_image", - "image_url": format!("data:image/png;base64,{}", PNG_BASE64) - }) - ); -} - -/// TS: `supportedUrls` (huggingface-responses-language-model.ts:67-69): image -/// URLs go to the API as they are; other URLs are downloaded by the operation. +/// aimux's own: supported_urls accepts HTTP(S) image URLs and no others. It +/// mirrors supportedUrls (huggingface-responses-language-model.ts:67-69); no +/// upstream test case covers it. #[tokio::test] async fn supports_http_image_urls() { let server = MockServer::start().await; From 33bc2d5bbad7616ea6abd667bdf62b1c33c01646 Mon Sep 17 00:00:00 2001 From: cunninghamcard-bit Date: Tue, 20 Oct 2026 06:19:56 +0000 Subject: [PATCH 02/10] test(huggingface): port the remaining upstream tests The upstream cases the old tests do not cover in full go to src/huggingface/tests/, one file per upstream test file (provider.rs, responses/responses_language_model.rs). The shared mock fetch is reached through src/test_support/. The boundary script skips files under tests/ dirs as it skips #[cfg(test)] blocks. Three cases fail here and are fixed in the next commits: MCP calls are marked dynamic, a function_call with an output has no tool-result, and a malformed stream chunk does not make the finish reason error. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01RoxcKU4zBD64Lri3e4Lrq5 --- aimux-providers/src/huggingface.rs | 3 + aimux-providers/src/huggingface/tests/mod.rs | 4 + .../src/huggingface/tests/provider.rs | 81 ++ .../src/huggingface/tests/responses/mod.rs | 3 + .../responses/responses_language_model.rs | 1069 +++++++++++++++++ aimux-providers/src/lib.rs | 3 + aimux-providers/src/test_support/mod.rs | 4 + scripts/check_provider_boundaries.sh | 4 +- 8 files changed, 1169 insertions(+), 2 deletions(-) create mode 100644 aimux-providers/src/huggingface/tests/mod.rs create mode 100644 aimux-providers/src/huggingface/tests/provider.rs create mode 100644 aimux-providers/src/huggingface/tests/responses/mod.rs create mode 100644 aimux-providers/src/huggingface/tests/responses/responses_language_model.rs create mode 100644 aimux-providers/src/test_support/mod.rs diff --git a/aimux-providers/src/huggingface.rs b/aimux-providers/src/huggingface.rs index 5e1d82e74..7ee47b173 100644 --- a/aimux-providers/src/huggingface.rs +++ b/aimux-providers/src/huggingface.rs @@ -216,3 +216,6 @@ impl ProviderDiscovery for HuggingFaceProvider { Box::pin(async move { crate::openai::model::list_models_once(&config).await }) } } + +#[cfg(test)] +mod tests; diff --git a/aimux-providers/src/huggingface/tests/mod.rs b/aimux-providers/src/huggingface/tests/mod.rs new file mode 100644 index 000000000..257663319 --- /dev/null +++ b/aimux-providers/src/huggingface/tests/mod.rs @@ -0,0 +1,4 @@ +//! Ports of the `@ai-sdk/huggingface@2.0.62` test files. + +mod provider; +mod responses; diff --git a/aimux-providers/src/huggingface/tests/provider.rs b/aimux-providers/src/huggingface/tests/provider.rs new file mode 100644 index 000000000..776addc5e --- /dev/null +++ b/aimux-providers/src/huggingface/tests/provider.rs @@ -0,0 +1,81 @@ +//! Port of `src/huggingface-provider.test.ts`. +//! +//! Harness differences from the TypeScript (expectations are unchanged): +//! - `typeof provider === 'function'` and the `[Function]` snapshot are checked +//! by calling [`HuggingFaceProvider::call`], the Rust form of the callable +//! provider. +//! - `typeof provider.responses === 'function'` is checked by a typed binding: +//! the method exists and returns the Responses model. + +use std::collections::HashMap; +use std::sync::Arc; + +use crate::{HuggingFaceProviderSettings, HuggingFaceResponsesModel, create_huggingface}; +use aimux_core::language_model::LanguageModel; +use aimux_core::provider::Provider; + +mod hugging_face_provider { + use super::*; + + mod create_hugging_face { + use super::*; + + /// upstream: @ai-sdk/huggingface src/huggingface-provider.test.ts › HuggingFaceProvider › createHuggingFace › should create provider with default configuration + #[test] + fn should_create_provider_with_default_configuration() { + let provider = + create_huggingface(HuggingFaceProviderSettings::default()).expect("valid settings"); + + let _model: Arc = provider.call("any-model"); + let _responses: HuggingFaceResponsesModel = provider.responses("any-model"); + provider + .language_model("any-model") + .expect("language model"); + } + + /// upstream: @ai-sdk/huggingface src/huggingface-provider.test.ts › HuggingFaceProvider › createHuggingFace › should create provider with custom settings + #[test] + fn should_create_provider_with_custom_settings() { + let provider = create_huggingface(HuggingFaceProviderSettings { + api_key: Some("custom-key".to_string().into()), + base_url: Some("https://custom.url".to_string()), + headers: Some(HashMap::from([( + "Custom-Header".to_string(), + Some("test".to_string()), + )])), + ..Default::default() + }) + .expect("valid settings"); + + let _model: Arc = provider.call("any-model"); + let _responses: HuggingFaceResponsesModel = provider.responses("any-model"); + provider + .language_model("any-model") + .expect("language model"); + } + } + + mod model_creation_methods { + use super::*; + + /// upstream: @ai-sdk/huggingface src/huggingface-provider.test.ts › HuggingFaceProvider › model creation methods › should expose responses method + #[test] + fn should_expose_responses_method() { + let provider = + create_huggingface(HuggingFaceProviderSettings::default()).expect("valid settings"); + + let _responses: HuggingFaceResponsesModel = provider.responses("any-model"); + } + + /// upstream: @ai-sdk/huggingface src/huggingface-provider.test.ts › HuggingFaceProvider › model creation methods › should expose languageModel method + #[test] + fn should_expose_language_model_method() { + let provider = + create_huggingface(HuggingFaceProviderSettings::default()).expect("valid settings"); + + let _model: Arc = provider + .language_model("any-model") + .expect("language model"); + } + } +} diff --git a/aimux-providers/src/huggingface/tests/responses/mod.rs b/aimux-providers/src/huggingface/tests/responses/mod.rs new file mode 100644 index 000000000..73e80b767 --- /dev/null +++ b/aimux-providers/src/huggingface/tests/responses/mod.rs @@ -0,0 +1,3 @@ +//! Ports of `src/responses/huggingface-responses-language-model.test.ts`. + +mod responses_language_model; diff --git a/aimux-providers/src/huggingface/tests/responses/responses_language_model.rs b/aimux-providers/src/huggingface/tests/responses/responses_language_model.rs new file mode 100644 index 000000000..ceed206af --- /dev/null +++ b/aimux-providers/src/huggingface/tests/responses/responses_language_model.rs @@ -0,0 +1,1069 @@ +//! Port of `src/responses/huggingface-responses-language-model.test.ts` from +//! `@ai-sdk/huggingface@2.0.62`: the cases the Responses test file +//! (`tests/huggingface_responses_test.rs`) does not cover in full. +//! +//! Harness differences from the TypeScript (expectations are unchanged): +//! - `createTestServer` is the [`MockFetch`] transport. `server.calls[0]` is the +//! request body the transport recorded (`Seen::json_body`). +//! - `toMatchInlineSnapshot` values are JSON values in the assertions, with +//! `undefined` properties absent (as `JSON.stringify` drops them). +//! - `mockId()` is not needed: the Rust model numbers its sources `id-N` itself. + +use std::sync::Arc; + +use crate::{HuggingFaceProvider, HuggingFaceProviderSettings, create_huggingface}; +use aimux_core::language_model::LanguageModel; +use aimux_core::language_model_message::{ + LanguageModelMessage, LanguageModelPrompt, ToolPart, ToolResultOutput, ToolResultPart, +}; +use aimux_core::options::{CallOptions, ResponseFormat}; +use aimux_core::result::StreamResult; +use futures::StreamExt; +use serde_json::{Value, json}; + +use crate::test_support::mock_fetch::{Canned, MockFetch}; + +const MODEL: &str = "deepseek-ai/DeepSeek-V3-0324"; + +// ── helpers ───────────────────────────────────────────────────────────────── + +/// The TS `TEST_PROMPT`: one user text message "Hello". +fn test_prompt() -> LanguageModelPrompt { + vec![LanguageModelMessage::user_text("Hello")] +} + +/// `CallOptions` with only `prompt` set. +fn call_options(prompt: LanguageModelPrompt) -> CallOptions { + CallOptions::new(prompt) +} + +/// `createModel`: the Hugging Face provider with its default base URL, the +/// `Bearer APIKEY` header and the mock transport. +fn provider(fetch: &Arc) -> HuggingFaceProvider { + create_huggingface(HuggingFaceProviderSettings { + api_key: Some("APIKEY".to_string().into()), + fetch: Some(fetch.transport()), + ..Default::default() + }) + .expect("valid settings") +} + +/// One JSON response from `/responses`. +fn json_fetch(body: &Value) -> Arc { + MockFetch::new(vec![Canned::json(body)]) +} + +/// One event-stream response: each frame followed by a blank line. +fn sse_fetch(frames: &[impl AsRef]) -> Arc { + let body: String = frames + .iter() + .map(|frame| format!("{}\n\n", frame.as_ref())) + .collect(); + MockFetch::new(vec![Canned { + status: 200, + headers: vec![("content-type".into(), "text/event-stream".into())], + body: body.into_bytes(), + }]) +} + +/// Drain a stream into the JSON of each part (the TS `convertReadableStreamToArray`). +/// +/// An `Err` item is the stream's error chunk: `enqueue_error` surfaces the +/// AI SDK's `{ type: 'error', error }` part as `Err` and the stream carries on. +async fn stream_parts(result: StreamResult) -> Vec { + let mut stream = result.stream; + let mut parts = Vec::new(); + while let Some(item) = stream.next().await { + parts.push(match item { + Ok(part) => serde_json::to_value(part).expect("serializable part"), + Err(error) => json!({ "type": "error", "error": error.to_string() }), + }); + } + parts +} + +/// The `beforeEach` response of the `basic text response` describe. +fn basic_text_response() -> Value { + json!({ + "id": "resp_67c97c0203188190a025beb4a75242bc", + "model": "deepseek-ai/DeepSeek-V3-0324", + "object": "response", + "created_at": 1741257730, + "status": "completed", + "error": null, + "instructions": null, + "max_output_tokens": null, + "metadata": null, + "tool_choice": "auto", + "tools": [], + "temperature": 1.0, + "top_p": 1.0, + "incomplete_details": null, + "usage": { + "input_tokens": 12, + "output_tokens": 25, + "total_tokens": 37 + }, + "output": [ + { + "id": "msg_67c97c02656c81908e080dfdf4a03cd1", + "type": "message", + "role": "assistant", + "status": "completed", + "content": [ + { + "type": "output_text", + "text": "Hello! How can I help you today?" + } + ] + } + ], + "output_text": "Hello! How can I help you today?" + }) +} + +/// The `beforeEach` response of the `message conversion` describe. +fn message_conversion_response() -> Value { + json!({ + "id": "resp_test", + "model": "moonshotai/Kimi-K2-Instruct", + "object": "response", + "created_at": 1741257730, + "status": "completed", + "error": null, + "instructions": null, + "max_output_tokens": null, + "metadata": null, + "tool_choice": "auto", + "tools": [], + "temperature": 1.0, + "top_p": 1.0, + "incomplete_details": null, + "usage": null, + "output": [], + "output_text": "Test response" + }) +} + +// ───────────────────────────────────────────────────────────────────────────── + +mod hugging_face_responses_language_model { + use super::*; + + mod do_generate { + use super::*; + + mod basic_text_response { + use super::*; + + /// upstream: @ai-sdk/huggingface src/responses/huggingface-responses-language-model.test.ts › HuggingFaceResponsesLanguageModel › doGenerate › basic text response › should generate text + #[tokio::test] + async fn should_generate_text() { + let fetch = json_fetch(&basic_text_response()); + let result = provider(&fetch) + .responses(MODEL) + .do_generate(&call_options(test_prompt())) + .await + .expect("should succeed"); + + assert_eq!( + serde_json::to_value(&result.content).unwrap(), + json!([{ + "providerMetadata": { + "huggingface": { "itemId": "msg_67c97c02656c81908e080dfdf4a03cd1" } + }, + "text": "Hello! How can I help you today?", + "type": "text" + }]) + ); + } + + /// upstream: @ai-sdk/huggingface src/responses/huggingface-responses-language-model.test.ts › HuggingFaceResponsesLanguageModel › doGenerate › basic text response › should extract usage + #[tokio::test] + async fn should_extract_usage() { + let fetch = json_fetch(&basic_text_response()); + let result = provider(&fetch) + .responses(MODEL) + .do_generate(&call_options(test_prompt())) + .await + .expect("should succeed"); + + assert_eq!( + serde_json::to_value(&result.usage).unwrap(), + json!({ + "inputTokens": { "cacheRead": 0, "noCache": 12, "total": 12 }, + "outputTokens": { "reasoning": 0, "text": 25, "total": 25 }, + "raw": { "input_tokens": 12, "output_tokens": 25, "total_tokens": 37 } + }) + ); + } + + /// upstream: @ai-sdk/huggingface src/responses/huggingface-responses-language-model.test.ts › HuggingFaceResponsesLanguageModel › doGenerate › basic text response › should extract text from output array when output_text is missing + #[tokio::test] + async fn should_extract_text_from_output_array_when_output_text_missing() { + let fetch = json_fetch(&json!({ + "id": "resp_test", + "model": "deepseek-ai/DeepSeek-V3-0324", + "object": "response", + "created_at": 1741257730, + "status": "completed", + "error": null, + "instructions": null, + "max_output_tokens": null, + "metadata": null, + "tool_choice": "auto", + "tools": [], + "temperature": 1.0, + "top_p": 1.0, + "incomplete_details": null, + "usage": null, + "output": [ + { + "id": "msg_test", + "type": "message", + "role": "assistant", + "status": "completed", + "content": [ + { "type": "output_text", "text": "Extracted from output array" } + ] + } + ], + "output_text": null + })); + let result = provider(&fetch) + .responses(MODEL) + .do_generate(&call_options(test_prompt())) + .await + .expect("should succeed"); + + assert_eq!( + serde_json::to_value(&result.content).unwrap(), + json!([{ + "providerMetadata": { "huggingface": { "itemId": "msg_test" } }, + "text": "Extracted from output array", + "type": "text" + }]) + ); + } + + /// upstream: @ai-sdk/huggingface src/responses/huggingface-responses-language-model.test.ts › HuggingFaceResponsesLanguageModel › doGenerate › basic text response › should handle missing usage gracefully + #[tokio::test] + async fn should_handle_missing_usage_gracefully() { + let fetch = json_fetch(&json!({ + "id": "resp_test", + "model": "deepseek-ai/DeepSeek-V3-0324", + "object": "response", + "created_at": 1741257730, + "status": "completed", + "error": null, + "instructions": null, + "max_output_tokens": null, + "metadata": null, + "tool_choice": "auto", + "tools": [], + "temperature": 1.0, + "top_p": 1.0, + "incomplete_details": null, + "usage": null, + "output": [], + "output_text": "Test response" + })); + let result = provider(&fetch) + .responses(MODEL) + .do_generate(&call_options(test_prompt())) + .await + .expect("should succeed"); + + // Every usage field is undefined, so none is serialized. + assert_eq!( + serde_json::to_value(&result.usage).unwrap(), + json!({ "inputTokens": {}, "outputTokens": {} }) + ); + } + + /// upstream: @ai-sdk/huggingface src/responses/huggingface-responses-language-model.test.ts › HuggingFaceResponsesLanguageModel › doGenerate › basic text response › should send model id, settings, and input + #[tokio::test] + async fn should_send_model_id_settings_and_input() { + let fetch = json_fetch(&basic_text_response()); + let mut call = call_options(vec![ + LanguageModelMessage::System { + content: "You are a helpful assistant.".into(), + provider_options: None, + }, + LanguageModelMessage::user_text("Hello"), + ]); + call.temperature = Some(0.5); + call.top_p = Some(0.3); + call.max_output_tokens = Some(100); + + provider(&fetch) + .responses(MODEL) + .do_generate(&call) + .await + .expect("should succeed"); + + let seen = fetch.seen(); + assert_eq!( + seen[0].json_body(), + json!({ + "model": "deepseek-ai/DeepSeek-V3-0324", + "temperature": 0.5, + "top_p": 0.3, + "max_output_tokens": 100, + "stream": false, + "input": [ + { "role": "system", "content": "You are a helpful assistant." }, + { + "role": "user", + "content": [{ "type": "input_text", "text": "Hello" }] + } + ] + }) + ); + } + + /// upstream: @ai-sdk/huggingface src/responses/huggingface-responses-language-model.test.ts › HuggingFaceResponsesLanguageModel › doGenerate › basic text response › should handle unsupported settings with warnings + #[tokio::test] + async fn should_handle_unsupported_settings_with_warnings() { + let fetch = json_fetch(&basic_text_response()); + let mut call = call_options(test_prompt()); + call.top_k = Some(10.0); + call.seed = Some(123); + call.presence_penalty = Some(0.5); + call.frequency_penalty = Some(0.3); + call.stop_sequences = Some(vec!["stop".to_string()]); + + let result = provider(&fetch) + .responses(MODEL) + .do_generate(&call) + .await + .expect("should succeed"); + + assert_eq!( + serde_json::to_value(&result.warnings).unwrap(), + json!([ + { "feature": "topK", "type": "unsupported" }, + { "feature": "seed", "type": "unsupported" }, + { "feature": "presencePenalty", "type": "unsupported" }, + { "feature": "frequencyPenalty", "type": "unsupported" }, + { "feature": "stopSequences", "type": "unsupported" } + ]) + ); + } + + /// upstream: @ai-sdk/huggingface src/responses/huggingface-responses-language-model.test.ts › HuggingFaceResponsesLanguageModel › doGenerate › basic text response › should generate text and sources from annotations + #[tokio::test] + async fn should_generate_text_and_sources_from_annotations() { + let fetch = json_fetch(&json!({ + "id": "resp_test_annotations", + "model": "deepseek-ai/DeepSeek-V3-0324", + "object": "response", + "created_at": 1741257730, + "status": "completed", + "error": null, + "instructions": null, + "max_output_tokens": null, + "metadata": null, + "tool_choice": "auto", + "tools": [], + "temperature": 1.0, + "top_p": 1.0, + "incomplete_details": null, + "usage": { "input_tokens": 20, "output_tokens": 50, "total_tokens": 70 }, + "output": [ + { + "id": "msg_test_annotations", + "type": "message", + "role": "assistant", + "status": "completed", + "content": [ + { + "type": "output_text", + "text": "Here are some recent articles about AI: The first article discusses new developments ([example.com](https://example.com/article1)). Another piece covers industry trends ([test.com](https://test.com/article2)).", + "annotations": [ + { + "type": "url_citation", + "url": "https://example.com/article1", + "title": "AI Developments Article" + }, + { + "type": "url_citation", + "url": "https://test.com/article2", + "title": "Industry Trends Report" + } + ] + } + ] + } + ], + "output_text": null + })); + let result = provider(&fetch) + .responses(MODEL) + .do_generate(&call_options(test_prompt())) + .await + .expect("should succeed"); + + assert_eq!( + serde_json::to_value(&result.content).unwrap(), + json!([ + { + "providerMetadata": { + "huggingface": { "itemId": "msg_test_annotations" } + }, + "text": "Here are some recent articles about AI: The first article discusses new developments ([example.com](https://example.com/article1)). Another piece covers industry trends ([test.com](https://test.com/article2)).", + "type": "text" + }, + { + "id": "id-0", + "sourceType": "url", + "title": "AI Developments Article", + "type": "source", + "url": "https://example.com/article1" + }, + { + "id": "id-1", + "sourceType": "url", + "title": "Industry Trends Report", + "type": "source", + "url": "https://test.com/article2" + } + ]) + ); + } + + /// upstream: @ai-sdk/huggingface src/responses/huggingface-responses-language-model.test.ts › HuggingFaceResponsesLanguageModel › doGenerate › basic text response › should handle MCP tools with annotations + #[tokio::test] + async fn should_handle_mcp_tools_with_annotations() { + let fetch = json_fetch(&json!({ + "id": "resp_mcp_test", + "model": "deepseek-ai/DeepSeek-V3-0324", + "object": "response", + "created_at": 1741257730, + "status": "completed", + "error": null, + "instructions": null, + "max_output_tokens": null, + "metadata": null, + "tool_choice": "auto", + "tools": [], + "temperature": 1.0, + "top_p": 1.0, + "incomplete_details": null, + "usage": { "input_tokens": 50, "output_tokens": 100, "total_tokens": 150 }, + "output": [ + { + "id": "mcp_search_test", + "type": "mcp_call", + "server_label": "web_search", + "name": "search", + "arguments": "{\"query\": \"San Francisco tech events\"}", + "output": "Found 25 tech events in San Francisco" + }, + { + "id": "msg_mcp_response", + "type": "message", + "role": "assistant", + "status": "completed", + "content": [ + { + "type": "output_text", + "text": "Based on the search results, here are the latest tech events in San Francisco: There are several AI conferences ([techevents.com](https://techevents.com/sf-ai)) and startup meetups ([eventbrite.com](https://eventbrite.com/sf-startups)) happening this week.", + "annotations": [ + { + "type": "url_citation", + "url": "https://techevents.com/sf-ai", + "title": "SF AI Conference 2025" + }, + { + "type": "url_citation", + "url": "https://eventbrite.com/sf-startups", + "title": "SF Startup Meetups" + } + ] + } + ] + } + ], + "output_text": null + })); + let result = provider(&fetch) + .responses(MODEL) + .do_generate(&call_options(test_prompt())) + .await + .expect("should succeed"); + + assert_eq!( + serde_json::to_value(&result.content).unwrap(), + json!([ + { + "input": "{\"query\": \"San Francisco tech events\"}", + "providerExecuted": true, + "toolCallId": "mcp_search_test", + "toolName": "search", + "type": "tool-call" + }, + { + "result": "Found 25 tech events in San Francisco", + "toolCallId": "mcp_search_test", + "toolName": "search", + "type": "tool-result" + }, + { + "providerMetadata": { + "huggingface": { "itemId": "msg_mcp_response" } + }, + "text": "Based on the search results, here are the latest tech events in San Francisco: There are several AI conferences ([techevents.com](https://techevents.com/sf-ai)) and startup meetups ([eventbrite.com](https://eventbrite.com/sf-startups)) happening this week.", + "type": "text" + }, + { + "id": "id-0", + "sourceType": "url", + "title": "SF AI Conference 2025", + "type": "source", + "url": "https://techevents.com/sf-ai" + }, + { + "id": "id-1", + "sourceType": "url", + "title": "SF Startup Meetups", + "type": "source", + "url": "https://eventbrite.com/sf-startups" + } + ]) + ); + } + } + } + + mod do_stream { + use super::*; + + /// upstream: @ai-sdk/huggingface src/responses/huggingface-responses-language-model.test.ts › HuggingFaceResponsesLanguageModel › doStream › should stream text deltas + #[tokio::test] + async fn should_stream_text_deltas() { + let fetch = sse_fetch(&[ + r#"data:{"type":"response.created","response":{"id":"resp_test","object":"response","created_at":1741269019,"status":"in_progress","model":"deepseek-ai/DeepSeek-V3-0324"}}"#, + r#"data:{"type":"response.in_progress","response":{"id":"resp_test","object":"response","created_at":1741269019,"status":"in_progress"}}"#, + r#"data:{"type":"response.output_item.added","output_index":0,"item":{"id":"msg_test","type":"message","role":"assistant","status":"in_progress","content":[]},"sequence_number":1}"#, + r#"data:{"type":"response.output_text.delta","item_id":"msg_test","output_index":0,"content_index":0,"delta":"Hello,","sequence_number":2}"#, + r#"data:{"type":"response.output_text.delta","item_id":"msg_test","output_index":0,"content_index":0,"delta":" World!","sequence_number":3}"#, + r#"data:{"type":"response.output_item.done","output_index":0,"item":{"id":"msg_test","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"Hello, World!"}]},"sequence_number":4}"#, + r#"data:{"type":"response.completed","response":{"id":"resp_test","model":"deepseek-ai/DeepSeek-V3-0324","object":"response","created_at":1741269112,"status":"completed","incomplete_details":null,"usage":{"input_tokens":12,"output_tokens":25,"total_tokens":37},"output":[{"id":"msg_test","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"Hello, World!"}]}]},"sequence_number":5}"#, + ]); + let result = provider(&fetch) + .responses(MODEL) + .do_stream(&call_options(test_prompt())) + .await + .expect("should succeed"); + + assert_eq!( + Value::Array(stream_parts(result).await), + json!([ + { "type": "stream-start", "warnings": [] }, + { + "id": "resp_test", + "modelId": "deepseek-ai/DeepSeek-V3-0324", + "timestamp": "2025-03-06T13:50:19.000Z", + "type": "response-metadata" + }, + { + "id": "msg_test", + "providerMetadata": { "huggingface": { "itemId": "msg_test" } }, + "type": "text-start" + }, + { "delta": "Hello,", "id": "msg_test", "type": "text-delta" }, + { "delta": " World!", "id": "msg_test", "type": "text-delta" }, + { "id": "msg_test", "type": "text-end" }, + { + "finishReason": { "unified": "stop" }, + "providerMetadata": { "huggingface": { "responseId": "resp_test" } }, + "type": "finish", + "usage": { + "inputTokens": { "cacheRead": 0, "noCache": 12, "total": 12 }, + "outputTokens": { "reasoning": 0, "text": 25, "total": 25 }, + "raw": { "input_tokens": 12, "output_tokens": 25, "total_tokens": 37 } + } + } + ]) + ); + } + + /// upstream: @ai-sdk/huggingface src/responses/huggingface-responses-language-model.test.ts › HuggingFaceResponsesLanguageModel › doStream › should handle streaming without usage + #[tokio::test] + async fn should_handle_streaming_without_usage() { + let fetch = sse_fetch(&[ + r#"data:{"type":"response.output_item.added","output_index":0,"item":{"id":"msg_test","type":"message","role":"assistant","status":"in_progress"},"sequence_number":1}"#, + r#"data:{"type":"response.output_text.delta","item_id":"msg_test","output_index":0,"content_index":0,"delta":"Hi!","sequence_number":2}"#, + r#"data:{"type":"response.output_item.done","output_index":0,"item":{"id":"msg_test","type":"message","role":"assistant","status":"completed"},"sequence_number":3}"#, + r#"data:{"type":"response.completed","response":{"id":"resp_test","status":"completed","incomplete_details":null,"usage":null},"sequence_number":4}"#, + ]); + let result = provider(&fetch) + .responses(MODEL) + .do_stream(&call_options(test_prompt())) + .await + .expect("should succeed"); + + let parts = stream_parts(result).await; + let finish = parts + .iter() + .find(|part| part["type"] == "finish") + .expect("finish part"); + + // Every usage field is undefined, so none is serialized. + assert_eq!( + finish["usage"], + json!({ "inputTokens": {}, "outputTokens": {} }) + ); + } + + /// upstream: @ai-sdk/huggingface src/responses/huggingface-responses-language-model.test.ts › HuggingFaceResponsesLanguageModel › doStream › should handle streaming errors + #[tokio::test] + async fn should_handle_streaming_errors() { + let fetch = sse_fetch(&[ + r#"data:{"type":"response.output_item.added","output_index":0,"item":{"id":"msg_test","type":"message","role":"assistant"},"sequence_number":1}"#, + "data:invalid json}", + ]); + let result = provider(&fetch) + .responses(MODEL) + .do_stream(&call_options(test_prompt())) + .await + .expect("should succeed"); + + let parts = stream_parts(result).await; + let error = parts.iter().find(|part| part["type"] == "error"); + let finish = parts.iter().find(|part| part["type"] == "finish"); + + assert!(error.is_some(), "expected an error part, got {parts:?}"); + assert_eq!( + finish.expect("finish part")["finishReason"], + json!({ "unified": "error" }) + ); + } + + /// upstream: @ai-sdk/huggingface src/responses/huggingface-responses-language-model.test.ts › HuggingFaceResponsesLanguageModel › doStream › should send correct streaming request + #[tokio::test] + async fn should_send_correct_streaming_request() { + let fetch = sse_fetch(&[ + r#"data:{"type":"response.completed","response":{"id":"resp_test","status":"completed"},"sequence_number":1}"#, + ]); + let mut call = call_options(test_prompt()); + call.temperature = Some(0.7); + + provider(&fetch) + .responses(MODEL) + .do_stream(&call) + .await + .expect("should succeed"); + + let seen = fetch.seen(); + assert_eq!( + seen[0].json_body(), + json!({ + "model": "deepseek-ai/DeepSeek-V3-0324", + "temperature": 0.7, + "stream": true, + "input": [ + { + "role": "user", + "content": [{ "type": "input_text", "text": "Hello" }] + } + ] + }) + ); + } + } + + mod message_conversion { + use super::*; + + /// upstream: @ai-sdk/huggingface src/responses/huggingface-responses-language-model.test.ts › HuggingFaceResponsesLanguageModel › message conversion › should warn about tool messages + #[tokio::test] + async fn should_warn_about_tool_messages() { + let fetch = json_fetch(&message_conversion_response()); + let prompt = vec![LanguageModelMessage::Tool { + content: vec![ToolPart::ToolResult(ToolResultPart { + tool_call_id: "test".into(), + tool_name: "test".into(), + output: ToolResultOutput::Text { + value: "test".into(), + provider_options: None, + }, + provider_options: None, + })], + provider_options: None, + }]; + + let result = provider(&fetch) + .responses(MODEL) + .do_generate(&call_options(prompt)) + .await + .expect("should succeed"); + + assert_eq!( + serde_json::to_value(&result.warnings).unwrap(), + json!([{ "feature": "tool messages", "type": "unsupported" }]) + ); + } + } + + mod tool_calls { + use super::*; + + /// upstream: @ai-sdk/huggingface src/responses/huggingface-responses-language-model.test.ts › HuggingFaceResponsesLanguageModel › tool calls › should handle function_call tool responses + #[tokio::test] + async fn should_handle_function_call_tool_responses() { + let fetch = json_fetch(&json!({ + "id": "resp_tool_test", + "model": "deepseek-ai/DeepSeek-V3-0324", + "object": "response", + "created_at": 1741257730, + "status": "completed", + "error": null, + "instructions": null, + "max_output_tokens": null, + "metadata": null, + "tool_choice": "auto", + "tools": [], + "temperature": 1.0, + "top_p": 1.0, + "incomplete_details": null, + "usage": { "input_tokens": 50, "output_tokens": 30, "total_tokens": 80 }, + "output": [ + { + "id": "fc_test", + "type": "function_call", + "call_id": "call_123", + "name": "getWeather", + "arguments": "{\"location\": \"New York\"}", + "output": "{\"temperature\": \"72°F\", \"condition\": \"sunny\"}" + }, + { + "id": "msg_after_tool", + "type": "message", + "role": "assistant", + "status": "completed", + "content": [ + { + "type": "output_text", + "text": "The weather in New York is 72°F and sunny." + } + ] + } + ], + "output_text": null + })); + let result = provider(&fetch) + .responses(MODEL) + .do_generate(&call_options(test_prompt())) + .await + .expect("should succeed"); + + assert_eq!( + serde_json::to_value(&result.content).unwrap(), + json!([ + { + "input": "{\"location\": \"New York\"}", + "toolCallId": "call_123", + "toolName": "getWeather", + "type": "tool-call" + }, + { + "result": "{\"temperature\": \"72°F\", \"condition\": \"sunny\"}", + "toolCallId": "call_123", + "toolName": "getWeather", + "type": "tool-result" + }, + { + "providerMetadata": { + "huggingface": { "itemId": "msg_after_tool" } + }, + "text": "The weather in New York is 72°F and sunny.", + "type": "text" + } + ]) + ); + } + + /// upstream: @ai-sdk/huggingface src/responses/huggingface-responses-language-model.test.ts › HuggingFaceResponsesLanguageModel › tool calls › should stream tool calls + #[tokio::test] + async fn should_stream_tool_calls() { + let fetch = sse_fetch(&[ + r#"data:{"type":"response.created","response":{"id":"resp_tool_stream","object":"response","created_at":1741269019,"status":"in_progress","model":"deepseek-ai/DeepSeek-V3-0324"}}"#, + r#"data:{"type":"response.output_item.added","output_index":0,"item":{"id":"fc_stream","type":"function_call","call_id":"call_456","name":"calculator","arguments":""},"sequence_number":1}"#, + r#"data:{"type":"response.function_call_arguments.delta","item_id":"fc_stream","output_index":0,"delta":"{\"operation\"","sequence_number":2}"#, + r#"data:{"type":"response.function_call_arguments.delta","item_id":"fc_stream","output_index":0,"delta":": \"add\", \"a\": 5, \"b\": 3}","sequence_number":3}"#, + r#"data:{"type":"response.output_item.done","output_index":0,"item":{"id":"fc_stream","type":"function_call","call_id":"call_456","name":"calculator","arguments":"{\"operation\": \"add\", \"a\": 5, \"b\": 3}","output":"8"},"sequence_number":4}"#, + r#"data:{"type":"response.completed","response":{"id":"resp_tool_stream","status":"completed","usage":{"input_tokens":20,"output_tokens":15,"total_tokens":35}},"sequence_number":5}"#, + ]); + let result = provider(&fetch) + .responses(MODEL) + .do_stream(&call_options(test_prompt())) + .await + .expect("should succeed"); + + assert_eq!( + Value::Array(stream_parts(result).await), + json!([ + { "type": "stream-start", "warnings": [] }, + { + "id": "resp_tool_stream", + "modelId": "deepseek-ai/DeepSeek-V3-0324", + "timestamp": "2025-03-06T13:50:19.000Z", + "type": "response-metadata" + }, + { + "id": "call_456", + "toolName": "calculator", + "type": "tool-input-start" + }, + { "id": "call_456", "type": "tool-input-end" }, + { + "input": "{\"operation\": \"add\", \"a\": 5, \"b\": 3}", + "toolCallId": "call_456", + "toolName": "calculator", + "type": "tool-call" + }, + { + "result": "8", + "toolCallId": "call_456", + "toolName": "calculator", + "type": "tool-result" + }, + { + "finishReason": { "unified": "stop" }, + "providerMetadata": { + "huggingface": { "responseId": "resp_tool_stream" } + }, + "type": "finish", + "usage": { + "inputTokens": { "cacheRead": 0, "noCache": 20, "total": 20 }, + "outputTokens": { "reasoning": 0, "text": 15, "total": 15 }, + "raw": { "input_tokens": 20, "output_tokens": 15, "total_tokens": 35 } + } + } + ]) + ); + } + } + + mod structured_output { + use super::*; + + /// upstream: @ai-sdk/huggingface src/responses/huggingface-responses-language-model.test.ts › HuggingFaceResponsesLanguageModel › structured output › should send text.format for structured output + #[tokio::test] + async fn should_send_text_format_for_structured_output() { + let fetch = json_fetch(&json!({ + "id": "resp_structured", + "model": "moonshotai/Kimi-K2-Instruct", + "object": "response", + "created_at": 1741257730, + "status": "completed", + "error": null, + "instructions": null, + "max_output_tokens": null, + "metadata": null, + "tool_choice": "auto", + "tools": [], + "temperature": 1.0, + "top_p": 1.0, + "incomplete_details": null, + "usage": null, + "output": [ + { + "id": "msg_structured", + "type": "message", + "role": "assistant", + "status": "completed", + "content": [ + { "type": "output_text", "text": "{\"name\": \"John Doe\", \"age\": 30}" } + ] + } + ], + "output_text": null + })); + let mut call = call_options(test_prompt()); + call.response_format = Some(ResponseFormat::Json { + schema: Some(json!({ + "type": "object", + "properties": { + "name": { "type": "string" }, + "age": { "type": "number" } + }, + "required": ["name", "age"] + })), + name: None, + description: None, + }); + + provider(&fetch) + .responses("moonshotai/Kimi-K2-Instruct") + .do_generate(&call) + .await + .expect("should succeed"); + + let seen = fetch.seen(); + assert_eq!( + seen[0].json_body()["text"], + json!({ + "format": { + "name": "response", + "schema": { + "properties": { + "age": { "type": "number" }, + "name": { "type": "string" } + }, + "required": ["name", "age"], + "type": "object" + }, + "strict": false, + "type": "json_schema" + } + }) + ); + } + } + + mod reasoning { + use super::*; + + /// upstream: @ai-sdk/huggingface src/responses/huggingface-responses-language-model.test.ts › HuggingFaceResponsesLanguageModel › reasoning › should handle reasoning content in responses + #[tokio::test] + async fn should_handle_reasoning_content_in_responses() { + let fetch = json_fetch(&json!({ + "id": "resp_reasoning", + "model": "deepseek-ai/DeepSeek-R1", + "object": "response", + "created_at": 1741257730, + "status": "completed", + "error": null, + "instructions": null, + "max_output_tokens": null, + "metadata": null, + "tool_choice": "auto", + "tools": [], + "temperature": 1.0, + "top_p": 1.0, + "incomplete_details": null, + "usage": { "input_tokens": 10, "output_tokens": 50, "total_tokens": 60 }, + "output": [ + { + "id": "reasoning_1", + "type": "reasoning", + "content": [ + { + "type": "reasoning_text", + "text": "Let me think about this problem step by step..." + } + ] + }, + { + "id": "msg_after_reasoning", + "type": "message", + "role": "assistant", + "status": "completed", + "content": [{ "type": "output_text", "text": "The answer is 42." }] + } + ], + "output_text": null + })); + let result = provider(&fetch) + .responses("deepseek-ai/DeepSeek-R1") + .do_generate(&call_options(test_prompt())) + .await + .expect("should succeed"); + + assert_eq!( + serde_json::to_value(&result.content).unwrap(), + json!([ + { + "providerMetadata": { "huggingface": { "itemId": "reasoning_1" } }, + "text": "Let me think about this problem step by step...", + "type": "reasoning" + }, + { + "providerMetadata": { + "huggingface": { "itemId": "msg_after_reasoning" } + }, + "text": "The answer is 42.", + "type": "text" + } + ]) + ); + } + + /// upstream: @ai-sdk/huggingface src/responses/huggingface-responses-language-model.test.ts › HuggingFaceResponsesLanguageModel › reasoning › should stream reasoning content + #[tokio::test] + async fn should_stream_reasoning_content() { + let fetch = sse_fetch(&[ + r#"data:{"type":"response.created","response":{"id":"resp_reasoning_stream","object":"response","created_at":1741269019,"status":"in_progress","model":"deepseek-ai/DeepSeek-R1"}}"#, + r#"data:{"type":"response.output_item.added","output_index":0,"item":{"id":"reasoning_stream","type":"reasoning"},"sequence_number":1}"#, + r#"data:{"type":"response.reasoning_text.delta","item_id":"reasoning_stream","output_index":0,"content_index":0,"delta":"Thinking about","sequence_number":2}"#, + r#"data:{"type":"response.reasoning_text.delta","item_id":"reasoning_stream","output_index":0,"content_index":0,"delta":" the problem...","sequence_number":3}"#, + r#"data:{"type":"response.reasoning_text.done","item_id":"reasoning_stream","output_index":0,"content_index":0,"text":"Thinking about the problem...","sequence_number":4}"#, + r#"data:{"type":"response.output_item.done","output_index":0,"item":{"id":"reasoning_stream","type":"reasoning","content":[{"type":"reasoning_text","text":"Thinking about the problem..."}]},"sequence_number":5}"#, + r#"data:{"type":"response.output_item.added","output_index":1,"item":{"id":"msg_stream","type":"message","role":"assistant"},"sequence_number":6}"#, + r#"data:{"type":"response.output_text.delta","item_id":"msg_stream","output_index":1,"content_index":0,"delta":"The solution is","sequence_number":7}"#, + r#"data:{"type":"response.output_text.delta","item_id":"msg_stream","output_index":1,"content_index":0,"delta":" simple.","sequence_number":8}"#, + r#"data:{"type":"response.output_item.done","output_index":1,"item":{"id":"msg_stream","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"The solution is simple."}]},"sequence_number":9}"#, + r#"data:{"type":"response.completed","response":{"id":"resp_reasoning_stream","status":"completed","usage":{"input_tokens":10,"output_tokens":20,"total_tokens":30}},"sequence_number":10}"#, + ]); + let result = provider(&fetch) + .responses("deepseek-ai/DeepSeek-R1") + .do_stream(&call_options(test_prompt())) + .await + .expect("should succeed"); + + assert_eq!( + Value::Array(stream_parts(result).await), + json!([ + { "type": "stream-start", "warnings": [] }, + { + "id": "resp_reasoning_stream", + "modelId": "deepseek-ai/DeepSeek-R1", + "timestamp": "2025-03-06T13:50:19.000Z", + "type": "response-metadata" + }, + { + "id": "reasoning_stream", + "providerMetadata": { + "huggingface": { "itemId": "reasoning_stream" } + }, + "type": "reasoning-start" + }, + { + "delta": "Thinking about", + "id": "reasoning_stream", + "type": "reasoning-delta" + }, + { + "delta": " the problem...", + "id": "reasoning_stream", + "type": "reasoning-delta" + }, + { "id": "reasoning_stream", "type": "reasoning-end" }, + { + "id": "msg_stream", + "providerMetadata": { "huggingface": { "itemId": "msg_stream" } }, + "type": "text-start" + }, + { "delta": "The solution is", "id": "msg_stream", "type": "text-delta" }, + { "delta": " simple.", "id": "msg_stream", "type": "text-delta" }, + { "id": "msg_stream", "type": "text-end" }, + { + "finishReason": { "unified": "stop" }, + "providerMetadata": { + "huggingface": { "responseId": "resp_reasoning_stream" } + }, + "type": "finish", + "usage": { + "inputTokens": { "cacheRead": 0, "noCache": 10, "total": 10 }, + "outputTokens": { "reasoning": 0, "text": 20, "total": 20 }, + "raw": { "input_tokens": 10, "output_tokens": 20, "total_tokens": 30 } + } + } + ]) + ); + } + } +} diff --git a/aimux-providers/src/lib.rs b/aimux-providers/src/lib.rs index 970691328..e8eb8d125 100644 --- a/aimux-providers/src/lib.rs +++ b/aimux-providers/src/lib.rs @@ -403,3 +403,6 @@ mod decision_support; pub use decision_support::DecisionProbabilitySource; mod runtime_decision; mod systemone; + +#[cfg(test)] +mod test_support; diff --git a/aimux-providers/src/test_support/mod.rs b/aimux-providers/src/test_support/mod.rs new file mode 100644 index 000000000..9bbf0e551 --- /dev/null +++ b/aimux-providers/src/test_support/mod.rs @@ -0,0 +1,4 @@ +//! Shared test helpers for the in-crate vendor tests. + +#[path = "../../tests/common/mock_fetch.rs"] +pub(crate) mod mock_fetch; diff --git a/scripts/check_provider_boundaries.sh b/scripts/check_provider_boundaries.sh index df1578cd2..54d3fb7fc 100755 --- a/scripts/check_provider_boundaries.sh +++ b/scripts/check_provider_boundaries.sh @@ -214,7 +214,7 @@ rule7b_hits="$( violation 'bedrock::sigv4 is back (use SigV4Fetch)' "$rule7b_hits" rule7c_hits="$( # Non-test, non-comment lines of every file but the two helpers. - find $gvb_family -name '*.rs' ! -name options.rs -print0 | + find $gvb_family -name '*.rs' ! -name options.rs ! -path '*/tests/*' -print0 | xargs -0 awk ' FNR == 1 { in_tests = 0 } /^#\[cfg\(test\)\]/ { in_tests = 1 } @@ -245,7 +245,7 @@ rule8_hits="$( violation 'Azure / xAI / Mistral / Cohere / Hugging Face / Codex / Open Responses / Voyage / ElevenLabs reads builder-era config state' "$rule8_hits" rule8b_hits="$( # Non-test, non-comment lines of every file but the options helpers. - find $vendor_family -name '*.rs' ! -name options.rs -print0 | + find $vendor_family -name '*.rs' ! -name options.rs ! -path '*/tests/*' -print0 | xargs -0 awk ' FNR == 1 { in_tests = 0 } /^#\[cfg\(test\)\]/ { in_tests = 1 } From 34573a0120021182cb985c443b167df33d053aa5 Mon Sep 17 00:00:00 2001 From: cunninghamcard-bit Date: Tue, 20 Oct 2026 06:22:25 +0000 Subject: [PATCH 03/10] fix(huggingface): MCP calls are not dynamic matches @ai-sdk/huggingface@2.0.62 doGenerate pushes mcp_call and mcp_list_tools as provider-executed tool-calls and tool-results without a dynamic flag (huggingface-responses-language-model.ts:264-301). aimux set dynamic: true on all four parts. Without a local tool of that name, Core now returns the call marked invalid, as parseToolCall does; the old test of that path follows. Tests: huggingface::tests::responses::responses_language_model::hugging_face_responses_language_model::do_generate::basic_text_response::should_handle_mcp_tools_with_annotations. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01RoxcKU4zBD64Lri3e4Lrq5 --- CHANGELOG.md | 4 ++++ aimux-providers/src/huggingface/responses.rs | 8 ++++---- aimux-providers/tests/huggingface_responses_test.rs | 6 ++++-- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e03fed7f..54c0da76f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1148,6 +1148,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Hugging Face MCP calls (`mcp_call`, `mcp_list_tools`) and their results are + no longer marked `dynamic`, as in `@ai-sdk/huggingface` 2.0.62. Without a + local tool of that name, `generate_text` now returns the call marked invalid + (`NoSuchTool`), as the AI SDK's `parseToolCall` does. - `default_providers()`, `openai()` and `anthropic()` no longer panic on an invalid `OPENAI_BASE_URL` / `ANTHROPIC_BASE_URL`. The variable is still read when the provider is created, as the AI SDK does; an invalid value now diff --git a/aimux-providers/src/huggingface/responses.rs b/aimux-providers/src/huggingface/responses.rs index d965c10bc..da79fada1 100644 --- a/aimux-providers/src/huggingface/responses.rs +++ b/aimux-providers/src/huggingface/responses.rs @@ -1023,7 +1023,7 @@ fn build_generate_content(response: &Value) -> Result, AiMu tool_name: name.to_string(), input, provider_executed: Some(true), - dynamic: Some(true), + dynamic: None, provider_metadata: None, })); @@ -1034,7 +1034,7 @@ fn build_generate_content(response: &Value) -> Result, AiMu result: output.clone(), is_error: None, preliminary: None, - dynamic: Some(true), + dynamic: None, provider_metadata: None, })); } @@ -1048,7 +1048,7 @@ fn build_generate_content(response: &Value) -> Result, AiMu tool_name: "list_tools".to_string(), input: json!({ "server_label": server_label }).to_string(), provider_executed: Some(true), - dynamic: Some(true), + dynamic: None, provider_metadata: None, })); @@ -1059,7 +1059,7 @@ fn build_generate_content(response: &Value) -> Result, AiMu result: json!({ "tools": tools }), is_error: None, preliminary: None, - dynamic: Some(true), + dynamic: None, provider_metadata: None, })); } diff --git a/aimux-providers/tests/huggingface_responses_test.rs b/aimux-providers/tests/huggingface_responses_test.rs index b0aaca871..4a01187f0 100644 --- a/aimux-providers/tests/huggingface_responses_test.rs +++ b/aimux-providers/tests/huggingface_responses_test.rs @@ -1173,14 +1173,16 @@ async fn should_handle_mcp_tools_with_annotations() { other => panic!("expected Source at [4], got {other:?}"), } + // Like upstream, the MCP call is not dynamic: without a local `search` + // tool, Core returns it marked invalid (parseToolCall's NoSuchToolError). let result = generate_text(&model, "Hello", GenerateTextOptions::default()) .await - .expect("dynamic MCP call should pass Core validation without local tools"); + .expect("an unknown tool is an invalid call, not an error"); let call = *result.tool_calls().first().expect("MCP tool call"); assert_eq!(call.tool_name, "search"); assert_eq!(call.provider_executed, Some(true)); assert_eq!(call.dynamic, Some(true)); - assert_eq!(call.invalid, None); + assert_eq!(call.invalid, Some(true)); assert_eq!(call.input, json!({ "query": "San Francisco tech events" })); } From 768950ed2f5fea3bbdf0a9f2604f64a4a2597d7e Mon Sep 17 00:00:00 2001 From: cunninghamcard-bit Date: Tue, 20 Oct 2026 06:25:41 +0000 Subject: [PATCH 04/10] fix(huggingface): output items yield a tool-result matches @ai-sdk/huggingface@2.0.62 doGenerate pushes a tool-result after a function_call or mcp_call when part.output is truthy, with the output string as the result (huggingface-responses-language-model.ts:273, :312). aimux dropped the function_call output and, for mcp_call, also gave a tool-result for an empty string. Both now follow `if (part.output)`, and so does the stream's function_call. The old function_call test asserted the dropped output and follows. Tests: huggingface::tests::responses::responses_language_model::hugging_face_responses_language_model::tool_calls::should_handle_function_call_tool_responses. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01RoxcKU4zBD64Lri3e4Lrq5 --- CHANGELOG.md | 5 ++++ aimux-providers/src/huggingface/responses.rs | 28 ++++++++++++++---- .../tests/huggingface_responses_test.rs | 29 ++++++++++++++----- 3 files changed, 50 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 54c0da76f..ad51d4c51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1152,6 +1152,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 no longer marked `dynamic`, as in `@ai-sdk/huggingface` 2.0.62. Without a local tool of that name, `generate_text` now returns the call marked invalid (`NoSuchTool`), as the AI SDK's `parseToolCall` does. +- A Hugging Face `function_call` or `mcp_call` output item with a non-empty + `output` string now also yields a tool-result holding that string, as + `@ai-sdk/huggingface` 2.0.62 does (`if (part.output)`). `function_call` + outputs were dropped in `do_generate`, and an empty `output` gave an MCP + tool-result. - `default_providers()`, `openai()` and `anthropic()` no longer panic on an invalid `OPENAI_BASE_URL` / `ANTHROPIC_BASE_URL`. The variable is still read when the provider is created, as the AI SDK does; an invalid value now diff --git a/aimux-providers/src/huggingface/responses.rs b/aimux-providers/src/huggingface/responses.rs index da79fada1..b8a506e16 100644 --- a/aimux-providers/src/huggingface/responses.rs +++ b/aimux-providers/src/huggingface/responses.rs @@ -446,9 +446,7 @@ impl HuggingFaceResponsesStream { provider_metadata: None, })); - if let Some(output) = - item.get("output").and_then(|v| v.as_str()) - { + if let Some(output) = non_empty_output(item) { controller.enqueue(StreamPart::ToolResult(ToolResult { tool_call_id: call_id, tool_name: name.clone(), @@ -1008,6 +1006,18 @@ fn build_generate_content(response: &Value) -> Result, AiMu dynamic: None, provider_metadata: None, })); + + if let Some(output) = non_empty_output(part) { + content.push(GenerateContent::ToolResult(ToolResult { + tool_call_id: call_id.to_string(), + tool_name: name.to_string(), + result: Value::String(output.to_string()), + is_error: None, + preliminary: None, + dynamic: None, + provider_metadata: None, + })); + } } "mcp_call" => { @@ -1027,11 +1037,11 @@ fn build_generate_content(response: &Value) -> Result, AiMu provider_metadata: None, })); - if let Some(output) = part.get("output").filter(|v| !v.is_null()) { + if let Some(output) = non_empty_output(part) { content.push(GenerateContent::ToolResult(ToolResult { tool_call_id: id.to_string(), tool_name: name.to_string(), - result: output.clone(), + result: Value::String(output.to_string()), is_error: None, preliminary: None, dynamic: None, @@ -1072,6 +1082,14 @@ fn build_generate_content(response: &Value) -> Result, AiMu Ok(content) } +/// `part.output` when JavaScript reads it as true (`if (part.output)`): a +/// non-empty string. +fn non_empty_output(part: &Value) -> Option<&str> { + part.get("output") + .and_then(Value::as_str) + .filter(|output| !output.is_empty()) +} + /// Parse the finish reason from a Responses API JSON body. fn parse_finish_reason(response: &Value) -> FinishReason { let incomplete_reason = response diff --git a/aimux-providers/tests/huggingface_responses_test.rs b/aimux-providers/tests/huggingface_responses_test.rs index 4a01187f0..8ad3b36f8 100644 --- a/aimux-providers/tests/huggingface_responses_test.rs +++ b/aimux-providers/tests/huggingface_responses_test.rs @@ -1478,9 +1478,9 @@ async fn should_warn_about_tool_messages() { assert_eq!(features, vec!["tool messages"]); } -/// Not the upstream expectation: for a function_call with an output, asserts -/// two content items and no tool-result. Upstream expects a tool-result too; -/// full case: src/huggingface/tests/responses/responses_language_model.rs. +/// Checks the content kinds and values of upstream `should handle function +/// call tool responses`; full case: +/// src/huggingface/tests/responses/responses_language_model.rs. #[tokio::test] async fn should_handle_function_call_tool_responses() { let server = MockServer::start().await; @@ -1534,9 +1534,8 @@ async fn should_handle_function_call_tool_responses() { .await .expect("should succeed"); - // content: tool-call, text — a client-executed `function_call` carries no - // output, so there is no paired ToolResult. - assert_eq!(result.content.len(), 2); + // content: tool-call, tool-result (the item has an output), text. + assert_eq!(result.content.len(), 3); match &result.content[0] { GenerateContent::ToolCall(RawToolCall { tool_call_id, @@ -1551,10 +1550,26 @@ async fn should_handle_function_call_tool_responses() { other => panic!("expected ToolCall at [0], got {other:?}"), } match &result.content[1] { + GenerateContent::ToolResult(ToolResult { + tool_call_id, + tool_name, + result, + .. + }) => { + assert_eq!(tool_call_id, "call_123"); + assert_eq!(tool_name, "getWeather"); + assert_eq!( + result, + &Value::String(r#"{"temperature": "72°F", "condition": "sunny"}"#.into()) + ); + } + other => panic!("expected ToolResult at [1], got {other:?}"), + } + match &result.content[2] { GenerateContent::Text { text, .. } => { assert_eq!(text, "The weather in New York is 72°F and sunny."); } - other => panic!("expected Text at [1], got {other:?}"), + other => panic!("expected Text at [2], got {other:?}"), } } From 72fc9f5ee572d485f0d1da0fbb279ad47dfcdc75 Mon Sep 17 00:00:00 2001 From: cunninghamcard-bit Date: Tue, 20 Oct 2026 06:26:24 +0000 Subject: [PATCH 05/10] fix(huggingface): stream errors end with an error finish matches @ai-sdk/huggingface@2.0.62 createHuggingFaceResponsesStreamError (huggingface-responses-language-model.ts:562-590) reads only `error` and `response.failed` events: the details are response.error for response.failed, else the event's error object or the event itself, and the message must be a string. The transform then sets finishReason { unified: 'error', raw: code or type } (:406-419); a chunk that fails to parse sets { unified: 'error' } (:396-404). aimux read only a top-level `error` field, so an `error` event with the message on the event and every `response.failed` event went unnoticed, and the finish reason stayed other. A first-event error is still returned by do_stream (registered SSE pre-read), so the mid-stream case gets its own aimux test. Tests: huggingface::tests::responses::responses_language_model::hugging_face_responses_language_model::do_stream::should_handle_streaming_errors, reads_mid_stream_error_events (tests/huggingface_responses_test.rs). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01RoxcKU4zBD64Lri3e4Lrq5 --- CHANGELOG.md | 8 ++ aimux-providers/src/huggingface/responses.rs | 124 ++++++++++++------ .../tests/huggingface_responses_test.rs | 54 ++++++++ 3 files changed, 146 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ad51d4c51..314e44a75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1157,6 +1157,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `@ai-sdk/huggingface` 2.0.62 does (`if (part.output)`). `function_call` outputs were dropped in `do_generate`, and an empty `output` gave an MCP tool-result. +- Hugging Face streams read in-stream errors as `@ai-sdk/huggingface` 2.0.62 + does: an `error` event (message on the event or under `error`) or a + `response.failed` event (`response.error`) with a string message is an + error part, and the finish reason becomes `error` with the code, else the + event type, as `raw`. A malformed chunk also makes the finish reason + `error`. Before, only a top-level `error` field was read, so `error` events + carrying the message on the event and `response.failed` events passed + unnoticed, and the finish reason stayed `other`. - `default_providers()`, `openai()` and `anthropic()` no longer panic on an invalid `OPENAI_BASE_URL` / `ANTHROPIC_BASE_URL`. The variable is still read when the provider is created, as the AI SDK does; an invalid value now diff --git a/aimux-providers/src/huggingface/responses.rs b/aimux-providers/src/huggingface/responses.rs index b8a506e16..baba665c8 100644 --- a/aimux-providers/src/huggingface/responses.rs +++ b/aimux-providers/src/huggingface/responses.rs @@ -91,37 +91,73 @@ fn huggingface_successful_response_handler() -> aimux_provider_utils::ResponseHa }) } -fn huggingface_stream_error( - event: &Value, - url: &str, - request_body_values: Value, - response_headers: HashMap, -) -> Option { - let error = event.get("error").filter(|value| !value.is_null())?; - let message = error - .as_str() - .or_else(|| error.get("message").and_then(Value::as_str)) - .unwrap_or("Hugging Face stream failed"); - let status_code = error - .get("status") - .or_else(|| error.get("code")) - .and_then(Value::as_u64) - .and_then(|status| u16::try_from(status).ok()) - .filter(|status| (400..=599).contains(status)); - let provider_code = error - .get("code") - .or_else(|| error.get("type")) - .and_then(Value::as_str) - .map(str::to_owned); - Some(aimux_provider_utils::stream_error_api_call( - message, - provider_code, - status_code, - event, - url, - request_body_values, - response_headers, - )) +/// An in-stream error event, read like upstream +/// `createHuggingFaceResponsesStreamError`: only `error` and `response.failed` +/// events, with a string message. +struct HuggingFaceStreamError<'a> { + message: &'a str, + event_type: &'a str, + code: Option, + details: &'a Value, +} + +impl<'a> HuggingFaceStreamError<'a> { + fn parse(event: &'a Value) -> Option { + let event_type = event.get("type").and_then(Value::as_str)?; + let details = match event_type { + "response.failed" => event.get("response")?.get("error")?, + "error" => event + .get("error") + .filter(|error| error.is_object()) + .unwrap_or(event), + _ => return None, + }; + let message = details.get("message").and_then(Value::as_str)?; + let code = details.get("code").and_then(|code| match code { + Value::String(code) => Some(code.clone()), + Value::Number(code) => Some(code.to_string()), + _ => None, + }); + Some(Self { + message, + event_type, + code, + details, + }) + } + + /// The finish reason's `raw`: the code, else the event type. + fn raw_finish_reason(&self) -> String { + self.code + .clone() + .unwrap_or_else(|| self.event_type.to_string()) + } + + fn into_error( + self, + event: &Value, + url: &str, + request_body_values: Value, + response_headers: HashMap, + ) -> AiMuxError { + let status_code = self + .details + .get("status") + .or_else(|| self.details.get("code")) + .and_then(Value::as_u64) + .and_then(|status| u16::try_from(status).ok()) + .filter(|status| (400..=599).contains(status)); + let provider_code = Some(self.raw_finish_reason()); + aimux_provider_utils::stream_error_api_call( + self.message, + provider_code, + status_code, + event, + url, + request_body_values, + response_headers, + ) + } } // ───────────────────────────────────────────────────────────────────────────── @@ -234,10 +270,9 @@ impl LanguageModel for HuggingFaceResponsesModel { first_event => first_event, }; if let Some(Ok(event)) = first_event.as_ref() - && let Some(error) = - huggingface_stream_error(event, &endpoint, body.clone(), response_headers.clone()) + && let Some(error) = HuggingFaceStreamError::parse(event) { - return Err(error); + return Err(error.into_error(event, &endpoint, body.clone(), response_headers.clone())); } let stream = pipe_through( futures::stream::iter(first_event).chain(sse_stream), @@ -312,12 +347,17 @@ impl HuggingFaceResponsesStream { } = *self; match event { Ok(parsed) => { - if let Some(error) = huggingface_stream_error( - &parsed, - &self.ctx.url, - self.ctx.request_body.clone(), - self.ctx.response_headers.clone(), - ) { + if let Some(error) = HuggingFaceStreamError::parse(&parsed) { + *finish_reason = FinishReason { + unified: FinishReasonUnified::Error, + raw: Some(error.raw_finish_reason()), + }; + let error = error.into_error( + &parsed, + &self.ctx.url, + self.ctx.request_body.clone(), + self.ctx.response_headers.clone(), + ); controller.enqueue(StreamPart::Error { error }); self.finished = true; return; @@ -532,6 +572,10 @@ impl HuggingFaceResponsesStream { } } Err(error) => { + *finish_reason = FinishReason { + unified: FinishReasonUnified::Error, + raw: None, + }; if error.is_recoverable_stream_error() { controller.enqueue_error(error); } else { diff --git a/aimux-providers/tests/huggingface_responses_test.rs b/aimux-providers/tests/huggingface_responses_test.rs index 8ad3b36f8..bb2982564 100644 --- a/aimux-providers/tests/huggingface_responses_test.rs +++ b/aimux-providers/tests/huggingface_responses_test.rs @@ -1996,3 +1996,57 @@ async fn supports_http_image_urls() { assert!(urls.0["image/*"][0].is_match("http://example.com/a.png")); assert!(!urls.0["image/*"][0].is_match("ftp://example.com/a.png")); } + +/// aimux's own: an `error` or `response.failed` event after the first one is +/// an error part, and the finish reason becomes error with the code (else the +/// event type) as raw (huggingface-responses-language-model.ts:406-419, +/// :562-590). Upstream's cases send the error as the first event, which aimux +/// returns from `do_stream` instead (registered SSE pre-read). +#[tokio::test] +async fn reads_mid_stream_error_events() { + let created = r#"{"type":"response.created","response":{"id":"resp_test","object":"response","created_at":1741269019,"status":"in_progress","model":"deepseek-ai/DeepSeek-V3-0324"},"sequence_number":0}"#; + for (event, message, raw) in [ + ( + r#"{"type":"response.failed","response":{"error":{"code":"429","message":"Rate limit reached"}},"sequence_number":1}"#, + "Rate limit reached", + "429", + ), + ( + r#"{"type":"error","message":"Service unavailable","param":null,"sequence_number":1}"#, + "Service unavailable", + "error", + ), + ] { + let server = MockServer::start().await; + mock_sse(&server, sse_body(&[sse_event(created), sse_event(event)])).await; + + let result = make_provider(&server) + .responses("deepseek-ai/DeepSeek-V3-0324") + .do_stream(&default_options(test_prompt())) + .await + .expect("the first event is not an error"); + let parts = collect_stream(result).await; + + let error = parts + .iter() + .find_map(|part| match part { + StreamPart::Error { error } => Some(error), + _ => None, + }) + .expect("an error part"); + match error { + AiMuxError::ApiCall(error) => { + assert_eq!(error.message, message); + assert_eq!(error.provider_code.as_deref(), Some(raw)); + } + other => panic!("expected ApiCall, got {other:?}"), + } + match parts.last() { + Some(StreamPart::Finish { finish_reason, .. }) => { + assert_eq!(finish_reason.unified, FinishReasonUnified::Error); + assert_eq!(finish_reason.raw.as_deref(), Some(raw)); + } + other => panic!("expected Finish last, got {other:?}"), + } + } +} From eb5741bf48bc20b34bfcc25169c72eb04955397a Mon Sep 17 00:00:00 2001 From: cunninghamcard-bit Date: Tue, 20 Oct 2026 06:26:34 +0000 Subject: [PATCH 06/10] test(huggingface): map the upstream files in layout.toml [huggingface] maps the 12 source files of @ai-sdk/huggingface@2.0.62: 10 mapped, settings.ts and version.ts not-applicable. Four test cases are excluded by hand: the first-event stream error is the registered SSE pre-read; the NoSuchModelError and UnsupportedFunctionalityError message texts come from aimux-core, which #268 changes, and are tracked in #276. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01RoxcKU4zBD64Lri3e4Lrq5 --- aimux-providers/layout.toml | 96 +++++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 aimux-providers/layout.toml diff --git a/aimux-providers/layout.toml b/aimux-providers/layout.toml new file mode 100644 index 000000000..a03060f84 --- /dev/null +++ b/aimux-providers/layout.toml @@ -0,0 +1,96 @@ +# Upstream file map for aimux-providers (RFC-0046 §4.4, RFC-0045 §3). +# One table per vendor with an upstream package; versions come from +# fixtures/aisdk/VERSIONS.json. `rust` paths are relative to src/. + +[huggingface] +package = "@ai-sdk/huggingface" + +[[huggingface.files]] +upstream = "src/huggingface-config.ts" +rust = "huggingface.rs" +status = "mapped" + +[huggingface.files.tests] + +[[huggingface.files]] +upstream = "src/huggingface-error.ts" +rust = "huggingface/responses.rs" +status = "mapped" + +[huggingface.files.tests] + +[[huggingface.files]] +upstream = "src/huggingface-provider.ts" +rust = "huggingface.rs" +status = "mapped" + +[huggingface.files.tests] +sub-feature = [ + { case = "HuggingFaceProvider › unsupported functionality › should throw for text embedding models", issue = 276, reason = "the NoSuchModelError carries no custom message in aimux-core yet; the core error type changes in #268" }, + { case = "HuggingFaceProvider › unsupported functionality › should throw for image models", issue = 276, reason = "the NoSuchModelError carries no custom message in aimux-core yet; the core error type changes in #268" }, +] + +[[huggingface.files]] +upstream = "src/index.ts" +rust = "huggingface.rs" +status = "mapped" + +[huggingface.files.tests] + +[[huggingface.files]] +upstream = "src/responses/convert-huggingface-responses-usage.ts" +rust = "huggingface/responses.rs" +status = "mapped" + +[huggingface.files.tests] + +[[huggingface.files]] +upstream = "src/responses/convert-to-huggingface-responses-messages.ts" +rust = "huggingface/responses.rs" +status = "mapped" + +[huggingface.files.tests] + +[[huggingface.files]] +upstream = "src/responses/huggingface-responses-language-model-options.ts" +rust = "huggingface/options.rs" +status = "mapped" + +[huggingface.files.tests] + +[[huggingface.files]] +upstream = "src/responses/huggingface-responses-language-model.ts" +rust = "huggingface/responses.rs" +status = "mapped" + +[huggingface.files.tests] +registered = [ + { case = "HuggingFaceResponsesLanguageModel › doStream › preserves $expectedType stream errors", ref = "SSE 预读", reason = "an error as the first SSE event is returned by do_stream as Err, not as an error part of the stream" }, +] +sub-feature = [ + { case = "HuggingFaceResponsesLanguageModel › message conversion › should throw for file parts with provider references", issue = 276, reason = "aimux-core formats UnsupportedFunctionalityError as \"unsupported functionality: …\", not upstream's \"'…' functionality not supported.\"; the core error type changes in #268" }, +] + +[[huggingface.files]] +upstream = "src/responses/huggingface-responses-prepare-tools.ts" +rust = "huggingface/responses.rs" +status = "mapped" + +[huggingface.files.tests] + +[[huggingface.files]] +upstream = "src/responses/map-huggingface-responses-finish-reason.ts" +rust = "huggingface/responses.rs" +status = "mapped" + +[huggingface.files.tests] + +[[huggingface.files]] +upstream = "src/responses/huggingface-responses-settings.ts" +status = "not-applicable" +reason = "only the HuggingFaceResponsesModelId string union; Rust takes the model id as &str" + +[[huggingface.files]] +upstream = "src/version.ts" +status = "not-applicable" +reason = "the crate version is used instead (RFC-0046 §4.2)" From 26cf0a834ecdf82bc67dda746f7b7c2b8657f3aa Mon Sep 17 00:00:00 2001 From: cunninghamcard-bit Date: Tue, 20 Oct 2026 06:46:48 +0000 Subject: [PATCH 07/10] fix(huggingface): MCP calls are not dynamic matches @ai-sdk/huggingface@2.0.62 doGenerate pushes mcp_call and mcp_list_tools as provider-executed tool-calls and tool-results without a dynamic flag (huggingface-responses-language-model.ts:264-301). aimux set dynamic: true on all four parts. Without a local tool of that name, Core now returns the call marked invalid, as parseToolCall does. Tests: should_handle_mcp_tools_with_annotations (tests/huggingface_responses_test.rs) now expects the invalid call. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01RoxcKU4zBD64Lri3e4Lrq5 --- CHANGELOG.md | 4 ++++ aimux-providers/src/huggingface/responses.rs | 8 ++++---- aimux-providers/tests/huggingface_responses_test.rs | 6 ++++-- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e03fed7f..54c0da76f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1148,6 +1148,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Hugging Face MCP calls (`mcp_call`, `mcp_list_tools`) and their results are + no longer marked `dynamic`, as in `@ai-sdk/huggingface` 2.0.62. Without a + local tool of that name, `generate_text` now returns the call marked invalid + (`NoSuchTool`), as the AI SDK's `parseToolCall` does. - `default_providers()`, `openai()` and `anthropic()` no longer panic on an invalid `OPENAI_BASE_URL` / `ANTHROPIC_BASE_URL`. The variable is still read when the provider is created, as the AI SDK does; an invalid value now diff --git a/aimux-providers/src/huggingface/responses.rs b/aimux-providers/src/huggingface/responses.rs index d965c10bc..da79fada1 100644 --- a/aimux-providers/src/huggingface/responses.rs +++ b/aimux-providers/src/huggingface/responses.rs @@ -1023,7 +1023,7 @@ fn build_generate_content(response: &Value) -> Result, AiMu tool_name: name.to_string(), input, provider_executed: Some(true), - dynamic: Some(true), + dynamic: None, provider_metadata: None, })); @@ -1034,7 +1034,7 @@ fn build_generate_content(response: &Value) -> Result, AiMu result: output.clone(), is_error: None, preliminary: None, - dynamic: Some(true), + dynamic: None, provider_metadata: None, })); } @@ -1048,7 +1048,7 @@ fn build_generate_content(response: &Value) -> Result, AiMu tool_name: "list_tools".to_string(), input: json!({ "server_label": server_label }).to_string(), provider_executed: Some(true), - dynamic: Some(true), + dynamic: None, provider_metadata: None, })); @@ -1059,7 +1059,7 @@ fn build_generate_content(response: &Value) -> Result, AiMu result: json!({ "tools": tools }), is_error: None, preliminary: None, - dynamic: Some(true), + dynamic: None, provider_metadata: None, })); } diff --git a/aimux-providers/tests/huggingface_responses_test.rs b/aimux-providers/tests/huggingface_responses_test.rs index 8bb5c913e..e04347dd5 100644 --- a/aimux-providers/tests/huggingface_responses_test.rs +++ b/aimux-providers/tests/huggingface_responses_test.rs @@ -575,14 +575,16 @@ async fn should_handle_mcp_tools_with_annotations() { other => panic!("expected Source at [4], got {other:?}"), } + // Like upstream, the MCP call is not dynamic: without a local `search` + // tool, Core returns it marked invalid (parseToolCall's NoSuchToolError). let result = generate_text(&model, "Hello", GenerateTextOptions::default()) .await - .expect("dynamic MCP call should pass Core validation without local tools"); + .expect("an unknown tool is an invalid call, not an error"); let call = *result.tool_calls().first().expect("MCP tool call"); assert_eq!(call.tool_name, "search"); assert_eq!(call.provider_executed, Some(true)); assert_eq!(call.dynamic, Some(true)); - assert_eq!(call.invalid, None); + assert_eq!(call.invalid, Some(true)); assert_eq!(call.input, json!({ "query": "San Francisco tech events" })); } From fcdc9c0a68fe2b432a68ee8914732b3c5cf3a824 Mon Sep 17 00:00:00 2001 From: cunninghamcard-bit Date: Tue, 20 Oct 2026 06:47:36 +0000 Subject: [PATCH 08/10] fix(huggingface): output items yield a tool-result matches @ai-sdk/huggingface@2.0.62 doGenerate pushes a tool-result after a function_call or mcp_call when part.output is truthy, with the output string as the result (huggingface-responses-language-model.ts:273, :312). aimux dropped the function_call output and, for mcp_call, also gave a tool-result for an empty string. Both now follow `if (part.output)`, and so does the stream's function_call (:489). Tests: should_handle_function_call_tool_responses now expects the tool-result; mcp_call_with_empty_output_has_no_tool_result is new (tests/huggingface_responses_test.rs). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01RoxcKU4zBD64Lri3e4Lrq5 --- CHANGELOG.md | 5 ++ aimux-providers/src/huggingface/responses.rs | 28 +++++++++-- .../tests/huggingface_responses_test.rs | 50 +++++++++++++++++-- 3 files changed, 74 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 54c0da76f..ad51d4c51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1152,6 +1152,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 no longer marked `dynamic`, as in `@ai-sdk/huggingface` 2.0.62. Without a local tool of that name, `generate_text` now returns the call marked invalid (`NoSuchTool`), as the AI SDK's `parseToolCall` does. +- A Hugging Face `function_call` or `mcp_call` output item with a non-empty + `output` string now also yields a tool-result holding that string, as + `@ai-sdk/huggingface` 2.0.62 does (`if (part.output)`). `function_call` + outputs were dropped in `do_generate`, and an empty `output` gave an MCP + tool-result. - `default_providers()`, `openai()` and `anthropic()` no longer panic on an invalid `OPENAI_BASE_URL` / `ANTHROPIC_BASE_URL`. The variable is still read when the provider is created, as the AI SDK does; an invalid value now diff --git a/aimux-providers/src/huggingface/responses.rs b/aimux-providers/src/huggingface/responses.rs index da79fada1..b8a506e16 100644 --- a/aimux-providers/src/huggingface/responses.rs +++ b/aimux-providers/src/huggingface/responses.rs @@ -446,9 +446,7 @@ impl HuggingFaceResponsesStream { provider_metadata: None, })); - if let Some(output) = - item.get("output").and_then(|v| v.as_str()) - { + if let Some(output) = non_empty_output(item) { controller.enqueue(StreamPart::ToolResult(ToolResult { tool_call_id: call_id, tool_name: name.clone(), @@ -1008,6 +1006,18 @@ fn build_generate_content(response: &Value) -> Result, AiMu dynamic: None, provider_metadata: None, })); + + if let Some(output) = non_empty_output(part) { + content.push(GenerateContent::ToolResult(ToolResult { + tool_call_id: call_id.to_string(), + tool_name: name.to_string(), + result: Value::String(output.to_string()), + is_error: None, + preliminary: None, + dynamic: None, + provider_metadata: None, + })); + } } "mcp_call" => { @@ -1027,11 +1037,11 @@ fn build_generate_content(response: &Value) -> Result, AiMu provider_metadata: None, })); - if let Some(output) = part.get("output").filter(|v| !v.is_null()) { + if let Some(output) = non_empty_output(part) { content.push(GenerateContent::ToolResult(ToolResult { tool_call_id: id.to_string(), tool_name: name.to_string(), - result: output.clone(), + result: Value::String(output.to_string()), is_error: None, preliminary: None, dynamic: None, @@ -1072,6 +1082,14 @@ fn build_generate_content(response: &Value) -> Result, AiMu Ok(content) } +/// `part.output` when JavaScript reads it as true (`if (part.output)`): a +/// non-empty string. +fn non_empty_output(part: &Value) -> Option<&str> { + part.get("output") + .and_then(Value::as_str) + .filter(|output| !output.is_empty()) +} + /// Parse the finish reason from a Responses API JSON body. fn parse_finish_reason(response: &Value) -> FinishReason { let incomplete_reason = response diff --git a/aimux-providers/tests/huggingface_responses_test.rs b/aimux-providers/tests/huggingface_responses_test.rs index e04347dd5..dae21e0c6 100644 --- a/aimux-providers/tests/huggingface_responses_test.rs +++ b/aimux-providers/tests/huggingface_responses_test.rs @@ -1146,9 +1146,8 @@ async fn should_handle_function_call_tool_responses() { .await .expect("should succeed"); - // content: tool-call, text — a client-executed `function_call` carries no - // output, so there is no paired ToolResult. - assert_eq!(result.content.len(), 2); + // content: tool-call, tool-result (the item has an output), text. + assert_eq!(result.content.len(), 3); match &result.content[0] { GenerateContent::ToolCall(RawToolCall { tool_call_id, @@ -1163,10 +1162,26 @@ async fn should_handle_function_call_tool_responses() { other => panic!("expected ToolCall at [0], got {other:?}"), } match &result.content[1] { + GenerateContent::ToolResult(ToolResult { + tool_call_id, + tool_name, + result, + .. + }) => { + assert_eq!(tool_call_id, "call_123"); + assert_eq!(tool_name, "getWeather"); + assert_eq!( + result, + &Value::String(r#"{"temperature": "72°F", "condition": "sunny"}"#.into()) + ); + } + other => panic!("expected ToolResult at [1], got {other:?}"), + } + match &result.content[2] { GenerateContent::Text { text, .. } => { assert_eq!(text, "The weather in New York is 72°F and sunny."); } - other => panic!("expected Text at [1], got {other:?}"), + other => panic!("expected Text at [2], got {other:?}"), } } @@ -1973,3 +1988,30 @@ async fn supports_http_image_urls() { assert!(urls.0["image/*"][0].is_match("http://example.com/a.png")); assert!(!urls.0["image/*"][0].is_match("ftp://example.com/a.png")); } + +/// aimux's own: an `mcp_call` whose `output` is an empty string gives only the +/// tool-call; upstream pushes the tool-result only `if (part.output)` +/// (huggingface-responses-language-model.ts:273). +#[tokio::test] +async fn mcp_call_with_empty_output_has_no_tool_result() { + let server = MockServer::start().await; + let mut body = basic_response_body(); + body["output"] = json!([{ + "id": "mcp_empty", + "type": "mcp_call", + "server_label": "web_search", + "name": "search", + "arguments": "{}", + "output": "" + }]); + mock_json(&server, body).await; + + let result = make_provider(&server) + .responses("deepseek-ai/DeepSeek-V3-0324") + .do_generate(&default_options(test_prompt())) + .await + .expect("should succeed"); + + assert_eq!(result.content.len(), 1); + assert!(matches!(result.content[0], GenerateContent::ToolCall(_))); +} From a3519880c4143f3c4add0f24a7e4e3ed768fae48 Mon Sep 17 00:00:00 2001 From: cunninghamcard-bit Date: Tue, 20 Oct 2026 06:48:31 +0000 Subject: [PATCH 09/10] fix(huggingface): stream errors end with an error finish matches @ai-sdk/huggingface@2.0.62 createHuggingFaceResponsesStreamError (huggingface-responses-language-model.ts:562-590) reads only `error` and `response.failed` events: the details are response.error for response.failed, else the event's error object or the event itself, and the message must be a string. The transform then sets finishReason { unified: 'error', raw: code or type } (:406-419); a chunk that fails to parse sets { unified: 'error' } (:396-404). aimux read only a top-level `error` field, so an `error` event with the message on the event and every `response.failed` event went unnoticed, and the finish reason stayed other. A first-event error is still returned by do_stream (registered SSE pre-read). Tests: reads_mid_stream_error_events, malformed_chunk_ends_with_an_error_finish (tests/huggingface_responses_test.rs). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01RoxcKU4zBD64Lri3e4Lrq5 --- CHANGELOG.md | 8 ++ aimux-providers/src/huggingface/responses.rs | 124 ++++++++++++------ .../tests/huggingface_responses_test.rs | 85 ++++++++++++ 3 files changed, 177 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ad51d4c51..314e44a75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1157,6 +1157,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `@ai-sdk/huggingface` 2.0.62 does (`if (part.output)`). `function_call` outputs were dropped in `do_generate`, and an empty `output` gave an MCP tool-result. +- Hugging Face streams read in-stream errors as `@ai-sdk/huggingface` 2.0.62 + does: an `error` event (message on the event or under `error`) or a + `response.failed` event (`response.error`) with a string message is an + error part, and the finish reason becomes `error` with the code, else the + event type, as `raw`. A malformed chunk also makes the finish reason + `error`. Before, only a top-level `error` field was read, so `error` events + carrying the message on the event and `response.failed` events passed + unnoticed, and the finish reason stayed `other`. - `default_providers()`, `openai()` and `anthropic()` no longer panic on an invalid `OPENAI_BASE_URL` / `ANTHROPIC_BASE_URL`. The variable is still read when the provider is created, as the AI SDK does; an invalid value now diff --git a/aimux-providers/src/huggingface/responses.rs b/aimux-providers/src/huggingface/responses.rs index b8a506e16..baba665c8 100644 --- a/aimux-providers/src/huggingface/responses.rs +++ b/aimux-providers/src/huggingface/responses.rs @@ -91,37 +91,73 @@ fn huggingface_successful_response_handler() -> aimux_provider_utils::ResponseHa }) } -fn huggingface_stream_error( - event: &Value, - url: &str, - request_body_values: Value, - response_headers: HashMap, -) -> Option { - let error = event.get("error").filter(|value| !value.is_null())?; - let message = error - .as_str() - .or_else(|| error.get("message").and_then(Value::as_str)) - .unwrap_or("Hugging Face stream failed"); - let status_code = error - .get("status") - .or_else(|| error.get("code")) - .and_then(Value::as_u64) - .and_then(|status| u16::try_from(status).ok()) - .filter(|status| (400..=599).contains(status)); - let provider_code = error - .get("code") - .or_else(|| error.get("type")) - .and_then(Value::as_str) - .map(str::to_owned); - Some(aimux_provider_utils::stream_error_api_call( - message, - provider_code, - status_code, - event, - url, - request_body_values, - response_headers, - )) +/// An in-stream error event, read like upstream +/// `createHuggingFaceResponsesStreamError`: only `error` and `response.failed` +/// events, with a string message. +struct HuggingFaceStreamError<'a> { + message: &'a str, + event_type: &'a str, + code: Option, + details: &'a Value, +} + +impl<'a> HuggingFaceStreamError<'a> { + fn parse(event: &'a Value) -> Option { + let event_type = event.get("type").and_then(Value::as_str)?; + let details = match event_type { + "response.failed" => event.get("response")?.get("error")?, + "error" => event + .get("error") + .filter(|error| error.is_object()) + .unwrap_or(event), + _ => return None, + }; + let message = details.get("message").and_then(Value::as_str)?; + let code = details.get("code").and_then(|code| match code { + Value::String(code) => Some(code.clone()), + Value::Number(code) => Some(code.to_string()), + _ => None, + }); + Some(Self { + message, + event_type, + code, + details, + }) + } + + /// The finish reason's `raw`: the code, else the event type. + fn raw_finish_reason(&self) -> String { + self.code + .clone() + .unwrap_or_else(|| self.event_type.to_string()) + } + + fn into_error( + self, + event: &Value, + url: &str, + request_body_values: Value, + response_headers: HashMap, + ) -> AiMuxError { + let status_code = self + .details + .get("status") + .or_else(|| self.details.get("code")) + .and_then(Value::as_u64) + .and_then(|status| u16::try_from(status).ok()) + .filter(|status| (400..=599).contains(status)); + let provider_code = Some(self.raw_finish_reason()); + aimux_provider_utils::stream_error_api_call( + self.message, + provider_code, + status_code, + event, + url, + request_body_values, + response_headers, + ) + } } // ───────────────────────────────────────────────────────────────────────────── @@ -234,10 +270,9 @@ impl LanguageModel for HuggingFaceResponsesModel { first_event => first_event, }; if let Some(Ok(event)) = first_event.as_ref() - && let Some(error) = - huggingface_stream_error(event, &endpoint, body.clone(), response_headers.clone()) + && let Some(error) = HuggingFaceStreamError::parse(event) { - return Err(error); + return Err(error.into_error(event, &endpoint, body.clone(), response_headers.clone())); } let stream = pipe_through( futures::stream::iter(first_event).chain(sse_stream), @@ -312,12 +347,17 @@ impl HuggingFaceResponsesStream { } = *self; match event { Ok(parsed) => { - if let Some(error) = huggingface_stream_error( - &parsed, - &self.ctx.url, - self.ctx.request_body.clone(), - self.ctx.response_headers.clone(), - ) { + if let Some(error) = HuggingFaceStreamError::parse(&parsed) { + *finish_reason = FinishReason { + unified: FinishReasonUnified::Error, + raw: Some(error.raw_finish_reason()), + }; + let error = error.into_error( + &parsed, + &self.ctx.url, + self.ctx.request_body.clone(), + self.ctx.response_headers.clone(), + ); controller.enqueue(StreamPart::Error { error }); self.finished = true; return; @@ -532,6 +572,10 @@ impl HuggingFaceResponsesStream { } } Err(error) => { + *finish_reason = FinishReason { + unified: FinishReasonUnified::Error, + raw: None, + }; if error.is_recoverable_stream_error() { controller.enqueue_error(error); } else { diff --git a/aimux-providers/tests/huggingface_responses_test.rs b/aimux-providers/tests/huggingface_responses_test.rs index dae21e0c6..023fe1fc3 100644 --- a/aimux-providers/tests/huggingface_responses_test.rs +++ b/aimux-providers/tests/huggingface_responses_test.rs @@ -2015,3 +2015,88 @@ async fn mcp_call_with_empty_output_has_no_tool_result() { assert_eq!(result.content.len(), 1); assert!(matches!(result.content[0], GenerateContent::ToolCall(_))); } + +/// aimux's own: an `error` or `response.failed` event after the first one is +/// an error part, and the finish reason becomes error with the code (else the +/// event type) as raw (huggingface-responses-language-model.ts:406-419, +/// :562-590). Upstream's cases send the error as the first event, which aimux +/// returns from `do_stream` instead (registered SSE pre-read). +#[tokio::test] +async fn reads_mid_stream_error_events() { + let created = r#"{"type":"response.created","response":{"id":"resp_test","object":"response","created_at":1741269019,"status":"in_progress","model":"deepseek-ai/DeepSeek-V3-0324"},"sequence_number":0}"#; + for (event, message, raw) in [ + ( + r#"{"type":"response.failed","response":{"error":{"code":"429","message":"Rate limit reached"}},"sequence_number":1}"#, + "Rate limit reached", + "429", + ), + ( + r#"{"type":"error","message":"Service unavailable","param":null,"sequence_number":1}"#, + "Service unavailable", + "error", + ), + ] { + let server = MockServer::start().await; + mock_sse(&server, sse_body(&[sse_event(created), sse_event(event)])).await; + + let result = make_provider(&server) + .responses("deepseek-ai/DeepSeek-V3-0324") + .do_stream(&default_options(test_prompt())) + .await + .expect("the first event is not an error"); + let parts = collect_stream(result).await; + + let error = parts + .iter() + .find_map(|part| match part { + StreamPart::Error { error } => Some(error), + _ => None, + }) + .expect("an error part"); + match error { + AiMuxError::ApiCall(error) => { + assert_eq!(error.message, message); + assert_eq!(error.provider_code.as_deref(), Some(raw)); + } + other => panic!("expected ApiCall, got {other:?}"), + } + match parts.last() { + Some(StreamPart::Finish { finish_reason, .. }) => { + assert_eq!(finish_reason.unified, FinishReasonUnified::Error); + assert_eq!(finish_reason.raw.as_deref(), Some(raw)); + } + other => panic!("expected Finish last, got {other:?}"), + } + } +} + +/// aimux's own: a chunk that fails to parse is an error part and, when no +/// later `response.completed` sets it again, the finish reason is error with +/// no raw value (huggingface-responses-language-model.ts:396-404). +#[tokio::test] +async fn malformed_chunk_ends_with_an_error_finish() { + let server = MockServer::start().await; + let chunks = sse_body(&[ + sse_event( + r#"{"type":"response.output_item.added","output_index":0,"item":{"id":"msg_test","type":"message","role":"assistant"},"sequence_number":1}"#, + ), + "data:invalid json}\n\n".to_string(), + ]); + mock_sse(&server, chunks).await; + + let result = make_provider(&server) + .responses("deepseek-ai/DeepSeek-V3-0324") + .do_stream(&default_options(test_prompt())) + .await + .expect("should succeed"); + + let outcomes: Vec<_> = result.stream.collect().await; + assert!(outcomes.iter().any(Result::is_err), "a parse error item"); + match outcomes.last() { + Some(Ok(StreamPart::Finish { finish_reason, .. })) => { + assert_eq!(finish_reason.unified, FinishReasonUnified::Error); + assert_eq!(finish_reason.raw, None); + } + other => panic!("expected Finish last, got {other:?}"), + } +} From 84c23353cbcbab5c3b5895f6890dbafc2a60f33b Mon Sep 17 00:00:00 2001 From: cunninghamcard-bit Date: Tue, 20 Oct 2026 08:33:06 +0000 Subject: [PATCH 10/10] test(huggingface): the error-text exclusions still hold after #268 #268 kept the NoSuchModel and UnsupportedFunctionality messages, so the three cases stay sub-feature; the reasons no longer point at it. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01RoxcKU4zBD64Lri3e4Lrq5 --- aimux-providers/layout.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/aimux-providers/layout.toml b/aimux-providers/layout.toml index a03060f84..14f5e4179 100644 --- a/aimux-providers/layout.toml +++ b/aimux-providers/layout.toml @@ -26,8 +26,8 @@ status = "mapped" [huggingface.files.tests] sub-feature = [ - { case = "HuggingFaceProvider › unsupported functionality › should throw for text embedding models", issue = 276, reason = "the NoSuchModelError carries no custom message in aimux-core yet; the core error type changes in #268" }, - { case = "HuggingFaceProvider › unsupported functionality › should throw for image models", issue = 276, reason = "the NoSuchModelError carries no custom message in aimux-core yet; the core error type changes in #268" }, + { case = "HuggingFaceProvider › unsupported functionality › should throw for text embedding models", issue = 276, reason = "aimux-core NoSuchModel carries only model_id and model_type and formats as \"no such model: …\", without the provider's own message" }, + { case = "HuggingFaceProvider › unsupported functionality › should throw for image models", issue = 276, reason = "aimux-core NoSuchModel carries only model_id and model_type and formats as \"no such model: …\", without the provider's own message" }, ] [[huggingface.files]] @@ -68,7 +68,7 @@ registered = [ { case = "HuggingFaceResponsesLanguageModel › doStream › preserves $expectedType stream errors", ref = "SSE 预读", reason = "an error as the first SSE event is returned by do_stream as Err, not as an error part of the stream" }, ] sub-feature = [ - { case = "HuggingFaceResponsesLanguageModel › message conversion › should throw for file parts with provider references", issue = 276, reason = "aimux-core formats UnsupportedFunctionalityError as \"unsupported functionality: …\", not upstream's \"'…' functionality not supported.\"; the core error type changes in #268" }, + { case = "HuggingFaceResponsesLanguageModel › message conversion › should throw for file parts with provider references", issue = 276, reason = "aimux-core formats UnsupportedFunctionalityError as \"unsupported functionality: …\", not upstream's \"'…' functionality not supported.\"" }, ] [[huggingface.files]]