diff --git a/aimux-providers/layout.toml b/aimux-providers/layout.toml index 0c6021f54..c30cef22b 100644 --- a/aimux-providers/layout.toml +++ b/aimux-providers/layout.toml @@ -2440,3 +2440,96 @@ status = "mapped" upstream = "src/version.ts" status = "not-applicable" reason = "VERSION is the package version string; aimux pins it in shared/aisdk_pins.rs, which the user-agent tests read" + +[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 = "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]] +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.\"" }, +] + +[[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)" diff --git a/aimux-providers/src/huggingface.rs b/aimux-providers/src/huggingface.rs index ce76d95b2..3cb6bb41e 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/tests/huggingface_responses_test.rs b/aimux-providers/tests/huggingface_responses_test.rs index 01d5adf49..08ccb03ef 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; @@ -588,11 +1186,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; @@ -682,7 +1277,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; @@ -725,47 +1321,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; @@ -815,7 +1373,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; @@ -845,85 +1404,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; @@ -955,100 +1437,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; @@ -1088,11 +1478,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" +/// 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; @@ -1185,7 +1573,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; @@ -1287,11 +1676,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; @@ -1375,63 +1761,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; @@ -1510,7 +1841,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; @@ -1649,333 +1981,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;