From 37523b89929105e06cd70effd295daa6221244a2 Mon Sep 17 00:00:00 2001 From: chenhaonan Date: Mon, 5 Oct 2026 11:51:21 +0800 Subject: [PATCH 01/10] test(providers): remove hand-written mock-server tests that build provider prompts by hand What: deletes 34 test files under aimux-providers/tests. Each one builds provider prompts from the old message struct and asserts request JSON or parsed output that a hand-written mock server returns. Why: the next commit replaces the provider prompt type. These files pin Rust's own earlier behaviour rather than a recording or an upstream sample, so they are removed instead of being rewritten. The same files are removed on the provider factory branch. Recorded-cassette tests, upstream-sample tests and the remaining suites stay and are converted. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01WGwanvo9WLU9HWD7sRR8TS --- .../anthropic_assistant_tool_result_test.rs | 823 ----- .../tests/anthropic_aws_model_test.rs | 592 ---- .../tests/anthropic_cache_control_test.rs | 729 ---- .../tests/anthropic_convert_full_test.rs | 773 ----- .../tests/anthropic_convert_test.rs | 1022 ------ aimux-providers/tests/anthropic_model_test.rs | 1957 ----------- .../tests/anthropic_provider_tools_test.rs | 2071 ----------- .../tests/anthropic_remaining_test.rs | 675 ---- aimux-providers/tests/azure_model_test.rs | 917 ----- aimux-providers/tests/azure_responses_test.rs | 1052 ------ aimux-providers/tests/bedrock_convert_test.rs | 1410 -------- aimux-providers/tests/bedrock_model_test.rs | 749 ---- .../tests/bedrock_reasoning_test.rs | 1357 -------- .../tests/bedrock_remaining_test.rs | 1359 -------- aimux-providers/tests/body_overrides_test.rs | 286 -- aimux-providers/tests/cohere_model_test.rs | 1591 --------- .../tests/cohere_remaining_test.rs | 767 ----- .../tests/deepseek_reasoning_test.rs | 954 ------ aimux-providers/tests/google_model_test.rs | 2706 --------------- .../tests/google_provider_tools_test.rs | 2594 -------------- .../tests/google_remaining_test.rs | 1440 -------- aimux-providers/tests/mistral_model_test.rs | 1638 --------- .../tests/mistral_remaining_test.rs | 860 ----- .../tests/openai_convert_extended_test.rs | 953 ------ aimux-providers/tests/openai_convert_test.rs | 826 ----- .../tests/openai_model_extended_test.rs | 935 ----- aimux-providers/tests/openai_model_test.rs | 2139 ------------ aimux-providers/tests/openai_provider_test.rs | 282 -- .../tests/openai_responses_test.rs | 1485 -------- .../tests/vertex_anthropic_test.rs | 565 --- aimux-providers/tests/vertex_model_test.rs | 1431 -------- .../tests/vertex_remaining_test.rs | 504 --- aimux-providers/tests/xai_responses_test.rs | 3041 ----------------- aimux-providers/tests/xai_test.rs | 2817 --------------- 34 files changed, 43300 deletions(-) delete mode 100644 aimux-providers/tests/anthropic_assistant_tool_result_test.rs delete mode 100644 aimux-providers/tests/anthropic_aws_model_test.rs delete mode 100644 aimux-providers/tests/anthropic_cache_control_test.rs delete mode 100644 aimux-providers/tests/anthropic_convert_full_test.rs delete mode 100644 aimux-providers/tests/anthropic_convert_test.rs delete mode 100644 aimux-providers/tests/anthropic_model_test.rs delete mode 100644 aimux-providers/tests/anthropic_provider_tools_test.rs delete mode 100644 aimux-providers/tests/anthropic_remaining_test.rs delete mode 100644 aimux-providers/tests/azure_model_test.rs delete mode 100644 aimux-providers/tests/azure_responses_test.rs delete mode 100644 aimux-providers/tests/bedrock_convert_test.rs delete mode 100644 aimux-providers/tests/bedrock_model_test.rs delete mode 100644 aimux-providers/tests/bedrock_reasoning_test.rs delete mode 100644 aimux-providers/tests/bedrock_remaining_test.rs delete mode 100644 aimux-providers/tests/body_overrides_test.rs delete mode 100644 aimux-providers/tests/cohere_model_test.rs delete mode 100644 aimux-providers/tests/cohere_remaining_test.rs delete mode 100644 aimux-providers/tests/deepseek_reasoning_test.rs delete mode 100644 aimux-providers/tests/google_model_test.rs delete mode 100644 aimux-providers/tests/google_provider_tools_test.rs delete mode 100644 aimux-providers/tests/google_remaining_test.rs delete mode 100644 aimux-providers/tests/mistral_model_test.rs delete mode 100644 aimux-providers/tests/mistral_remaining_test.rs delete mode 100644 aimux-providers/tests/openai_convert_extended_test.rs delete mode 100644 aimux-providers/tests/openai_convert_test.rs delete mode 100644 aimux-providers/tests/openai_model_extended_test.rs delete mode 100644 aimux-providers/tests/openai_model_test.rs delete mode 100644 aimux-providers/tests/openai_provider_test.rs delete mode 100644 aimux-providers/tests/openai_responses_test.rs delete mode 100644 aimux-providers/tests/vertex_anthropic_test.rs delete mode 100644 aimux-providers/tests/vertex_model_test.rs delete mode 100644 aimux-providers/tests/vertex_remaining_test.rs delete mode 100644 aimux-providers/tests/xai_responses_test.rs delete mode 100644 aimux-providers/tests/xai_test.rs diff --git a/aimux-providers/tests/anthropic_assistant_tool_result_test.rs b/aimux-providers/tests/anthropic_assistant_tool_result_test.rs deleted file mode 100644 index 9e38d8eff..000000000 --- a/aimux-providers/tests/anthropic_assistant_tool_result_test.rs +++ /dev/null @@ -1,823 +0,0 @@ -//! Assistant-role `ContentPart::ToolResult` → Anthropic provider-executed -//! result blocks. -//! -//! Anthropic only accepts a bare `tool_result` block inside a **user** message. -//! On an **assistant** message the converter has to emit the typed -//! provider-executed block (`web_search_tool_result`, `mcp_tool_result`, …) -//! instead, or the follow-up request is rejected with HTTP 400. -//! -//! Core routes provider-executed calls and results into the same assistant -//! response message; these converter tests pin the replay wire blocks directly. -//! -//! Mirrors the assistant `case 'tool-result'` branch of -//! `reference/vercel-ai/anthropic/src/convert-to-anthropic-prompt.ts` (:871-1285). - -use aimux_core::tool::ToolResult; -use serde_json::{Value, json}; -use wiremock::matchers::{method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -use aimux_core::content::ContentPart; -use aimux_core::generate::{GenerateTextOptions, generate_text}; -use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{ - LanguageModelPrompt, LanguageModelPromptMessage, convert_to_language_model_prompt, -}; -use aimux_core::message::{MessageContent, ModelMessage, Role}; -use aimux_core::options::CallOptions; -use aimux_core::result::GenerateContent; -use aimux_core::tool::{ProviderTool, Tool}; -use aimux_providers::anthropic::AnthropicConfig; -use aimux_providers::anthropic::convert::build_request_body_with_warnings; -use aimux_providers::anthropic::model::AnthropicModel; - -// ── helpers ───────────────────────────────────────────────────────────────── - -fn provider_tool(id: &str, name: &str) -> Tool { - Tool::Provider(ProviderTool { - id: id.to_string(), - name: name.to_string(), - args: json!({}), - }) -} - -fn tool_result(tool_call_id: &str, tool_name: &str, result: Value) -> ContentPart { - ContentPart::ToolResult { - tool_call_id: tool_call_id.to_string(), - tool_name: Some(tool_name.to_string()), - result, - is_error: None, - preliminary: None, - dynamic: None, - provider_options: None, - } -} - -fn msg(role: Role, content: Vec) -> LanguageModelPromptMessage { - LanguageModelPromptMessage { - role, - content, - provider_options: None, - } -} - -/// Build the request body for `prompt` + `tools` and return -/// `(assistant content blocks, warning messages)`. -fn convert(prompt: LanguageModelPrompt, tools: Vec) -> (Vec, Vec) { - let mut options = CallOptions::new(prompt); - if !tools.is_empty() { - options.tools = Some(tools); - } - let req = build_request_body_with_warnings("claude-sonnet-4-5", &options, false).unwrap(); - let messages = req.body["messages"].as_array().cloned().unwrap_or_default(); - let blocks = messages - .iter() - .filter(|m| m["role"] == "assistant") - .flat_map(|m| m["content"].as_array().cloned().unwrap_or_default()) - .collect(); - let warnings = req - .warnings - .iter() - .map(|w| serde_json::to_string(w).unwrap()) - .collect(); - (blocks, warnings) -} - -// ── provider-executed result blocks ───────────────────────────────────────── - -#[test] -fn assistant_tool_call_replays_programmatic_caller() { - let call = ContentPart::ToolCall { - tool_call_id: "toolu_programmatic".to_string(), - tool_name: "query_database".to_string(), - input: json!({ "sql": "SELECT 1" }), - provider_executed: None, - thought_signature: None, - provider_options: Some( - serde_json::from_value(json!({ - "anthropic": { - "caller": { - "type": "code_execution_20260120", - "toolId": "srvtoolu_code", - } - } - })) - .unwrap(), - ), - }; - - let (blocks, warnings) = convert(vec![msg(Role::Assistant, vec![call])], vec![]); - - assert_eq!( - blocks[0]["caller"], - json!({ - "type": "code_execution_20260120", - "tool_id": "srvtoolu_code", - }) - ); - assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}"); -} - -#[test] -fn assistant_web_search_result_becomes_web_search_tool_result() { - let (blocks, warnings) = convert( - vec![msg( - Role::Assistant, - vec![tool_result( - "srvtoolu_1", - "web_search", - json!([{ - "url": "https://example.com", - "title": "Example", - "pageAge": "3 days", - "encryptedContent": "enc-1", - "type": "web_search_result", - }]), - )], - )], - vec![provider_tool("anthropic.web_search_20250305", "web_search")], - ); - - assert_eq!( - blocks, - vec![json!({ - "type": "web_search_tool_result", - "tool_use_id": "srvtoolu_1", - "content": [{ - "url": "https://example.com", - "title": "Example", - "page_age": "3 days", - "encrypted_content": "enc-1", - "type": "web_search_result", - }], - })] - ); - assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}"); -} - -/// A renamed provider tool must still resolve to the provider block type. -#[test] -fn renamed_web_search_tool_still_resolves_to_provider_block() { - let (blocks, _) = convert( - vec![msg( - Role::Assistant, - vec![tool_result("srvtoolu_1", "mySearch", json!([]))], - )], - vec![provider_tool("anthropic.web_search_20250305", "mySearch")], - ); - - assert_eq!(blocks[0]["type"], "web_search_tool_result"); -} - -#[test] -fn assistant_web_search_error_becomes_error_content() { - let (blocks, _) = convert( - vec![msg( - Role::Assistant, - vec![tool_result( - "srvtoolu_1", - "web_search", - json!({ - "type": "web_search_tool_result_error", - "errorCode": "max_uses_exceeded", - }), - )], - )], - vec![provider_tool("anthropic.web_search_20250305", "web_search")], - ); - - assert_eq!( - blocks[0]["content"], - json!({ - "type": "web_search_tool_result_error", - "error_code": "max_uses_exceeded", - }) - ); -} - -#[test] -fn assistant_mcp_result_becomes_mcp_tool_result() { - let mcp_call = ContentPart::ToolCall { - tool_call_id: "mcptoolu_1".to_string(), - tool_name: "echo".to_string(), - input: json!({ "text": "hi" }), - provider_executed: Some(true), - thought_signature: None, - provider_options: Some( - serde_json::from_value(json!({ - "anthropic": { "type": "mcp-tool-use", "serverName": "my-server" } - })) - .unwrap(), - ), - }; - - let (blocks, warnings) = convert( - vec![msg( - Role::Assistant, - vec![ - mcp_call, - tool_result( - "mcptoolu_1", - "echo", - json!([{ "type": "text", "text": "hi" }]), - ), - ], - )], - vec![], - ); - - assert_eq!( - blocks[0], - json!({ - "type": "mcp_tool_use", - "id": "mcptoolu_1", - "name": "echo", - "input": { "text": "hi" }, - "server_name": "my-server", - }) - ); - assert_eq!( - blocks[1], - json!({ - "type": "mcp_tool_result", - "tool_use_id": "mcptoolu_1", - "is_error": false, - "content": [{ "type": "text", "text": "hi" }], - }) - ); - assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}"); -} - -#[test] -fn renamed_provider_executed_call_replays_as_server_tool_use() { - let call = ContentPart::ToolCall { - tool_call_id: "srvtoolu_1".to_string(), - tool_name: "mySearch".to_string(), - input: json!({ "query": "Rust" }), - provider_executed: Some(true), - thought_signature: None, - provider_options: None, - }; - - let (blocks, warnings) = convert( - vec![msg(Role::Assistant, vec![call])], - vec![provider_tool("anthropic.web_search_20250305", "mySearch")], - ); - - assert_eq!( - blocks, - vec![json!({ - "type": "server_tool_use", - "id": "srvtoolu_1", - "name": "web_search", - "input": { "query": "Rust" }, - })] - ); - assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}"); -} - -#[test] -fn assistant_code_execution_result_becomes_code_execution_tool_result() { - let (blocks, warnings) = convert( - vec![msg( - Role::Assistant, - vec![tool_result( - "srvtoolu_1", - "code_execution", - json!({ - "type": "code_execution_result", - "stdout": "42\n", - "stderr": "", - "return_code": 0, - "content": [], - }), - )], - )], - vec![provider_tool( - "anthropic.code_execution_20250522", - "code_execution", - )], - ); - - assert_eq!( - blocks, - vec![json!({ - "type": "code_execution_tool_result", - "tool_use_id": "srvtoolu_1", - "content": { - "type": "code_execution_result", - "stdout": "42\n", - "stderr": "", - "return_code": 0, - "content": [], - }, - })] - ); - assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}"); -} - -/// The bash subtool reports through the `code_execution` tool name but needs -/// its own block type. -#[test] -fn assistant_bash_code_execution_result_uses_bash_block() { - let (blocks, _) = convert( - vec![msg( - Role::Assistant, - vec![tool_result( - "srvtoolu_1", - "code_execution", - json!({ - "type": "bash_code_execution_result", - "stdout": "ok", - "stderr": "", - "return_code": 0, - "content": [], - }), - )], - )], - vec![provider_tool( - "anthropic.code_execution_20250825", - "code_execution", - )], - ); - - assert_eq!(blocks[0]["type"], "bash_code_execution_tool_result"); - assert_eq!(blocks[0]["content"]["type"], "bash_code_execution_result"); -} - -#[test] -fn assistant_advisor_result_becomes_advisor_tool_result() { - let (blocks, _) = convert( - vec![msg( - Role::Assistant, - vec![tool_result( - "srvtoolu_1", - "advisor", - json!({ - "type": "advisor_result", - "text": "consider X", - "stopReason": "end_turn", - }), - )], - )], - vec![provider_tool("anthropic.advisor_20260301", "advisor")], - ); - - assert_eq!( - blocks, - vec![json!({ - "type": "advisor_tool_result", - "tool_use_id": "srvtoolu_1", - "content": { - "type": "advisor_result", - "text": "consider X", - "stop_reason": "end_turn", - }, - })] - ); -} - -#[test] -fn assistant_tool_search_result_becomes_tool_search_tool_result() { - let (blocks, _) = convert( - vec![msg( - Role::Assistant, - vec![tool_result( - "srvtoolu_1", - "tool_search_tool_regex", - json!([{ "type": "tool_reference", "toolName": "get_weather" }]), - )], - )], - vec![provider_tool( - "anthropic.tool_search_regex_20251119", - "tool_search_tool_regex", - )], - ); - - assert_eq!( - blocks, - vec![json!({ - "type": "tool_search_tool_result", - "tool_use_id": "srvtoolu_1", - "content": { - "type": "tool_search_tool_search_result", - "tool_references": [{ "type": "tool_reference", "tool_name": "get_weather" }], - }, - })] - ); -} - -#[test] -fn assistant_web_fetch_result_becomes_web_fetch_tool_result() { - let (blocks, _) = convert( - vec![msg( - Role::Assistant, - vec![tool_result( - "srvtoolu_1", - "web_fetch", - json!({ - "type": "web_fetch_result", - "url": "https://example.com", - "retrievedAt": "2025-01-01T00:00:00Z", - "content": { - "type": "document", - "title": "Example", - "citations": { "enabled": true }, - "source": { - "type": "text", - "mediaType": "text/plain", - "data": "hello", - }, - }, - }), - )], - )], - vec![provider_tool("anthropic.web_fetch_20250910", "web_fetch")], - ); - - assert_eq!( - blocks[0]["content"], - json!({ - "type": "web_fetch_result", - "url": "https://example.com", - "retrieved_at": "2025-01-01T00:00:00Z", - "content": { - "type": "document", - "title": "Example", - "citations": { "enabled": true }, - "source": { "type": "text", "media_type": "text/plain", "data": "hello" }, - }, - }) - ); -} - -// ── the fix: unknown tools must not emit a bare `tool_result` ─────────────── - -/// The core of the fix. A `tool_result` block inside an assistant message is a -/// hard HTTP 400 from Anthropic, so an unrecognised provider-executed result -/// must be dropped with a warning rather than emitted. -#[test] -fn unrecognized_assistant_tool_result_is_dropped_with_warning() { - let (blocks, warnings) = convert( - vec![msg( - Role::Assistant, - vec![ - ContentPart::text("done"), - tool_result("call_1", "get_weather", json!({ "temp": 20 })), - ], - )], - vec![], - ); - - assert_eq!(blocks, vec![json!({ "type": "text", "text": "done" })]); - assert!( - blocks.iter().all(|b| b["type"] != "tool_result"), - "assistant messages must never carry a bare tool_result: {blocks:?}" - ); - assert!( - warnings - .iter() - .any(|w| w - .contains("provider executed tool result for tool get_weather is not supported")), - "expected an unsupported-tool warning, got {warnings:?}" - ); -} - -/// A `ToolResult` with no `tool_name` cannot be resolved either. -#[test] -fn assistant_tool_result_without_tool_name_is_dropped_with_warning() { - let (blocks, warnings) = convert( - vec![msg( - Role::Assistant, - vec![ContentPart::tool_result("call_1", json!({ "ok": true }))], - )], - vec![], - ); - - assert!(blocks.is_empty(), "expected no blocks, got {blocks:?}"); - assert!( - warnings.iter().any(|w| w.contains("is not supported")), - "expected a warning, got {warnings:?}" - ); -} - -/// A `code_execution` result whose payload has no recognisable `type` is also -/// dropped (upstream pushes the "not a valid code execution result" warning). -#[test] -fn invalid_code_execution_payload_is_dropped_with_warning() { - let (blocks, warnings) = convert( - vec![msg( - Role::Assistant, - vec![tool_result( - "srvtoolu_1", - "code_execution", - json!({ "stdout": "no type field" }), - )], - )], - vec![provider_tool( - "anthropic.code_execution_20250522", - "code_execution", - )], - ); - - assert!(blocks.is_empty(), "expected no blocks, got {blocks:?}"); - assert!( - warnings - .iter() - .any(|w| w.contains("not a valid code execution result")), - "expected a code-execution warning, got {warnings:?}" - ); -} - -// ── regression: user/tool-role results keep the bare `tool_result` ────────── - -#[test] -fn user_role_tool_result_still_emits_bare_tool_result() { - let mut options = CallOptions::new(vec![msg( - Role::User, - vec![tool_result("call_1", "get_weather", json!("sunny"))], - )]); - options.tools = Some(vec![provider_tool( - "anthropic.web_search_20250305", - "web_search", - )]); - let req = build_request_body_with_warnings("claude-sonnet-4-5", &options, false).unwrap(); - - assert_eq!( - req.body["messages"], - json!([{ - "role": "user", - "content": [{ - "type": "tool_result", - "tool_use_id": "call_1", - "content": "sunny", - }], - }]) - ); -} - -#[test] -fn tool_role_tool_result_still_emits_bare_tool_result() { - let options = CallOptions::new(vec![msg( - Role::Tool, - vec![tool_result( - "call_1", - "web_search", - json!([{ "url": "https://example.com" }]), - )], - )]); - let req = build_request_body_with_warnings("claude-sonnet-4-5", &options, false).unwrap(); - - assert_eq!(req.body["messages"][0]["role"], "user"); - assert_eq!( - req.body["messages"][0]["content"][0]["type"], "tool_result", - "tool-role results must keep the bare block: {}", - req.body["messages"][0] - ); -} - -// ── round-trip: response block → ToolResult → the same wire block ─────────── - -/// The response side (`parse_anthropic_content`) camel-cases the payload; the -/// prompt side has to snake-case it back. Feeding a parsed `ToolResult` -/// straight back into the converter must reproduce the original wire block. -#[tokio::test] -async fn web_search_result_round_trips_through_a_generate_call() { - let wire_block = json!({ - "type": "web_search_tool_result", - "tool_use_id": "srvtoolu_rt", - "content": [{ - "url": "https://example.com", - "title": "Example", - "page_age": "3 days", - "encrypted_content": "enc-1", - "type": "web_search_result", - }], - }); - - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/v1/messages")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "msg_rt", - "type": "message", - "role": "assistant", - "content": [wire_block], - "model": "claude-sonnet-4-5", - "stop_reason": "end_turn", - "stop_sequence": null, - "usage": { "input_tokens": 1, "output_tokens": 1 }, - }))) - .mount(&server) - .await; - - let mut options = CallOptions::new(vec![msg(Role::User, vec![ContentPart::text("Hello")])]); - options.tools = Some(vec![provider_tool( - "anthropic.web_search_20250305", - "mySearch", - )]); - let model = AnthropicModel::new( - "claude-sonnet-4-5".to_string(), - AnthropicConfig::new("test-api-key").with_base_url(server.uri()), - ); - let result = model.do_generate(&options).await.unwrap(); - - // Replay the parsed tool result on an assistant message. - let replayed: Vec = result - .content - .iter() - .filter_map(|c| match c { - GenerateContent::ToolResult(ToolResult { - tool_call_id, - tool_name, - result, - .. - }) => Some(tool_result(tool_call_id, tool_name, result.clone())), - _ => None, - }) - .collect(); - assert_eq!(replayed.len(), 1, "expected one parsed tool result"); - - let (blocks, warnings) = convert( - vec![msg(Role::Assistant, replayed)], - vec![provider_tool("anthropic.web_search_20250305", "mySearch")], - ); - - assert_eq!(blocks, vec![wire_block]); - assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}"); -} - -#[tokio::test] -async fn public_response_messages_replay_server_call_and_result_next_turn() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/v1/messages")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "msg_server_roundtrip", - "type": "message", - "role": "assistant", - "content": [ - { - "type": "server_tool_use", - "id": "srvtoolu_rt", - "name": "web_search", - "input": { "query": "Rust" }, - }, - { - "type": "web_search_tool_result", - "tool_use_id": "srvtoolu_rt", - "content": [{ - "url": "https://example.com", - "title": "Example", - "page_age": "3 days", - "encrypted_content": "enc-1", - "type": "web_search_result", - }], - }, - ], - "model": "claude-sonnet-4-5", - "stop_reason": "end_turn", - "stop_sequence": null, - "usage": { "input_tokens": 1, "output_tokens": 1 }, - }))) - .mount(&server) - .await; - - let tool = provider_tool("anthropic.web_search_20250305", "mySearch"); - let model = AnthropicModel::new( - "claude-sonnet-4-5".to_string(), - AnthropicConfig::new("test-api-key").with_base_url(server.uri()), - ); - let generated = generate_text( - &model, - "Search for Rust", - GenerateTextOptions { - tools: Some(vec![tool.clone()]), - ..Default::default() - }, - ) - .await - .unwrap(); - - let MessageContent::Parts(parts) = &generated.response_messages[0].content else { - panic!("expected multipart response message"); - }; - assert!(matches!( - &parts[0], - ContentPart::ToolCall { - tool_name, - provider_executed: Some(true), - .. - } if tool_name == "mySearch" - )); - assert!(matches!( - &parts[1], - ContentPart::ToolResult { tool_name: Some(name), .. } if name == "mySearch" - )); - - let mut messages = vec![ModelMessage::user("Search for Rust")]; - messages.extend(generated.response_messages); - messages.push(ModelMessage::user("Summarize the result")); - let mut next_options = CallOptions::new(convert_to_language_model_prompt(&messages, None)); - next_options.tools = Some(vec![tool]); - let replay = - build_request_body_with_warnings("claude-sonnet-4-5", &next_options, false).unwrap(); - let assistant = replay.body["messages"] - .as_array() - .unwrap() - .iter() - .find(|message| message["role"] == "assistant") - .unwrap(); - - assert_eq!(assistant["content"][0]["type"], "server_tool_use"); - assert_eq!(assistant["content"][0]["name"], "web_search"); - assert_eq!(assistant["content"][0]["id"], "srvtoolu_rt"); - assert_eq!(assistant["content"][1]["type"], "web_search_tool_result"); - assert_eq!(assistant["content"][1]["tool_use_id"], "srvtoolu_rt"); - assert!( - replay.warnings.is_empty(), - "unexpected warnings: {:?}", - replay.warnings - ); -} - -#[tokio::test] -async fn public_response_messages_replay_renamed_client_executed_provider_tool_name() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/v1/messages")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "msg_client_tool_roundtrip", - "type": "message", - "role": "assistant", - "content": [{ - "type": "tool_use", - "id": "toolu_bash_1", - "name": "bash", - "input": { "command": "pwd" }, - }], - "model": "claude-sonnet-4-5", - "stop_reason": "tool_use", - "stop_sequence": null, - "usage": { "input_tokens": 1, "output_tokens": 1 }, - }))) - .mount(&server) - .await; - - let tool = provider_tool("anthropic.bash_20250124", "terminal"); - let model = AnthropicModel::new( - "claude-sonnet-4-5".to_string(), - AnthropicConfig::new("test-api-key").with_base_url(server.uri()), - ); - let generated = generate_text( - &model, - "Show the current directory", - GenerateTextOptions { - tools: Some(vec![tool.clone()]), - ..Default::default() - }, - ) - .await - .unwrap(); - - let MessageContent::Parts(parts) = &generated.response_messages[0].content else { - panic!("expected multipart response message"); - }; - assert!(matches!( - &parts[0], - ContentPart::ToolCall { - tool_name, - provider_executed: None, - .. - } if tool_name == "terminal" - )); - - let mut messages = vec![ModelMessage::user("Show the current directory")]; - messages.extend(generated.response_messages); - messages.push(ModelMessage::user("Continue")); - let mut next_options = CallOptions::new(convert_to_language_model_prompt(&messages, None)); - next_options.tools = Some(vec![tool]); - let replay = - build_request_body_with_warnings("claude-sonnet-4-5", &next_options, false).unwrap(); - let assistant = replay.body["messages"] - .as_array() - .unwrap() - .iter() - .find(|message| message["role"] == "assistant") - .unwrap(); - - assert_eq!( - assistant["content"], - json!([{ - "type": "tool_use", - "id": "toolu_bash_1", - "name": "bash", - "input": { "command": "pwd" }, - }]) - ); - assert!( - replay.warnings.is_empty(), - "unexpected warnings: {:?}", - replay.warnings - ); -} diff --git a/aimux-providers/tests/anthropic_aws_model_test.rs b/aimux-providers/tests/anthropic_aws_model_test.rs deleted file mode 100644 index d36943a0d..000000000 --- a/aimux-providers/tests/anthropic_aws_model_test.rs +++ /dev/null @@ -1,592 +0,0 @@ -//! Wiremock tests for the Anthropic-AWS (Claude Platform on AWS) provider. -//! -//! Tests cover: -//! - Non-streaming text generation (Anthropic Messages API JSON response) -//! - Non-streaming tool call extraction -//! - Streaming via SSE (Anthropic message events) -//! - API key authentication -//! - Error handling - -use aimux_core::tool::RawToolCall; -use futures::StreamExt; -use serde_json::{Value, json}; -use wiremock::matchers::{header, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -use aimux_core::content::ContentPart; -use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; -use aimux_core::options::CallOptions; -use aimux_core::result::{GenerateContent, StreamResult}; -use aimux_core::stream_part::StreamPart; -use aimux_core::types::FinishReasonUnified; - -use aimux_providers::anthropic_aws::{AnthropicAwsAuth, AnthropicAwsConfig, AnthropicAwsModel}; - -// ── Helpers ────────────────────────────────────────────────────────────────── - -fn test_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] -} - -fn default_options(prompt: LanguageModelPrompt) -> CallOptions { - CallOptions::new(prompt) -} - -fn make_model(server: &MockServer) -> AnthropicAwsModel { - AnthropicAwsModel::new( - "claude-sonnet-4-20250514".to_string(), - AnthropicAwsConfig { - base_url: server.uri(), - auth: AnthropicAwsAuth::ApiKey("test-api-key".to_string()), - api_version: "2023-06-01".to_string(), - workspace_id: None, - api_key_source: None, - retry_config: aimux_provider_utils::RetryConfig::default(), - }, - ) -} - -async fn mock_messages_json(server: &MockServer, status: u16, body: Value) { - Mock::given(method("POST")) - .and(path("/messages")) - .respond_with(ResponseTemplate::new(status).set_body_json(body)) - .mount(server) - .await; -} - -async fn mock_messages_sse(server: &MockServer, sse_body: &str) { - Mock::given(method("POST")) - .and(path("/messages")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(sse_body.to_string()), - ) - .mount(server) - .await; -} - -fn sse(data: &Value) -> String { - format!("data: {data}\n\n") -} - -fn sse_stream(events: &[Value]) -> String { - events.iter().map(sse).collect() -} - -fn as_text(item: &GenerateContent) -> &str { - match item { - GenerateContent::Text { text, .. } => text, - _ => panic!("expected Text content, got {item:?}"), - } -} - -fn as_tool_call(item: &GenerateContent) -> (&str, &str, &str) { - match item { - GenerateContent::ToolCall(RawToolCall { - tool_call_id, - tool_name, - input, - .. - }) => (tool_call_id, tool_name, input), - _ => panic!("expected ToolCall content, got {item:?}"), - } -} - -async fn collect_stream(result: StreamResult) -> Vec { - let mut parts = Vec::new(); - let mut stream = result.stream; - while let Some(part) = stream.next().await { - match part { - Ok(p) => parts.push(p), - Err(e) => panic!("stream error: {e:?}"), - } - } - parts -} - -fn text_response(text: &str) -> Value { - json!({ - "id": "msg_017TfcQ4AgGxKyBduUpqYPZn", - "type": "message", - "role": "assistant", - "content": [{ "type": "text", "text": text }], - "model": "claude-sonnet-4-20250514", - "stop_reason": "end_turn", - "stop_sequence": null, - "usage": { "input_tokens": 4, "output_tokens": 30 } - }) -} - -// ═════════════════════════════════════════════════════════════════════════════ -// doGenerate tests -// ═════════════════════════════════════════════════════════════════════════════ - -/// Test: non-streaming text generation extracts text, usage, and finish reason. -#[tokio::test] -async fn anthropic_aws_generate_text_response() { - let server = MockServer::start().await; - mock_messages_json(&server, 200, text_response("Hello from Claude on AWS!")).await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!(result.content.len(), 1); - assert_eq!(as_text(&result.content[0]), "Hello from Claude on AWS!"); - assert_eq!(result.finish_reason.unified, FinishReasonUnified::Stop); - assert_eq!(result.usage.input_tokens.total, Some(4)); - assert_eq!(result.usage.output_tokens.total, Some(30)); - assert_eq!( - result.response.id.as_deref(), - Some("msg_017TfcQ4AgGxKyBduUpqYPZn") - ); -} - -/// Test: non-streaming tool call extraction. -#[tokio::test] -async fn anthropic_aws_generate_tool_call() { - let server = MockServer::start().await; - mock_messages_json( - &server, - 200, - json!({ - "id": "msg_001", - "type": "message", - "role": "assistant", - "content": [ - { "type": "text", "text": "I'll check that for you." }, - { - "type": "tool_use", - "id": "toolu_01A", - "name": "getWeather", - "input": { "location": "Paris" } - } - ], - "model": "claude-sonnet-4-20250514", - "stop_reason": "tool_use", - "usage": { "input_tokens": 10, "output_tokens": 20 } - }), - ) - .await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!(result.content.len(), 2); - assert_eq!(as_text(&result.content[0]), "I'll check that for you."); - - let (id, name, input) = as_tool_call(&result.content[1]); - assert_eq!(id, "toolu_01A"); - assert_eq!(name, "getWeather"); - assert_eq!(input, r#"{"location":"Paris"}"#); - assert_eq!(result.finish_reason.unified, FinishReasonUnified::ToolCalls); -} - -/// Test: HTTP error status is propagated. -#[tokio::test] -async fn anthropic_aws_generate_error() { - let server = MockServer::start().await; - mock_messages_json( - &server, - 429, - json!({ - "type": "error", - "error": { "type": "rate_limit_error", "message": "Too many requests" } - }), - ) - .await; - - let model = make_model(&server); - let result = model.do_generate(&default_options(test_prompt())).await; - - assert!(result.is_err(), "should return error for 429 status"); -} - -/// Test: request body uses Anthropic format with anthropic-version header. -#[tokio::test] -async fn anthropic_aws_request_body_and_headers() { - let server = MockServer::start().await; - - // Mount a mock that also verifies the x-api-key header. - Mock::given(method("POST")) - .and(path("/messages")) - .and(header("x-api-key", "test-api-key")) - .and(header("anthropic-version", "2023-06-01")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_response("OK"))) - .mount(&server) - .await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!(as_text(&result.content[0]), "OK"); - - // Verify request body format. - let body = result.request_body.expect("should have request body"); - assert_eq!(body["model"], "claude-sonnet-4-20250514"); - assert_eq!(body["messages"][0]["role"], "user"); - assert_eq!(body["messages"][0]["content"][0]["text"], "Hello"); - assert_eq!(body["max_tokens"], 64000); // model default for claude-sonnet-4 -} - -// ═════════════════════════════════════════════════════════════════════════════ -// doStream tests -// ═════════════════════════════════════════════════════════════════════════════ - -/// Test: streaming text via Anthropic SSE events. -#[tokio::test] -async fn anthropic_aws_stream_text() { - let server = MockServer::start().await; - - let sse_body = sse_stream(&[ - json!({ - "type": "message_start", - "message": { - "id": "msg_001", - "model": "claude-sonnet-4-20250514", - "usage": { "input_tokens": 3 } - } - }), - json!({ - "type": "content_block_start", - "index": 0, - "content_block": { "type": "text", "text": "" } - }), - json!({ - "type": "content_block_delta", - "index": 0, - "delta": { "type": "text_delta", "text": "Hello" } - }), - json!({ - "type": "content_block_delta", - "index": 0, - "delta": { "type": "text_delta", "text": " streamed!" } - }), - json!({ - "type": "content_block_stop", - "index": 0 - }), - json!({ - "type": "message_delta", - "delta": { "stop_reason": "end_turn" }, - "usage": { "output_tokens": 5 } - }), - json!({ "type": "message_stop" }), - ]); - - mock_messages_sse(&server, &sse_body).await; - - let model = make_model(&server); - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - - let parts = collect_stream(result).await; - - let text_deltas: Vec = parts - .iter() - .filter_map(|p| match p { - StreamPart::TextDelta { delta, .. } => Some(delta.clone()), - _ => None, - }) - .collect(); - assert_eq!(text_deltas, vec!["Hello", " streamed!"]); - - let finish = parts.iter().find_map(|p| match p { - StreamPart::Finish { finish_reason, .. } => Some(finish_reason.clone()), - _ => None, - }); - assert!(finish.is_some()); - assert_eq!(finish.unwrap().unified, FinishReasonUnified::Stop); -} - -/// Test: streaming tool calls via Anthropic SSE events. -#[tokio::test] -async fn anthropic_aws_stream_tool_call() { - let server = MockServer::start().await; - - let sse_body = sse_stream(&[ - json!({ - "type": "message_start", - "message": { - "id": "msg_002", - "model": "claude-sonnet-4-20250514", - "usage": { "input_tokens": 5 } - } - }), - json!({ - "type": "content_block_start", - "index": 0, - "content_block": { - "type": "tool_use", - "id": "toolu_01B", - "name": "getWeather", - "input": {} - } - }), - json!({ - "type": "content_block_delta", - "index": 0, - "delta": { "type": "input_json_delta", "partial_json": "{\"location\":" } - }), - json!({ - "type": "content_block_delta", - "index": 0, - "delta": { "type": "input_json_delta", "partial_json": "\"Berlin\"}" } - }), - json!({ - "type": "content_block_stop", - "index": 0 - }), - json!({ - "type": "message_delta", - "delta": { "stop_reason": "tool_use" }, - "usage": { "output_tokens": 10 } - }), - json!({ "type": "message_stop" }), - ]); - - mock_messages_sse(&server, &sse_body).await; - - let model = make_model(&server); - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - - let parts = collect_stream(result).await; - - let tool_calls: Vec<_> = parts - .iter() - .filter_map(|p| match p { - StreamPart::ToolCall(RawToolCall { - tool_call_id, - tool_name, - input, - .. - }) => Some((tool_call_id.clone(), tool_name.clone(), input.clone())), - _ => None, - }) - .collect(); - assert_eq!(tool_calls.len(), 1); - assert_eq!(tool_calls[0].0, "toolu_01B"); - assert_eq!(tool_calls[0].1, "getWeather"); - assert_eq!(tool_calls[0].2, r#"{"location":"Berlin"}"#); -} - -/// Test: SigV4 authentication adds Authorization header. -#[tokio::test] -async fn anthropic_aws_sigv4_auth() { - let server = MockServer::start().await; - mock_messages_json(&server, 200, text_response("Signed!")).await; - - let model = AnthropicAwsModel::new( - "claude-sonnet-4-20250514".to_string(), - AnthropicAwsConfig { - base_url: server.uri(), - auth: AnthropicAwsAuth::SigV4(aimux_providers::bedrock::AwsCredentials { - access_key_id: "AKIAIOSFODNN7EXAMPLE".to_string(), - secret_access_key: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".to_string(), - session_token: None, - region: "us-east-1".to_string(), - }), - api_version: "2023-06-01".to_string(), - workspace_id: None, - api_key_source: None, - retry_config: aimux_provider_utils::RetryConfig::default(), - }, - ); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed with SigV4"); - - assert_eq!(as_text(&result.content[0]), "Signed!"); -} - -// ═════════════════════════════════════════════════════════════════════════════ -// Additional cases — finish reasons, settings, usage, headers, errors. -// ═════════════════════════════════════════════════════════════════════════════ - -/// TS: max_tokens stop reason → Length -#[tokio::test] -async fn anthropic_aws_generate_finish_reason_max_tokens() { - let server = MockServer::start().await; - mock_messages_json( - &server, - 200, - json!({ - "id": "msg_max", - "type": "message", - "role": "assistant", - "content": [{ "type": "text", "text": "truncated" }], - "model": "claude-sonnet-4-20250514", - "stop_reason": "max_tokens", - "usage": { "input_tokens": 4, "output_tokens": 30 } - }), - ) - .await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - assert_eq!(result.finish_reason.unified, FinishReasonUnified::Length); - assert_eq!(result.finish_reason.raw.as_deref(), Some("max_tokens")); -} - -/// TS: an unknown stop reason → Other -#[tokio::test] -async fn anthropic_aws_generate_finish_reason_unknown() { - let server = MockServer::start().await; - mock_messages_json( - &server, - 200, - json!({ - "id": "msg_unk", - "type": "message", - "role": "assistant", - "content": [{ "type": "text", "text": "hi" }], - "model": "claude-sonnet-4-20250514", - "stop_reason": "pause_turn", - "usage": { "input_tokens": 4, "output_tokens": 30 } - }), - ) - .await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - assert_eq!(result.finish_reason.unified, FinishReasonUnified::Other); - assert_eq!(result.finish_reason.raw.as_deref(), Some("pause_turn")); -} - -/// TS: settings — max_tokens override, temperature, top_p land in the body. -#[tokio::test] -async fn anthropic_aws_generate_settings() { - let server = MockServer::start().await; - mock_messages_json(&server, 200, text_response("ok")).await; - - let model = make_model(&server); - let mut opts = default_options(test_prompt()); - opts.max_output_tokens = Some(1024); - opts.temperature = Some(0.5); - opts.top_p = Some(0.9); - opts.stop_sequences = Some(vec!["END".to_string()]); - - let result = model.do_generate(&opts).await.expect("should succeed"); - let body = result.request_body.expect("body"); - assert_eq!(body["max_tokens"], json!(1024)); - assert!((body["temperature"].as_f64().unwrap() - 0.5).abs() < 1e-6); - assert!((body["top_p"].as_f64().unwrap() - 0.9).abs() < 1e-6); - assert_eq!(body["stop_sequences"], json!(["END"])); -} - -/// TS: a 401 response maps to `AiMuxError::ApiCall` (401 in `status_code`). -#[tokio::test] -async fn anthropic_aws_generate_auth_error() { - let server = MockServer::start().await; - mock_messages_json( - &server, - 401, - json!({ "type": "error", "error": { "type": "authentication_error", "message": "invalid x-api-key" } }), - ) - .await; - - let model = make_model(&server); - let result = model.do_generate(&default_options(test_prompt())).await; - assert!( - matches!(result, Err(ref e) if e.status_code() == Some(401)), - "expected Auth, got {result:?}" - ); -} - -/// TS: a 429 response maps to `AiMuxError::ApiCall` (429 in `status_code`). -#[tokio::test] -async fn anthropic_aws_generate_rate_limit_error() { - let server = MockServer::start().await; - mock_messages_json( - &server, - 429, - json!({ "type": "error", "error": { "type": "rate_limit_error", "message": "Too many requests" } }), - ) - .await; - - let model = make_model(&server); - let result = model.do_generate(&default_options(test_prompt())).await; - assert!( - matches!(result, Err(ref e) if e.status_code() == Some(429)), - "expected RateLimited, got {result:?}" - ); -} - -/// TS: response headers are exposed on the stream result. -#[tokio::test] -async fn anthropic_aws_stream_response_headers() { - let server = MockServer::start().await; - let sse_body = sse_stream(&[ - json!({ - "type": "message_start", - "message": { "id": "msg_h", "model": "claude-sonnet-4-20250514", "usage": { "input_tokens": 3 } } - }), - json!({ - "type": "content_block_start", "index": 0, - "content_block": { "type": "text", "text": "" } - }), - json!({ - "type": "content_block_delta", "index": 0, - "delta": { "type": "text_delta", "text": "Hi" } - }), - json!({ "type": "content_block_stop", "index": 0 }), - json!({ - "type": "message_delta", - "delta": { "stop_reason": "end_turn" }, - "usage": { "output_tokens": 1 } - }), - json!({ "type": "message_stop" }), - ]); - Mock::given(method("POST")) - .and(path("/messages")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .insert_header("test-header", "test-value") - .set_body_string(sse_body), - ) - .mount(&server) - .await; - - let model = make_model(&server); - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - - let headers = result - .response_headers - .as_ref() - .expect("response_headers should be Some"); - assert_eq!(headers.get("test-header"), Some(&"test-value".to_string())); -} diff --git a/aimux-providers/tests/anthropic_cache_control_test.rs b/aimux-providers/tests/anthropic_cache_control_test.rs deleted file mode 100644 index 4485de6d0..000000000 --- a/aimux-providers/tests/anthropic_cache_control_test.rs +++ /dev/null @@ -1,729 +0,0 @@ -// Panic convert wrappers are #[deprecated]; these tests still use them. -#![allow(deprecated)] -//! Rust port of the Anthropic `cache_control` tests. -//! -//! Translated from the Vercel AI SDK TypeScript test suites: -//! - `packages/anthropic/src/convert-to-anthropic-prompt.test.ts` -//! `describe('cache control')` (L3557-4263) — conversion-level tests that -//! verify `cache_control` is emitted on the correct Anthropic content blocks. -//! - `packages/anthropic/src/anthropic-language-model.test.ts` -//! `it('should support cache control')` (L2298) and -//! `it('should support cache control and return extra fields in provider metadata')` (L2457) -//! — exercised here at the conversion level (the request-body shape that -//! `doGenerate` builds), since the cache_control feature lives in -//! `convert_prompt_to_anthropic_full`. -//! -//! `cache_control` is read from `providerOptions.anthropic.cacheControl` -//! (`{ "type": "ephemeral" }`) at three levels: -//! 1. **Part-level** — on `ContentPart` (`Text`, `ToolCall`, `ToolResult`, …). -//! 2. **Message-level** — on `LanguageModelPromptMessage.provider_options`, -//! applied to the last part of the message. -//! 3. **Tool-result output-level** — on a structured `output.providerOptions` -//! or on the first content part of a `content` output. -//! -//! A `CacheControlValidator` caps the total breakpoints at 4 and rejects -//! `cache_control` on thinking / redacted-thinking blocks (emitting a warning). - -use aimux_core::content::ContentPart; -use aimux_core::language_model_message::LanguageModelPromptMessage; -use aimux_core::message::Role; -use aimux_core::shared::{SharedProviderOptions, provider_namespace}; -use aimux_core::types::Warning; -use aimux_providers::anthropic::convert::convert_prompt_to_anthropic_full; -use serde_json::{Value, json}; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -fn msg(role: Role, parts: Vec) -> LanguageModelPromptMessage { - LanguageModelPromptMessage { - role, - content: parts, - provider_options: None, - } -} - -/// Build a message with message-level `provider_options` (for message-level -/// cache_control). -fn msg_with_opts( - role: Role, - parts: Vec, - opts: SharedProviderOptions, -) -> LanguageModelPromptMessage { - LanguageModelPromptMessage { - role, - content: parts, - provider_options: Some(opts), - } -} - -fn prompt(msgs: Vec) -> Vec { - msgs -} - -fn convert_full( - msgs: Vec, - send_reasoning: bool, -) -> aimux_providers::anthropic::convert::AnthropicPromptConversion { - convert_prompt_to_anthropic_full(&msgs, send_reasoning) -} - -/// The message-level / part-level `providerOptions` shape carrying -/// `anthropic.cacheControl`. -fn cache_control_opts(cache_control: Value) -> SharedProviderOptions { - provider_namespace("anthropic", json!({ "cacheControl": cache_control })) -} - -/// Create a text part with `provider_options` set (for part-level cache_control). -fn text_part_with_cache_control(t: &str, cache_control: Value) -> ContentPart { - ContentPart::Text { - text: t.to_string(), - provider_options: Some(cache_control_opts(cache_control)), - } -} - -/// Extract the content array from the first message in the conversion result. -fn first_message_content( - result: &aimux_providers::anthropic::convert::AnthropicPromptConversion, -) -> &Vec { - result - .messages - .first() - .expect("expected at least one message") - .get("content") - .and_then(|c| c.as_array()) - .expect("expected content array") -} - -/// Extract the first system block from the conversion result. -fn first_system_block( - result: &aimux_providers::anthropic::convert::AnthropicPromptConversion, -) -> &Value { - result - .system - .as_ref() - .expect("expected system blocks") - .first() - .expect("expected at least one system block") -} - -// =========================================================================== -// describe('cache control') > describe('system message') -// =========================================================================== - -mod system_message { - use super::*; - - // TS: "should set cache_control on system message with message cache control" - // - // Message-level `providerOptions.anthropic.cacheControl` on a system - // message. The cache_control is applied to the system text block. - #[test] - fn should_set_cache_control_on_system_message_with_message_cache_control() { - let p = prompt(vec![msg_with_opts( - Role::System, - vec![ContentPart::text("system message")], - cache_control_opts(json!({ "type": "ephemeral" })), - )]); - let result = convert_full(p, true); - let system = first_system_block(&result); - assert_eq!( - system.get("cache_control"), - Some(&json!({ "type": "ephemeral" })) - ); - } -} - -// =========================================================================== -// describe('cache control') > describe('user message') -// =========================================================================== - -mod user_message { - use super::*; - - // TS: "should set cache_control on user message part with part cache control" - // - // Part-level `providerOptions.anthropic.cacheControl` on a text part. - #[test] - fn should_set_cache_control_on_user_message_part_with_part_cache_control() { - let p = prompt(vec![msg( - Role::User, - vec![text_part_with_cache_control( - "test", - json!({ "type": "ephemeral" }), - )], - )]); - let result = convert_full(p, true); - let content = first_message_content(&result); - assert_eq!(content.len(), 1); - assert_eq!(content[0].get("type"), Some(&json!("text"))); - assert_eq!(content[0].get("text"), Some(&json!("test"))); - assert_eq!( - content[0].get("cache_control"), - Some(&json!({ "type": "ephemeral" })) - ); - } - - // TS: "should set cache_control on last user message part with message cache control" - // - // Message-level `providerOptions.anthropic.cacheControl` applied to the - // last part of the message. - #[test] - fn should_set_cache_control_on_last_user_message_part_with_message_cache_control() { - let p = prompt(vec![msg_with_opts( - Role::User, - vec![ContentPart::text("part1"), ContentPart::text("part2")], - cache_control_opts(json!({ "type": "ephemeral" })), - )]); - let result = convert_full(p, true); - let content = first_message_content(&result); - assert_eq!(content.len(), 2); - // First part: no cache_control - assert!(content[0].get("cache_control").is_none()); - // Last part: cache_control from message-level providerOptions - assert_eq!( - content[1].get("cache_control"), - Some(&json!({ "type": "ephemeral" })) - ); - } -} - -// =========================================================================== -// describe('cache control') > describe('assistant message') -// =========================================================================== - -mod assistant_message { - use super::*; - - // TS: "should set cache_control on assistant message text part with part cache control" - // - // Part-level `providerOptions.anthropic.cacheControl` on an assistant text part. - #[test] - fn should_set_cache_control_on_assistant_message_text_part_with_part_cache_control() { - let p = prompt(vec![ - msg(Role::User, vec![ContentPart::text("user-content")]), - msg( - Role::Assistant, - vec![text_part_with_cache_control( - "test", - json!({ "type": "ephemeral" }), - )], - ), - ]); - let result = convert_full(p, true); - // messages[0] = user, messages[1] = assistant - let assistant_content = result - .messages - .get(1) - .expect("expected assistant message") - .get("content") - .and_then(|c| c.as_array()) - .expect("expected content array"); - assert_eq!(assistant_content.len(), 1); - assert_eq!(assistant_content[0].get("type"), Some(&json!("text"))); - assert_eq!(assistant_content[0].get("text"), Some(&json!("test"))); - assert_eq!( - assistant_content[0].get("cache_control"), - Some(&json!({ "type": "ephemeral" })) - ); - } - - // TS: "should set cache_control on assistant tool call part with part cache control" - // - // Part-level `providerOptions` on a `tool-call` content part. - #[test] - fn should_set_cache_control_on_assistant_tool_call_part_with_part_cache_control() { - let p = prompt(vec![ - msg(Role::User, vec![ContentPart::text("user-content")]), - msg( - Role::Assistant, - vec![ContentPart::ToolCall { - tool_call_id: "test-id".to_string(), - tool_name: "test-tool".to_string(), - input: json!({ "some": "arg" }), - provider_executed: None, - thought_signature: None, - provider_options: Some(cache_control_opts(json!({ "type": "ephemeral" }))), - }], - ), - ]); - let result = convert_full(p, true); - let assistant_content = result - .messages - .get(1) - .expect("expected assistant message") - .get("content") - .and_then(|c| c.as_array()) - .expect("expected content array"); - assert_eq!(assistant_content[0].get("type"), Some(&json!("tool_use"))); - assert_eq!(assistant_content[0].get("name"), Some(&json!("test-tool"))); - assert_eq!(assistant_content[0].get("id"), Some(&json!("test-id"))); - assert_eq!( - assistant_content[0].get("input"), - Some(&json!({ "some": "arg" })) - ); - assert_eq!( - assistant_content[0].get("cache_control"), - Some(&json!({ "type": "ephemeral" })) - ); - } - - // TS: "should wrap non-object (invalid) tool call input in an object" - // - // This lives inside the `describe('assistant message')` cache_control block - // in the TS source. The same case is also covered in - // `anthropic_convert_test.rs`; reproduced here for parity with the TS suite. - #[test] - fn should_wrap_non_object_invalid_tool_call_input_in_an_object() { - let p = prompt(vec![ - msg(Role::User, vec![ContentPart::text("user-content")]), - msg( - Role::Assistant, - vec![ContentPart::tool_call( - "test-id".to_string(), - "test-tool".to_string(), - json!("invalid"), - )], - ), - ]); - let result = convert_full(p, true); - let assistant_content = result - .messages - .get(1) - .expect("expected assistant message") - .get("content") - .and_then(|c| c.as_array()) - .expect("expected content array"); - assert_eq!(assistant_content[0].get("type"), Some(&json!("tool_use"))); - assert_eq!( - assistant_content[0].get("input"), - Some(&json!({ "rawInvalidInput": "invalid" })) - ); - } - - // TS: "should set cache_control on last assistant message part with message cache control" - // - // Message-level `providerOptions.anthropic.cacheControl`. - #[test] - fn should_set_cache_control_on_last_assistant_message_part_with_message_cache_control() { - let p = prompt(vec![ - msg(Role::User, vec![ContentPart::text("user-content")]), - msg_with_opts( - Role::Assistant, - vec![ContentPart::text("part1"), ContentPart::text("part2")], - cache_control_opts(json!({ "type": "ephemeral" })), - ), - ]); - let result = convert_full(p, true); - let assistant_content = result - .messages - .get(1) - .expect("expected assistant message") - .get("content") - .and_then(|c| c.as_array()) - .expect("expected content array"); - assert_eq!(assistant_content.len(), 2); - // First part: no cache_control - assert!(assistant_content[0].get("cache_control").is_none()); - // Last part: cache_control from message-level providerOptions - assert_eq!( - assistant_content[1].get("cache_control"), - Some(&json!({ "type": "ephemeral" })) - ); - } -} - -// =========================================================================== -// describe('cache control') > describe('tool message') -// =========================================================================== - -mod tool_message { - use super::*; - - // TS: "should set cache_control on tool result message part with part cache control" - // - // Part-level `providerOptions` on a `tool-result` content part. - #[test] - fn should_set_cache_control_on_tool_result_message_part_with_part_cache_control() { - let p = prompt(vec![msg( - Role::Tool, - vec![ContentPart::ToolResult { - tool_call_id: "test".to_string(), - result: json!({ "test": "test" }), - tool_name: None, - is_error: None, - preliminary: None, - dynamic: None, - provider_options: Some(cache_control_opts(json!({ "type": "ephemeral" }))), - }], - )]); - let result = convert_full(p, true); - let content = first_message_content(&result); - assert_eq!(content[0].get("type"), Some(&json!("tool_result"))); - assert_eq!(content[0].get("tool_use_id"), Some(&json!("test"))); - assert_eq!( - content[0].get("cache_control"), - Some(&json!({ "type": "ephemeral" })) - ); - } - - // TS: "should set cache_control on tool result with output cache control" - // - // The `output` of a tool-result carries `providerOptions` (here a `text` - // output with `providerOptions.anthropic.cacheControl`). - #[test] - fn should_set_cache_control_on_tool_result_with_output_cache_control() { - let p = prompt(vec![msg( - Role::Tool, - vec![ContentPart::tool_result( - "test".to_string(), - json!({ - "type": "text", - "value": "test", - "providerOptions": cache_control_opts(json!({ "type": "ephemeral" })), - }), - )], - )]); - let result = convert_full(p, true); - let content = first_message_content(&result); - assert_eq!(content[0].get("type"), Some(&json!("tool_result"))); - assert_eq!( - content[0].get("cache_control"), - Some(&json!({ "type": "ephemeral" })) - ); - } - - // TS: "should set cache_control on tool result with content output cache control" - // - // The `output` is a `content` output whose first content part carries - // `providerOptions.anthropic.cacheControl`. - #[test] - fn should_set_cache_control_on_tool_result_with_content_output_cache_control() { - let p = prompt(vec![msg( - Role::Tool, - vec![ContentPart::tool_result( - "test".to_string(), - json!({ - "type": "content", - "value": [{ - "type": "text", - "text": "test", - "providerOptions": cache_control_opts(json!({ "type": "ephemeral" })), - }], - }), - )], - )]); - let result = convert_full(p, true); - let content = first_message_content(&result); - assert_eq!(content[0].get("type"), Some(&json!("tool_result"))); - assert_eq!( - content[0].get("cache_control"), - Some(&json!({ "type": "ephemeral" })) - ); - } - - // TS: "should set cache_control on last tool result message part with message cache control" - // - // Message-level `providerOptions.anthropic.cacheControl`. - #[test] - fn should_set_cache_control_on_last_tool_result_message_part_with_message_cache_control() { - let p = prompt(vec![msg_with_opts( - Role::Tool, - vec![ - ContentPart::tool_result("part1".to_string(), json!({ "test": "part1" })), - ContentPart::tool_result("part2".to_string(), json!({ "test": "part2" })), - ], - cache_control_opts(json!({ "type": "ephemeral" })), - )]); - let result = convert_full(p, true); - let content = first_message_content(&result); - assert_eq!(content.len(), 2); - // First part: no cache_control - assert!(content[0].get("cache_control").is_none()); - // Last part: cache_control from message-level providerOptions - assert_eq!( - content[1].get("cache_control"), - Some(&json!({ "type": "ephemeral" })) - ); - } -} - -// =========================================================================== -// describe('cache control') > describe('cache control validation') -// =========================================================================== - -mod cache_control_validation { - use super::*; - - // TS: "should reject cache_control on thinking blocks" - // - // When a reasoning part (with a signature → thinking block) has - // `providerOptions.anthropic.cacheControl`, the CacheControlValidator emits - // a warning and does NOT include cache_control on the thinking block. - #[test] - fn should_reject_cache_control_on_thinking_blocks() { - let p = prompt(vec![msg( - Role::Assistant, - vec![ContentPart::Reasoning { - text: "thinking content".to_string(), - signature: Some("test-sig".to_string()), - provider_options: Some(cache_control_opts(json!({ "type": "ephemeral" }))), - }], - )]); - let result = convert_full(p, true); - let content = first_message_content(&result); - assert_eq!(content[0].get("type"), Some(&json!("thinking"))); - assert_eq!(content[0].get("thinking"), Some(&json!("thinking content"))); - assert_eq!(content[0].get("signature"), Some(&json!("test-sig"))); - // cache_control should NOT be present on thinking blocks - assert!(content[0].get("cache_control").is_none()); - - // A warning should be emitted about the rejected cache_control. - let has_warning = result.warnings.iter().any(|w| match w { - Warning::Unsupported { - feature, details, .. - } => { - feature == "cache_control on non-cacheable context" - && details - .as_deref() - .is_some_and(|d| d.contains("thinking block")) - } - _ => false, - }); - assert!( - has_warning, - "expected a cache_control rejection warning, got: {:?}", - result.warnings - ); - } - - // TS: "should reject cache_control on redacted thinking blocks" - // - // A reasoning part with `providerOptions.anthropic.redactedData` (and no - // signature) becomes a `redacted_thinking` block; cache_control is rejected. - #[test] - fn should_reject_cache_control_on_redacted_thinking_blocks() { - let p = prompt(vec![msg( - Role::Assistant, - vec![ContentPart::Reasoning { - text: "redacted".to_string(), - signature: None, - provider_options: Some( - serde_json::from_value(json!({ - "anthropic": { - "redactedData": "abc123", - "cacheControl": { "type": "ephemeral" }, - } - })) - .unwrap(), - ), - }], - )]); - let result = convert_full(p, true); - let content = first_message_content(&result); - assert_eq!(content[0].get("type"), Some(&json!("redacted_thinking"))); - assert_eq!(content[0].get("data"), Some(&json!("abc123"))); - // cache_control should NOT be present on redacted thinking blocks - assert!(content[0].get("cache_control").is_none()); - - let has_warning = result.warnings.iter().any(|w| match w { - Warning::Unsupported { - feature, details, .. - } => { - feature == "cache_control on non-cacheable context" - && details - .as_deref() - .is_some_and(|d| d.contains("redacted thinking block")) - } - _ => false, - }); - assert!( - has_warning, - "expected a cache_control rejection warning, got: {:?}", - result.warnings - ); - } -} - -// =========================================================================== -// describe('cache control') > "should limit cache breakpoints to 4" -// =========================================================================== - -mod breakpoint_limit { - use super::*; - - // TS: "should limit cache breakpoints to 4" - // - // The CacheControlValidator tracks the number of cache_control breakpoints - // across the entire prompt and rejects any beyond the 4th. The first 4 - // get cache_control; the 5th is rejected with a warning. - #[test] - fn should_limit_cache_breakpoints_to_4() { - // Build a prompt with 5 cache_control breakpoints: - // 1. system 1 (cache_control) - // 2. system 2 (cache_control) - // 3. user 1 (cache_control) - // 4. assistant 1 (cache_control) - // 5. user 2 (cache_control — should be rejected) - let p = prompt(vec![ - msg( - Role::System, - vec![text_part_with_cache_control( - "system 1", - json!({ "type": "ephemeral" }), - )], - ), - msg( - Role::System, - vec![text_part_with_cache_control( - "system 2", - json!({ "type": "ephemeral" }), - )], - ), - msg( - Role::User, - vec![text_part_with_cache_control( - "user 1", - json!({ "type": "ephemeral" }), - )], - ), - msg( - Role::Assistant, - vec![text_part_with_cache_control( - "assistant 1", - json!({ "type": "ephemeral" }), - )], - ), - msg( - Role::User, - vec![text_part_with_cache_control( - "user 2 (should be rejected)", - json!({ "type": "ephemeral" }), - )], - ), - ]); - let result = convert_full(p, true); - - // First 4 should have cache_control - let system = result.system.as_ref().expect("expected system blocks"); - assert_eq!(system.len(), 2); - assert_eq!( - system[0].get("cache_control"), - Some(&json!({ "type": "ephemeral" })) - ); - assert_eq!( - system[1].get("cache_control"), - Some(&json!({ "type": "ephemeral" })) - ); - - let msg0_content = result - .messages - .first() - .expect("expected message 0") - .get("content") - .and_then(|c| c.as_array()) - .expect("expected content array"); - assert_eq!( - msg0_content[0].get("cache_control"), - Some(&json!({ "type": "ephemeral" })) - ); - - let msg1_content = result - .messages - .get(1) - .expect("expected message 1") - .get("content") - .and_then(|c| c.as_array()) - .expect("expected content array"); - assert_eq!( - msg1_content[0].get("cache_control"), - Some(&json!({ "type": "ephemeral" })) - ); - - // 5th should be rejected (no cache_control) - let msg2_content = result - .messages - .get(2) - .expect("expected message 2") - .get("content") - .and_then(|c| c.as_array()) - .expect("expected content array"); - assert!( - msg2_content[0].get("cache_control").is_none(), - "5th breakpoint should be rejected" - ); - - // Should have warning about exceeding limit - let has_limit_warning = result.warnings.iter().any(|w| match w { - Warning::Unsupported { - feature, details, .. - } => { - feature == "cacheControl breakpoint limit" - && details - .as_deref() - .is_some_and(|d| d.contains("Maximum 4 cache breakpoints exceeded")) - } - _ => false, - }); - assert!( - has_limit_warning, - "expected a breakpoint limit warning, got: {:?}", - result.warnings - ); - } -} - -// =========================================================================== -// Integration tests from anthropic-language-model.test.ts -// -// The TS integration tests exercise the full `doGenerate` path with a mock -// server. The cache_control feature itself lives in -// `convert_prompt_to_anthropic_full` (the request-body builder), so these are -// exercised here at the conversion level — verifying the request shape that -// `doGenerate` would send. -// =========================================================================== - -mod integration { - use super::*; - - // TS: "should support cache control" (L2298) - // - // A user message with message-level `providerOptions.anthropic.cacheControl` - // produces a request whose text block carries `cache_control`. - #[test] - fn should_support_cache_control() { - let p = prompt(vec![msg_with_opts( - Role::User, - vec![ContentPart::text("Hello")], - cache_control_opts(json!({ "type": "ephemeral" })), - )]); - let result = convert_full(p, true); - let content = first_message_content(&result); - assert_eq!(content[0].get("type"), Some(&json!("text"))); - assert_eq!(content[0].get("text"), Some(&json!("Hello"))); - assert_eq!( - content[0].get("cache_control"), - Some(&json!({ "type": "ephemeral" })) - ); - } - - // TS: "should support cache control and return extra fields in provider metadata" (L2457) - // - // Same as above but with `ttl: '1h'`; the request block carries the full - // `cache_control` object including `ttl`. - #[test] - fn should_support_cache_control_and_return_extra_fields_in_provider_metadata() { - let p = prompt(vec![msg_with_opts( - Role::User, - vec![ContentPart::text("Hello")], - cache_control_opts(json!({ "type": "ephemeral", "ttl": "1h" })), - )]); - let result = convert_full(p, true); - let content = first_message_content(&result); - assert_eq!( - content[0].get("cache_control"), - Some(&json!({ "type": "ephemeral", "ttl": "1h" })) - ); - } -} diff --git a/aimux-providers/tests/anthropic_convert_full_test.rs b/aimux-providers/tests/anthropic_convert_full_test.rs deleted file mode 100644 index fc463d68a..000000000 --- a/aimux-providers/tests/anthropic_convert_full_test.rs +++ /dev/null @@ -1,773 +0,0 @@ -// Panic convert wrappers are #[deprecated]; these tests still use them. -#![allow(deprecated)] -//! Rust port of the remaining `convert-to-anthropic-prompt.test.ts` cases that -//! exercise features now supported by the Rust data model: mid-conversation -//! system messages, trailing-whitespace trimming, reasoning/thinking parts, URL -//! & base64 file parts, PDF beta, top-level media-type detection, and -//! provider-referenced files. -//! -//! Cases that remain out of scope (they need richer per-part provider options or -//! server-tool content parts not present in the Rust model) are documented in -//! the task summary: cache_control, citations, mid-conversation tool-changes, -//! server tools (web_search/web_fetch/code_execution/mcp/advisor), and -//! tool-result `content` outputs whose inner parts need conversion. - -use aimux_core::content::ContentPart; -use aimux_core::language_model_message::LanguageModelPromptMessage; -use aimux_core::message::Role; -use aimux_core::shared::provider_namespace; -use aimux_core::types::Warning; -use aimux_providers::anthropic::convert::convert_prompt_to_anthropic_full; -use serde_json::{Value, json}; -use std::collections::BTreeSet; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -fn msg(role: Role, parts: Vec) -> LanguageModelPromptMessage { - LanguageModelPromptMessage { - role, - content: parts, - provider_options: None, - } -} - -fn text_part(t: &str) -> ContentPart { - ContentPart::text(t) -} - -fn prompt(msgs: Vec) -> Vec { - msgs -} - -fn convert_full( - msgs: Vec, - send_reasoning: bool, -) -> aimux_providers::anthropic::convert::AnthropicPromptConversion { - convert_prompt_to_anthropic_full(&msgs, send_reasoning) -} - -fn assert_other_warning(warnings: &[Warning], message: &str) { - let found = warnings.iter().any(|w| match w { - Warning::Other { message: m } => m == message, - _ => false, - }); - assert!( - found, - "expected a Warning::Other with message {message:?}, got {warnings:?}" - ); -} - -fn reasoning_part(text: &str, signature: Option<&str>) -> ContentPart { - ContentPart::Reasoning { - text: text.to_string(), - signature: signature.map(std::string::ToString::to_string), - provider_options: None, - } -} - -fn file_url(url: &str, media_type: &str) -> ContentPart { - ContentPart::file_url(url.to_string(), media_type.to_string()) -} - -fn file_base64(data: &str, media_type: &str) -> ContentPart { - ContentPart::file_base64(data.to_string(), media_type.to_string()) -} - -fn file_bytes(data: &[u8], media_type: &str) -> ContentPart { - ContentPart::file(data.to_vec(), media_type.to_string()) -} - -fn file_bytes_named(data: &[u8], media_type: &str, filename: &str) -> ContentPart { - ContentPart::File { - data: data.to_vec(), - media_type: media_type.to_string(), - filename: Some(filename.to_string()), - provider_options: None, - } -} - -fn file_ref(media_type: &str, reference: Value) -> ContentPart { - ContentPart::file_reference(media_type.to_string(), reference) -} - -// PNG magic bytes and their base64 encoding (matches the TS `pngBase64`). -const PNG_BYTES: [u8; 8] = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; -const PNG_BASE64: &str = "iVBORw0KGgo="; -// PDF magic bytes and their base64 encoding (matches the TS `pdfBase64`). -const PDF_BYTES: [u8; 5] = [0x25, 0x50, 0x44, 0x46, 0x2d]; -const PDF_BASE64: &str = "JVBERi0="; - -// --------------------------------------------------------------------------- -// mid-conversation system -// --------------------------------------------------------------------------- - -#[test] -fn should_emit_a_mid_conversation_system_message_inline_and_add_the_beta() { - let p = prompt(vec![ - msg(Role::System, vec![text_part("initial")]), - msg(Role::User, vec![text_part("hi")]), - msg(Role::Assistant, vec![text_part("hello")]), - msg(Role::System, vec![text_part("switch tone")]), - msg(Role::User, vec![text_part("go")]), - ]); - let result = convert_full(p, true); - assert_eq!( - result.system, - Some(vec![json!({ "type": "text", "text": "initial" })]) - ); - assert!( - result.messages.iter().any(|m| m - == &json!({ - "role": "system", - "content": [{ "type": "text", "text": "switch tone" }] - })), - "expected an inline system message, got {:?}", - result.messages - ); - assert!(result.betas.contains("mid-conversation-system-2026-04-07")); -} - -// --------------------------------------------------------------------------- -// trailing whitespace -// --------------------------------------------------------------------------- - -#[test] -fn should_remove_trailing_whitespace_from_last_assistant_message_when_no_further_user_message() { - let p = prompt(vec![ - msg(Role::User, vec![text_part("user content")]), - msg(Role::Assistant, vec![text_part("assistant content ")]), - ]); - let result = convert_full(p, true); - assert_eq!( - result.messages, - vec![ - json!({ "role": "user", "content": [{ "type": "text", "text": "user content" }] }), - json!({ "role": "assistant", "content": [{ "type": "text", "text": "assistant content" }] }), - ] - ); -} - -#[test] -fn should_remove_trailing_whitespace_from_last_assistant_message_with_multi_part_content() { - let p = prompt(vec![ - msg(Role::User, vec![text_part("user content")]), - msg( - Role::Assistant, - vec![text_part("assistant "), text_part("content ")], - ), - ]); - let result = convert_full(p, true); - assert_eq!( - result.messages, - vec![ - json!({ "role": "user", "content": [{ "type": "text", "text": "user content" }] }), - json!({ - "role": "assistant", - "content": [ - { "type": "text", "text": "assistant " }, - { "type": "text", "text": "content" }, - ] - }), - ] - ); -} - -#[test] -fn should_keep_trailing_whitespace_from_assistant_message_when_there_is_a_further_user_message() { - let p = prompt(vec![ - msg(Role::User, vec![text_part("user content")]), - msg(Role::Assistant, vec![text_part("assistant content ")]), - msg(Role::User, vec![text_part("user content 2")]), - ]); - let result = convert_full(p, true); - assert_eq!( - result.messages, - vec![ - json!({ "role": "user", "content": [{ "type": "text", "text": "user content" }] }), - json!({ "role": "assistant", "content": [{ "type": "text", "text": "assistant content " }] }), - json!({ "role": "user", "content": [{ "type": "text", "text": "user content 2" }] }), - ] - ); -} - -// --------------------------------------------------------------------------- -// reasoning / thinking -// --------------------------------------------------------------------------- - -#[test] -fn should_convert_reasoning_parts_with_signature_into_thinking_parts_when_send_reasoning_is_true() { - let p = prompt(vec![msg( - Role::Assistant, - vec![ - reasoning_part( - "I need to count the number of \"r\"s in the word \"strawberry\".", - Some("test-signature"), - ), - text_part("The word \"strawberry\" has 2 \"r\"s."), - ], - )]); - let result = convert_full(p, true); - assert_eq!( - result.messages, - vec![json!({ - "role": "assistant", - "content": [ - { - "type": "thinking", - "thinking": "I need to count the number of \"r\"s in the word \"strawberry\".", - "signature": "test-signature", - }, - { "type": "text", "text": "The word \"strawberry\" has 2 \"r\"s." }, - ] - })] - ); - assert!(result.warnings.is_empty()); -} - -#[test] -fn should_ignore_reasoning_parts_without_signature_when_send_reasoning_is_true() { - let p = prompt(vec![msg( - Role::Assistant, - vec![ - reasoning_part( - "I need to count the number of \"r\"s in the word \"strawberry\".", - None, - ), - text_part("The word \"strawberry\" has 2 \"r\"s."), - ], - )]); - let result = convert_full(p, true); - assert_eq!( - result.messages, - vec![json!({ - "role": "assistant", - "content": [ - { "type": "text", "text": "The word \"strawberry\" has 2 \"r\"s." }, - ] - })] - ); - assert_eq!(result.warnings.len(), 1); - assert_other_warning(&result.warnings, "unsupported reasoning metadata"); -} - -#[test] -fn should_omit_reasoning_parts_with_signature_when_send_reasoning_is_false() { - let p = prompt(vec![msg( - Role::Assistant, - vec![ - reasoning_part( - "I need to count the number of \"r\"s in the word \"strawberry\".", - Some("test-signature"), - ), - text_part("The word \"strawberry\" has 2 \"r\"s."), - ], - )]); - let result = convert_full(p, false); - assert_eq!( - result.messages, - vec![json!({ - "role": "assistant", - "content": [ - { "type": "text", "text": "The word \"strawberry\" has 2 \"r\"s." }, - ] - })] - ); - assert_eq!(result.warnings.len(), 1); - assert_other_warning( - &result.warnings, - "sending reasoning content is disabled for this model", - ); -} - -#[test] -fn should_omit_reasoning_parts_without_signature_when_send_reasoning_is_false() { - let p = prompt(vec![msg( - Role::Assistant, - vec![ - reasoning_part( - "I need to count the number of \"r\"s in the word \"strawberry\".", - None, - ), - text_part("The word \"strawberry\" has 2 \"r\"s."), - ], - )]); - let result = convert_full(p, false); - assert_eq!( - result.messages, - vec![json!({ - "role": "assistant", - "content": [ - { "type": "text", "text": "The word \"strawberry\" has 2 \"r\"s." }, - ] - })] - ); - assert_eq!(result.warnings.len(), 1); - assert_other_warning( - &result.warnings, - "sending reasoning content is disabled for this model", - ); -} - -// --------------------------------------------------------------------------- -// Extended-thinking input echo (build_request_body_with_warnings wiring) -// --------------------------------------------------------------------------- - -#[test] -fn thinking_enabled_echoes_reasoning_parts_as_thinking_blocks() { - use aimux_core::options::CallOptions; - use aimux_providers::anthropic::convert::build_request_body_with_warnings; - - let mut options = CallOptions::new(vec![msg( - Role::Assistant, - vec![ - reasoning_part("Let me weigh the options.", Some("sig-echo-1")), - text_part("Here is the answer."), - ], - )]); - options.provider_options = Some(provider_namespace( - "anthropic", - json!({ "thinking": { "type": "enabled", "budgetTokens": 1024 } }), - )); - let result = - build_request_body_with_warnings("claude-sonnet-4-20250514", &options, false).unwrap(); - let blocks = result.body["messages"][0]["content"].as_array().unwrap(); - let thinking = blocks - .iter() - .find(|b| b["type"] == "thinking") - .expect("reasoning part must be echoed as a thinking block"); - assert_eq!(thinking["signature"], "sig-echo-1"); - assert_eq!(thinking["thinking"], "Let me weigh the options."); - assert!( - !result - .warnings - .iter() - .any(|w| matches!(w, Warning::Other { message } if message.contains("disabled"))), - "no 'disabled' warning when thinking is enabled: {:?}", - result.warnings - ); -} - -#[test] -fn thinking_disabled_omits_reasoning_parts() { - use aimux_core::options::CallOptions; - use aimux_providers::anthropic::convert::build_request_body_with_warnings; - - let mut options = CallOptions::new(vec![msg( - Role::Assistant, - vec![ - reasoning_part("Let me weigh the options.", Some("sig-echo-1")), - text_part("Here is the answer."), - ], - )]); - options.provider_options = Some(provider_namespace( - "anthropic", - json!({ "thinking": { "type": "disabled" } }), - )); - let result = - build_request_body_with_warnings("claude-sonnet-4-20250514", &options, false).unwrap(); - let blocks = result.body["messages"][0]["content"].as_array().unwrap(); - assert!( - !blocks.iter().any(|b| b["type"] == "thinking"), - "thinking block must be omitted when thinking is disabled" - ); -} - -// --------------------------------------------------------------------------- -// URL images -// --------------------------------------------------------------------------- - -#[test] -fn should_add_image_parts_for_url_images() { - let p = prompt(vec![msg( - Role::User, - vec![file_url("https://example.com/image.png", "image/*")], - )]); - let result = convert_full(p, true); - assert!(result.system.is_none()); - assert_eq!( - result.messages, - vec![json!({ - "role": "user", - "content": [{ - "type": "image", - "source": { "type": "url", "url": "https://example.com/image.png" }, - }], - })] - ); - assert!(result.betas.is_empty()); -} - -#[test] -fn should_treat_url_strings_in_image_file_data_as_urls_not_base64() { - let p = prompt(vec![msg( - Role::User, - vec![file_url("https://example.com/image.png", "image/png")], - )]); - let result = convert_full(p, true); - assert_eq!( - result.messages, - vec![json!({ - "role": "user", - "content": [{ - "type": "image", - "source": { "type": "url", "url": "https://example.com/image.png" }, - }], - })] - ); -} - -#[test] -fn passes_through_url_for_top_level_only_image() { - let p = prompt(vec![msg( - Role::User, - vec![file_url("https://example.com/x.png", "image")], - )]); - let result = convert_full(p, true); - assert_eq!( - result.messages[0]["content"][0], - json!({ - "type": "image", - "source": { "type": "url", "url": "https://example.com/x.png" }, - }) - ); -} - -// --------------------------------------------------------------------------- -// URL PDFs -// --------------------------------------------------------------------------- - -#[test] -fn should_treat_url_strings_in_pdf_file_data_as_urls_not_base64() { - let p = prompt(vec![msg( - Role::User, - vec![file_url( - "https://example.com/document.pdf", - "application/pdf", - )], - )]); - let result = convert_full(p, true); - assert_eq!( - result.messages, - vec![json!({ - "role": "user", - "content": [{ - "type": "document", - "source": { "type": "url", "url": "https://example.com/document.pdf" }, - }], - })] - ); - let mut expected_betas = BTreeSet::new(); - expected_betas.insert("pdfs-2024-09-25".to_string()); - assert_eq!(result.betas, expected_betas); -} - -#[test] -fn should_add_pdf_file_parts_for_url_pdfs() { - let p = prompt(vec![msg( - Role::User, - vec![file_url( - "https://example.com/document.pdf", - "application/pdf", - )], - )]); - let result = convert_full(p, true); - assert_eq!( - result.messages[0]["content"][0], - json!({ - "type": "document", - "source": { "type": "url", "url": "https://example.com/document.pdf" }, - }) - ); - assert!(result.betas.contains("pdfs-2024-09-25")); -} - -// --------------------------------------------------------------------------- -// base64 PDFs -// --------------------------------------------------------------------------- - -#[test] -fn should_add_pdf_file_parts_for_base64_pdfs() { - // The TS test uses the opaque placeholder string 'base64PDFdata'; the Rust - // `FileBase64` variant holds the base64 string verbatim (it is NOT - // decoded/re-encoded), so the same placeholder round-trips exactly. - let p = prompt(vec![msg( - Role::User, - vec![file_base64("base64PDFdata", "application/pdf")], - )]); - let result = convert_full(p, true); - assert_eq!( - result.messages, - vec![json!({ - "role": "user", - "content": [{ - "type": "document", - "source": { - "type": "base64", - "media_type": "application/pdf", - "data": "base64PDFdata", - }, - }], - })] - ); - assert!(result.betas.contains("pdfs-2024-09-25")); -} - -// --------------------------------------------------------------------------- -// text/plain documents (filename -> title) -// --------------------------------------------------------------------------- - -#[test] -fn should_add_text_file_parts_for_text_plain_documents() { - let p = prompt(vec![msg( - Role::User, - vec![file_bytes_named( - b"sample text content", - "text/plain", - "sample.txt", - )], - )]); - let result = convert_full(p, true); - assert_eq!( - result.messages[0]["content"][0], - json!({ - "type": "document", - "source": { "type": "text", "media_type": "text/plain", "data": "sample text content" }, - "title": "sample.txt", - }) - ); - assert!(result.betas.is_empty()); -} - -#[test] -fn should_map_inline_text_file_parts_to_inline_text_document_source() { - let p = prompt(vec![msg( - Role::User, - vec![file_bytes_named( - b"inline text content", - "text/plain", - "inline.txt", - )], - )]); - let result = convert_full(p, true); - assert_eq!( - result.messages[0]["content"][0], - json!({ - "type": "document", - "source": { "type": "text", "media_type": "text/plain", "data": "inline text content" }, - "title": "inline.txt", - }) - ); -} - -#[test] -fn still_routes_text_plain_inline_text_through_document_source() { - let p = prompt(vec![msg( - Role::User, - vec![file_bytes(b"hello", "text/plain")], - )]); - let result = convert_full(p, true); - assert_eq!( - result.messages[0]["content"][0], - json!({ - "type": "document", - "source": { "type": "text", "media_type": "text/plain", "data": "hello" }, - }) - ); -} - -// --------------------------------------------------------------------------- -// unsupported file types -// --------------------------------------------------------------------------- - -#[test] -#[should_panic(expected = "media type: video/mp4")] -fn should_throw_error_for_unsupported_file_types() { - let p = prompt(vec![msg( - Role::User, - vec![file_base64("base64data", "video/mp4")], - )]); - let _ = convert_full(p, true); -} - -// --------------------------------------------------------------------------- -// top-level-only media type resolution (byte sniffing) -// --------------------------------------------------------------------------- - -#[test] -fn detects_image_subtype_from_inline_bytes_for_top_level_image() { - let p = prompt(vec![msg( - Role::User, - vec![file_base64(PNG_BASE64, "image")], - )]); - let result = convert_full(p, true); - assert_eq!( - result.messages[0]["content"][0], - json!({ - "type": "image", - "source": { "type": "base64", "media_type": "image/png", "data": PNG_BASE64 }, - }) - ); -} - -#[test] -fn normalizes_image_star_via_detection() { - let p = prompt(vec![msg( - Role::User, - vec![file_base64(PNG_BASE64, "image/*")], - )]); - let result = convert_full(p, true); - assert_eq!( - result.messages[0]["content"][0]["source"]["media_type"], - json!("image/png") - ); -} - -#[test] -fn detects_pdf_subtype_from_inline_bytes_for_top_level_application() { - let p = prompt(vec![msg( - Role::User, - vec![file_base64(PDF_BASE64, "application")], - )]); - let result = convert_full(p, true); - assert_eq!( - result.messages[0]["content"][0]["source"]["media_type"], - json!("application/pdf") - ); - assert!(result.betas.contains("pdfs-2024-09-25")); -} - -#[test] -fn preserves_full_image_png_pass_through() { - let p = prompt(vec![msg( - Role::User, - vec![file_base64(PNG_BASE64, "image/png")], - )]); - let result = convert_full(p, true); - assert_eq!( - result.messages[0]["content"][0], - json!({ - "type": "image", - "source": { "type": "base64", "media_type": "image/png", "data": PNG_BASE64 }, - }) - ); -} - -#[test] -fn detects_image_subtype_from_inline_bytes_for_top_level_image_via_file_bytes() { - // Same as above but through the raw-bytes `File` variant, which re-encodes - // the bytes to base64 (rather than passing a string through verbatim). - let p = prompt(vec![msg(Role::User, vec![file_bytes(&PNG_BYTES, "image")])]); - let result = convert_full(p, true); - assert_eq!( - result.messages[0]["content"][0], - json!({ - "type": "image", - "source": { "type": "base64", "media_type": "image/png", "data": PNG_BASE64 }, - }) - ); -} - -#[test] -fn detects_pdf_subtype_from_inline_bytes_via_file_bytes() { - let p = prompt(vec![msg( - Role::User, - vec![file_bytes(&PDF_BYTES, "application")], - )]); - let result = convert_full(p, true); - assert_eq!( - result.messages[0]["content"][0]["source"]["media_type"], - json!("application/pdf") - ); - assert!(result.betas.contains("pdfs-2024-09-25")); -} - -// --------------------------------------------------------------------------- -// provider-referenced files -// --------------------------------------------------------------------------- - -#[test] -fn should_convert_messages_with_image_file_parts_using_provider_reference() { - let p = prompt(vec![msg( - Role::User, - vec![file_ref( - "image/png", - json!({ "anthropic": "file-img-12345" }), - )], - )]); - let result = convert_full(p, true); - assert_eq!( - result.messages, - vec![json!({ - "role": "user", - "content": [{ - "type": "image", - "source": { "type": "file", "file_id": "file-img-12345" }, - }], - })] - ); - assert!(result.betas.contains("files-api-2025-04-14")); -} - -#[test] -fn should_convert_messages_with_pdf_file_parts_using_provider_reference() { - let p = prompt(vec![msg( - Role::User, - vec![file_ref( - "application/pdf", - json!({ "anthropic": "file-pdf-12345" }), - )], - )]); - let result = convert_full(p, true); - assert_eq!( - result.messages[0]["content"][0], - json!({ - "type": "document", - "source": { "type": "file", "file_id": "file-pdf-12345" }, - }) - ); - assert!(result.betas.contains("files-api-2025-04-14")); -} - -#[test] -fn should_convert_provider_referenced_file_parts_to_container_uploads_when_requested() { - let part = ContentPart::FileReference { - media_type: "text/csv".to_string(), - reference: json!({ "anthropic": "file-csv-12345" }), - filename: None, - provider_options: Some( - serde_json::from_value(json!({ "anthropic": { "containerUpload": true } })).unwrap(), - ), - }; - let p = prompt(vec![msg( - Role::User, - vec![text_part("Analyze this data."), part], - )]); - let result = convert_full(p, true); - assert_eq!( - result.messages, - vec![json!({ - "role": "user", - "content": [ - { "type": "text", "text": "Analyze this data." }, - { "type": "container_upload", "file_id": "file-csv-12345" }, - ], - })] - ); - assert!(result.betas.contains("files-api-2025-04-14")); -} - -#[test] -#[should_panic(expected = "No provider reference found for provider 'anthropic'")] -fn should_throw_when_provider_reference_does_not_contain_anthropic_key() { - let p = prompt(vec![msg( - Role::User, - vec![file_ref("application/pdf", json!({ "openai": "file-xyz" }))], - )]); - let _ = convert_full(p, true); -} - -// Keep the `Value` import used even if some tests only use `json!`. -#[allow(dead_code)] -fn _ensure_value_import_used(_v: Value) {} diff --git a/aimux-providers/tests/anthropic_convert_test.rs b/aimux-providers/tests/anthropic_convert_test.rs deleted file mode 100644 index 0b835c6cb..000000000 --- a/aimux-providers/tests/anthropic_convert_test.rs +++ /dev/null @@ -1,1022 +0,0 @@ -// Panic convert wrappers are #[deprecated]; these tests still use them. -#![allow(deprecated)] -//! Rust port of the Anthropic provider pure-function tests. -//! -//! Translated from the Vercel AI SDK TypeScript tests: -//! - `packages/anthropic/src/convert-anthropic-usage.test.ts` (15 cases) -//! - `packages/anthropic/src/anthropic-prepare-tools.test.ts` (function-tool + -//! tool-choice subset) -//! - `packages/anthropic/src/convert-to-anthropic-prompt.test.ts` (subset -//! supported by the current Rust data model) -//! -//! Tests that exercise features absent from the Rust `aimux-core` data model -//! (reasoning parts, cache control, URL images, provider-defined tools, -//! mid-conversation system messages, citations, ...) are intentionally omitted -//! and documented in the task summary rather than translated here. - -use aimux_core::content::ContentPart; -use aimux_core::language_model_message::LanguageModelPromptMessage; -use aimux_core::message::Role; -use aimux_core::shared::{SharedProviderOptions, provider_namespace}; -use aimux_core::tool::{FunctionTool, ToolChoice}; -use aimux_core::types::Warning; -use aimux_providers::anthropic::convert::convert_prompt_to_anthropic; -use aimux_providers::anthropic::prepare_tools::prepare_tools; -use aimux_providers::anthropic::usage::{ - AnthropicInputTokens, AnthropicOutputTokens, convert_anthropic_usage, -}; -use base64::Engine; -use serde_json::{Value, json}; - -// ───────────────────────────────────────────────────────────────────────────── -// Helpers -// ───────────────────────────────────────────────────────────────────────────── - -fn msg(role: Role, parts: Vec) -> LanguageModelPromptMessage { - LanguageModelPromptMessage { - role, - content: parts, - provider_options: None, - } -} - -fn text_part(t: &str) -> ContentPart { - ContentPart::text(t.to_string()) -} - -fn prompt(msgs: Vec) -> Vec { - msgs -} - -/// Build a function tool with the common defaults (no strict, no provider -/// options, no input examples). -fn ftool(name: &str, desc: &str, schema: Value) -> FunctionTool { - FunctionTool::new(name.to_string(), schema).with_description(desc.to_string()) -} - -fn anthropic_opts(value: Value) -> Option { - Some(provider_namespace("anthropic", value)) -} - -/// Decode a base64 string into raw bytes (mirrors the TS `data: { type: 'data', -/// data: '' }` input form, which the Rust `ContentPart::Image` holds as -/// raw bytes). -fn decode_b64(s: &str) -> Vec { - base64::engine::general_purpose::STANDARD - .decode(s) - .expect("valid base64") -} - -// ═════════════════════════════════════════════════════════════════════════════ -// convertAnthropicUsage (15 cases) -// ═════════════════════════════════════════════════════════════════════════════ - -#[cfg(test)] -mod convert_anthropic_usage_tests { - use super::*; - - #[test] - fn should_use_usage_as_raw_when_raw_usage_is_not_provided() { - let usage = json!({ "input_tokens": 10, "output_tokens": 20 }); - let result = convert_anthropic_usage(&usage, None); - assert_eq!(result.raw, usage); - } - - #[test] - fn should_use_raw_usage_as_raw_when_provided() { - let usage = json!({ "input_tokens": 10, "output_tokens": 20 }); - let raw_usage = json!({ - "input_tokens": 10, - "output_tokens": 20, - "service_tier": "standard", - "inference_geo": "not_available", - "cache_creation": { - "ephemeral_5m_input_tokens": 0, - "ephemeral_1h_input_tokens": 0, - }, - }); - let result = convert_anthropic_usage(&usage, Some(&raw_usage)); - assert_eq!(result.raw, raw_usage); - } - - #[test] - fn should_compute_token_totals_correctly_with_cache_tokens() { - let usage = json!({ - "input_tokens": 10, - "output_tokens": 20, - "cache_creation_input_tokens": 5, - "cache_read_input_tokens": 3, - }); - let result = convert_anthropic_usage(&usage, None); - assert_eq!( - result.input_tokens, - AnthropicInputTokens { - total: 18, - no_cache: 10, - cache_read: 3, - cache_write: 5 - } - ); - assert_eq!( - result.output_tokens, - AnthropicOutputTokens { - total: 20, - text: None, - reasoning: None - } - ); - } - - #[test] - fn should_handle_null_cache_tokens() { - let usage = json!({ - "input_tokens": 100, - "output_tokens": 50, - "cache_creation_input_tokens": null, - "cache_read_input_tokens": null, - }); - let result = convert_anthropic_usage(&usage, None); - assert_eq!(result.input_tokens.total, 100); - assert_eq!(result.input_tokens.cache_read, 0); - assert_eq!(result.input_tokens.cache_write, 0); - } - - #[test] - fn should_sum_across_all_iterations_when_iterations_array_is_present() { - let usage = json!({ - "input_tokens": 45000, - "output_tokens": 1234, - "iterations": [ - { "type": "compaction", "input_tokens": 180000, "output_tokens": 3500 }, - { "type": "message", "input_tokens": 23000, "output_tokens": 1000 }, - ], - }); - let result = convert_anthropic_usage(&usage, None); - // Total should be sum of iterations, not top-level values. - assert_eq!(result.input_tokens.total, 203000); // 180000 + 23000 - assert_eq!(result.input_tokens.no_cache, 203000); - assert_eq!(result.output_tokens.total, 4500); // 3500 + 1000 - assert_eq!(result.raw, usage); - } - - #[test] - fn should_handle_single_iteration_message_only_no_compaction_triggered() { - let usage = json!({ - "input_tokens": 5000, - "output_tokens": 500, - "iterations": [ - { "type": "message", "input_tokens": 5000, "output_tokens": 500 }, - ], - }); - let result = convert_anthropic_usage(&usage, None); - assert_eq!(result.input_tokens.total, 5000); - assert_eq!(result.output_tokens.total, 500); - } - - #[test] - fn should_handle_multiple_compaction_iterations_long_running_task() { - let usage = json!({ - "input_tokens": 10000, - "output_tokens": 500, - "iterations": [ - { "type": "compaction", "input_tokens": 200000, "output_tokens": 4000 }, - { "type": "message", "input_tokens": 50000, "output_tokens": 2000 }, - { "type": "compaction", "input_tokens": 180000, "output_tokens": 3500 }, - { "type": "message", "input_tokens": 30000, "output_tokens": 1500 }, - ], - }); - let result = convert_anthropic_usage(&usage, None); - // Total = 200000 + 50000 + 180000 + 30000 = 460000 input - assert_eq!(result.input_tokens.total, 460000); - // Total = 4000 + 2000 + 3500 + 1500 = 11000 output - assert_eq!(result.output_tokens.total, 11000); - } - - #[test] - fn should_combine_iterations_with_cache_tokens() { - let usage = json!({ - "input_tokens": 45000, - "output_tokens": 1234, - "cache_creation_input_tokens": 1000, - "cache_read_input_tokens": 500, - "iterations": [ - { "type": "compaction", "input_tokens": 180000, "output_tokens": 3500 }, - { "type": "message", "input_tokens": 23000, "output_tokens": 1000 }, - ], - }); - let result = convert_anthropic_usage(&usage, None); - // noCache = sum of iterations only - assert_eq!(result.input_tokens.no_cache, 203000); // 180000 + 23000 - assert_eq!(result.input_tokens.cache_write, 1000); - assert_eq!(result.input_tokens.cache_read, 500); - assert_eq!(result.input_tokens.total, 204500); // 203000 + 1000 + 500 - assert_eq!(result.output_tokens.total, 4500); // 3500 + 1000 - } - - #[test] - fn should_use_raw_usage_as_raw_even_when_iterations_are_present() { - let usage = json!({ - "input_tokens": 45000, - "output_tokens": 1234, - "iterations": [ - { "type": "compaction", "input_tokens": 180000, "output_tokens": 3500 }, - { "type": "message", "input_tokens": 23000, "output_tokens": 1000 }, - ], - }); - let raw_usage = json!({ - "input_tokens": 45000, - "output_tokens": 1234, - "service_tier": "standard", - }); - let result = convert_anthropic_usage(&usage, Some(&raw_usage)); - assert_eq!(result.raw, raw_usage); - assert_eq!(result.input_tokens.total, 203000); - } - - #[test] - fn should_use_top_level_values_when_iterations_is_null() { - let usage = json!({ - "input_tokens": 100, - "output_tokens": 50, - "iterations": null, - }); - let result = convert_anthropic_usage(&usage, None); - assert_eq!(result.input_tokens.total, 100); - assert_eq!(result.output_tokens.total, 50); - } - - #[test] - fn should_use_top_level_values_when_iterations_is_undefined() { - let usage = json!({ "input_tokens": 100, "output_tokens": 50 }); - let result = convert_anthropic_usage(&usage, None); - assert_eq!(result.input_tokens.total, 100); - assert_eq!(result.output_tokens.total, 50); - } - - #[test] - fn should_use_top_level_values_when_iterations_array_is_empty() { - let usage = json!({ - "input_tokens": 100, - "output_tokens": 50, - "iterations": [], - }); - let result = convert_anthropic_usage(&usage, None); - assert_eq!(result.input_tokens.total, 100); - assert_eq!(result.output_tokens.total, 50); - } - - #[test] - fn should_handle_zero_tokens_in_iterations() { - let usage = json!({ - "input_tokens": 0, - "output_tokens": 0, - "iterations": [ - { "type": "compaction", "input_tokens": 0, "output_tokens": 0 }, - { "type": "message", "input_tokens": 0, "output_tokens": 0 }, - ], - }); - let result = convert_anthropic_usage(&usage, None); - assert_eq!(result.input_tokens.total, 0); - assert_eq!(result.output_tokens.total, 0); - } - - #[test] - fn should_match_documentation_example_exactly() { - let usage = json!({ - "input_tokens": 45000, - "output_tokens": 1234, - "iterations": [ - { "type": "compaction", "input_tokens": 180000, "output_tokens": 3500 }, - { "type": "message", "input_tokens": 23000, "output_tokens": 1000 }, - ], - }); - let result = convert_anthropic_usage(&usage, None); - let expected_total_input = 180000 + 23000; // 203000 - let expected_total_output = 3500 + 1000; // 4500 - assert_eq!(result.input_tokens.total, expected_total_input); - assert_eq!(result.output_tokens.total, expected_total_output); - // The top-level values (45000, 1234) are NOT the billed amounts when - // iterations is present. - assert_ne!( - result.input_tokens.total, - usage["input_tokens"].as_u64().unwrap() - ); - assert_ne!( - result.output_tokens.total, - usage["output_tokens"].as_u64().unwrap() - ); - } - - #[test] - fn should_handle_re_applying_previous_compaction_block_no_new_compaction() { - let usage = json!({ - "input_tokens": 15000, - "output_tokens": 800, - }); - let result = convert_anthropic_usage(&usage, None); - // Top-level values are accurate when no new compaction triggered. - assert_eq!(result.input_tokens.total, 15000); - assert_eq!(result.output_tokens.total, 800); - } -} - -// ═════════════════════════════════════════════════════════════════════════════ -// prepareTools (function-tool + tool-choice subset) -// ═════════════════════════════════════════════════════════════════════════════ - -#[cfg(test)] -mod prepare_tools_tests { - use super::*; - - fn assert_strict_warning(warnings: &[Warning], tool_name: &str, strict_val: bool) { - assert_eq!(warnings.len(), 1, "expected exactly one warning"); - match &warnings[0] { - Warning::Unsupported { feature, details } => { - assert_eq!(feature, "strict"); - let expected = format!( - "Tool '{tool_name}' has strict: {strict_val}, but strict mode is not supported by this provider. The strict property will be ignored." - ); - assert_eq!(details.as_ref().unwrap(), &expected); - } - other => panic!("expected Unsupported warning, got {other:?}"), - } - } - - #[test] - fn should_return_undefined_tools_and_tool_choice_when_tools_are_null() { - let result = prepare_tools(None, None, false, true, true, false); - assert!(result.tools.is_none()); - assert!(result.tool_choice.is_none()); - assert!(result.tool_warnings.is_empty()); - assert!(result.betas.is_empty()); - } - - #[test] - fn should_return_undefined_tools_and_tool_choice_when_tools_are_empty() { - let tools: Vec = vec![]; - let result = prepare_tools(Some(&tools), None, false, true, true, false); - assert!(result.tools.is_none()); - assert!(result.tool_choice.is_none()); - assert!(result.tool_warnings.is_empty()); - assert!(result.betas.is_empty()); - } - - #[test] - fn should_correctly_prepare_function_tools() { - let mut tool = ftool( - "testFunction", - "A test function", - json!({ "type": "object", "properties": {} }), - ); - tool.provider_options = anthropic_opts(json!({ "eagerInputStreaming": true })); - let tools = vec![tool]; - let result = prepare_tools(Some(&tools), None, false, true, true, false); - - let expected = json!({ - "name": "testFunction", - "description": "A test function", - "input_schema": { "type": "object", "properties": {} }, - "eager_input_streaming": true, - }); - assert_eq!(result.tools.unwrap()[0], expected); - assert!(result.tool_choice.is_none()); - assert!(result.tool_warnings.is_empty()); - } - - #[test] - fn should_correctly_preserve_tool_input_examples() { - let mut tool = ftool( - "tool_with_examples", - "tool with examples", - json!({ "type": "object", "properties": { "a": { "type": "number" } } }), - ); - tool.input_examples = Some(vec![ - json!({ "input": { "a": 1 } }), - json!({ "input": { "a": 2 } }), - ]); - let tools = vec![tool]; - let result = prepare_tools(Some(&tools), None, false, true, true, false); - - assert!(result.betas.contains("structured-outputs-2025-11-13")); - assert!(result.betas.contains("advanced-tool-use-2025-11-20")); - assert!(result.tool_choice.is_none()); - assert!(result.tool_warnings.is_empty()); - - let expected = json!({ - "name": "tool_with_examples", - "description": "tool with examples", - "input_schema": { "type": "object", "properties": { "a": { "type": "number" } } }, - "input_examples": [{ "a": 1 }, { "a": 2 }], - }); - assert_eq!(result.tools.unwrap()[0], expected); - } - - // ── strict mode for function tools ── - - #[test] - fn strict_included_and_beta_when_supports_structured_output_and_strict_true() { - let mut tool = ftool( - "testFunction", - "A test function", - json!({ "type": "object", "properties": {} }), - ); - tool.strict = Some(true); - let tools = vec![tool]; - let result = prepare_tools(Some(&tools), None, false, true, true, false); - - let expected = json!({ - "name": "testFunction", - "description": "A test function", - "input_schema": { "type": "object", "properties": {} }, - "strict": true, - }); - assert_eq!(result.tools.unwrap()[0], expected); - assert!(result.betas.contains("structured-outputs-2025-11-13")); - assert_eq!(result.betas.len(), 1); - assert!(result.tool_warnings.is_empty()); - } - - #[test] - fn beta_but_not_strict_when_strict_undefined_and_supports_structured_output() { - let tool = ftool( - "testFunction", - "A test function", - json!({ "type": "object", "properties": {} }), - ); - let tools = vec![tool]; - let result = prepare_tools(Some(&tools), None, false, true, true, false); - - let expected = json!({ - "name": "testFunction", - "description": "A test function", - "input_schema": { "type": "object", "properties": {} }, - }); - assert_eq!(result.tools.unwrap()[0], expected); - assert!(result.betas.contains("structured-outputs-2025-11-13")); - assert!(result.tool_warnings.is_empty()); - } - - #[test] - fn no_strict_emit_warning_no_beta_when_both_supports_flags_false() { - let mut tool = ftool( - "testFunction", - "A test function", - json!({ "type": "object", "properties": {} }), - ); - tool.strict = Some(true); - let tools = vec![tool]; - let result = prepare_tools(Some(&tools), None, false, false, false, false); - - let expected = json!({ - "name": "testFunction", - "description": "A test function", - "input_schema": { "type": "object", "properties": {} }, - }); - assert_eq!(result.tools.unwrap()[0], expected); - assert!(result.betas.is_empty()); - assert_strict_warning(&result.tool_warnings, "testFunction", true); - } - - #[test] - fn strict_but_no_beta_when_supports_structured_output_false_but_strict_tools_true() { - let mut tool = ftool( - "testFunction", - "A test function", - json!({ "type": "object", "properties": {} }), - ); - tool.strict = Some(true); - let tools = vec![tool]; - let result = prepare_tools(Some(&tools), None, false, false, true, false); - - let expected = json!({ - "name": "testFunction", - "description": "A test function", - "input_schema": { "type": "object", "properties": {} }, - "strict": true, - }); - assert_eq!(result.tools.unwrap()[0], expected); - assert!(result.betas.is_empty()); - assert!(result.tool_warnings.is_empty()); - } - - #[test] - fn beta_when_strict_false_and_supports_structured_output_true() { - let mut tool = ftool( - "testFunction", - "A test function", - json!({ "type": "object", "properties": {} }), - ); - tool.strict = Some(false); - let tools = vec![tool]; - let result = prepare_tools(Some(&tools), None, false, true, true, false); - - let expected = json!({ - "name": "testFunction", - "description": "A test function", - "input_schema": { "type": "object", "properties": {} }, - "strict": false, - }); - assert_eq!(result.tools.unwrap()[0], expected); - assert!(result.betas.contains("structured-outputs-2025-11-13")); - assert!(result.tool_warnings.is_empty()); - } - - // ── deferLoading for function tools ── - - #[test] - fn should_include_defer_loading_when_set_to_true() { - let mut tool = ftool( - "testFunction", - "A test function", - json!({ "type": "object", "properties": {} }), - ); - tool.provider_options = anthropic_opts(json!({ "deferLoading": true })); - let tools = vec![tool]; - let result = prepare_tools(Some(&tools), None, false, true, true, false); - - let expected = json!({ - "name": "testFunction", - "description": "A test function", - "input_schema": { "type": "object", "properties": {} }, - "defer_loading": true, - }); - assert_eq!(result.tools.unwrap()[0], expected); - assert!(result.betas.contains("structured-outputs-2025-11-13")); - assert_eq!(result.betas.len(), 1); - } - - #[test] - fn should_include_defer_loading_when_set_to_false() { - let mut tool = ftool( - "testFunction", - "A test function", - json!({ "type": "object", "properties": {} }), - ); - tool.provider_options = anthropic_opts(json!({ "deferLoading": false })); - let tools = vec![tool]; - let result = prepare_tools(Some(&tools), None, false, true, true, false); - - let expected = json!({ - "name": "testFunction", - "description": "A test function", - "input_schema": { "type": "object", "properties": {} }, - "defer_loading": false, - }); - assert_eq!(result.tools.unwrap()[0], expected); - } - - #[test] - fn should_not_include_defer_loading_when_not_specified() { - let tool = ftool( - "testFunction", - "A test function", - json!({ "type": "object", "properties": {} }), - ); - let tools = vec![tool]; - let result = prepare_tools(Some(&tools), None, false, true, true, false); - let tool_def = result.tools.unwrap()[0].as_object().unwrap().clone(); - assert!(!tool_def.contains_key("defer_loading")); - } - - // ── allowedCallers for function tools ── - - #[test] - fn should_include_allowed_callers_and_advanced_tool_use_beta_when_set() { - let mut tool = ftool( - "query_database", - "Query a database", - json!({ "type": "object", "properties": { "sql": { "type": "string" } } }), - ); - tool.provider_options = - anthropic_opts(json!({ "allowedCallers": ["code_execution_20250825"] })); - let tools = vec![tool]; - let result = prepare_tools(Some(&tools), None, false, true, true, false); - - assert!(result.betas.contains("structured-outputs-2025-11-13")); - assert!(result.betas.contains("advanced-tool-use-2025-11-20")); - - let expected = json!({ - "name": "query_database", - "description": "Query a database", - "input_schema": { "type": "object", "properties": { "sql": { "type": "string" } } }, - "allowed_callers": ["code_execution_20250825"], - }); - assert_eq!(result.tools.unwrap()[0], expected); - } - - #[test] - fn should_not_include_allowed_callers_when_not_specified() { - let tool = ftool( - "testFunction", - "A test function", - json!({ "type": "object", "properties": {} }), - ); - let tools = vec![tool]; - let result = prepare_tools(Some(&tools), None, false, true, true, false); - let tool_def = result.tools.unwrap()[0].as_object().unwrap().clone(); - assert!(!tool_def.contains_key("allowed_callers")); - } - - #[test] - fn should_include_both_defer_loading_and_allowed_callers_when_both_set() { - let mut tool = ftool( - "query_database", - "Query a database", - json!({ "type": "object", "properties": { "sql": { "type": "string" } } }), - ); - tool.provider_options = anthropic_opts(json!({ - "deferLoading": true, - "allowedCallers": ["code_execution_20250825"], - })); - let tools = vec![tool]; - let result = prepare_tools(Some(&tools), None, false, true, true, false); - - let tool_def = result.tools.unwrap()[0].as_object().unwrap().clone(); - assert_eq!(tool_def.get("defer_loading"), Some(&json!(true))); - assert_eq!( - tool_def.get("allowed_callers"), - Some(&json!(["code_execution_20250825"])) - ); - assert!(result.betas.contains("advanced-tool-use-2025-11-20")); - } - - #[test] - fn should_include_allowed_callers_with_code_execution_20260120() { - let mut tool = ftool( - "query_database", - "Query a database", - json!({ "type": "object", "properties": { "sql": { "type": "string" } } }), - ); - tool.provider_options = - anthropic_opts(json!({ "allowedCallers": ["code_execution_20260120"] })); - let tools = vec![tool]; - let result = prepare_tools(Some(&tools), None, false, true, true, false); - - assert!(result.betas.contains("structured-outputs-2025-11-13")); - assert!(result.betas.contains("advanced-tool-use-2025-11-20")); - - let expected = json!({ - "name": "query_database", - "description": "Query a database", - "input_schema": { "type": "object", "properties": { "sql": { "type": "string" } } }, - "allowed_callers": ["code_execution_20260120"], - }); - assert_eq!(result.tools.unwrap()[0], expected); - } - - // ── tool choice ── - - #[test] - fn should_handle_tool_choice_auto() { - let tool = ftool("testFunction", "Test", json!({})); - let tools = vec![tool]; - let tc = ToolChoice::Auto; - let result = prepare_tools(Some(&tools), Some(&tc), false, true, true, false); - assert_eq!(result.tool_choice.unwrap(), json!({ "type": "auto" })); - } - - #[test] - fn should_handle_tool_choice_required() { - let tool = ftool("testFunction", "Test", json!({})); - let tools = vec![tool]; - let tc = ToolChoice::Required; - let result = prepare_tools(Some(&tools), Some(&tc), false, true, true, false); - assert_eq!(result.tool_choice.unwrap(), json!({ "type": "any" })); - } - - #[test] - fn should_handle_tool_choice_none() { - let tool = ftool("testFunction", "Test", json!({})); - let tools = vec![tool]; - let tc = ToolChoice::None; - let result = prepare_tools(Some(&tools), Some(&tc), false, true, true, false); - assert!(result.tools.is_none()); - assert!(result.tool_choice.is_none()); - } - - #[test] - fn should_handle_tool_choice_tool() { - let tool = ftool("testFunction", "Test", json!({})); - let tools = vec![tool]; - let tc = ToolChoice::Tool { - tool_name: "testFunction".to_string(), - }; - let result = prepare_tools(Some(&tools), Some(&tc), false, true, true, false); - assert_eq!( - result.tool_choice.unwrap(), - json!({ "type": "tool", "name": "testFunction" }) - ); - } -} - -// ═════════════════════════════════════════════════════════════════════════════ -// convertToAnthropicPrompt (subset supported by the Rust data model) -// ═════════════════════════════════════════════════════════════════════════════ - -#[cfg(test)] -mod convert_prompt_tests { - use super::*; - - #[test] - fn should_convert_a_single_system_message_into_an_anthropic_system_message() { - let p = prompt(vec![msg( - Role::System, - vec![text_part("This is a system message")], - )]); - let (system, messages) = convert_prompt_to_anthropic(&p); - assert_eq!( - system, - Some(vec![ - json!({ "type": "text", "text": "This is a system message" }) - ]) - ); - assert!(messages.is_empty()); - } - - #[test] - fn should_convert_multiple_system_messages_into_an_anthropic_system_message() { - let p = prompt(vec![ - msg(Role::System, vec![text_part("This is a system message")]), - msg( - Role::System, - vec![text_part("This is another system message")], - ), - ]); - let (system, messages) = convert_prompt_to_anthropic(&p); - assert_eq!( - system, - Some(vec![ - json!({ "type": "text", "text": "This is a system message" }), - json!({ "type": "text", "text": "This is another system message" }), - ]) - ); - assert!(messages.is_empty()); - } - - #[test] - fn should_add_image_parts_for_uint8array_images() { - // TS input uses `data: { type: 'data', data: 'AAECAw==' }`; the Rust - // `ContentPart::Image` holds the decoded raw bytes and re-encodes them. - let p = prompt(vec![msg( - Role::User, - vec![ContentPart::image( - decode_b64("AAECAw=="), - "image/png".to_string(), - )], - )]); - let (system, messages) = convert_prompt_to_anthropic(&p); - assert!(system.is_none()); - assert_eq!( - messages, - vec![json!({ - "role": "user", - "content": [{ - "type": "image", - "source": { - "type": "base64", - "media_type": "image/png", - "data": "AAECAw==", - }, - }], - })] - ); - } - - #[test] - fn should_convert_a_single_tool_result_into_an_anthropic_user_message() { - let p = prompt(vec![msg( - Role::Tool, - vec![ContentPart::tool_result( - "tool-call-1".to_string(), - json!({ "type": "json", "value": { "test": "This is a tool message" } }), - )], - )]); - let (system, messages) = convert_prompt_to_anthropic(&p); - assert!(system.is_none()); - assert_eq!( - messages, - vec![json!({ - "role": "user", - "content": [{ - "type": "tool_result", - "tool_use_id": "tool-call-1", - "content": "{\"test\":\"This is a tool message\"}", - }], - })] - ); - } - - #[test] - fn should_convert_multiple_tool_results_into_an_anthropic_user_message() { - let p = prompt(vec![msg( - Role::Tool, - vec![ - ContentPart::tool_result( - "tool-call-1".to_string(), - json!({ "type": "json", "value": { "test": "This is a tool message" } }), - ), - ContentPart::tool_result( - "tool-call-2".to_string(), - json!({ "type": "json", "value": { "something": "else" } }), - ), - ], - )]); - let (system, messages) = convert_prompt_to_anthropic(&p); - assert!(system.is_none()); - assert_eq!( - messages, - vec![json!({ - "role": "user", - "content": [ - { - "type": "tool_result", - "tool_use_id": "tool-call-1", - "content": "{\"test\":\"This is a tool message\"}", - }, - { - "type": "tool_result", - "tool_use_id": "tool-call-2", - "content": "{\"something\":\"else\"}", - }, - ], - })] - ); - } - - #[test] - fn should_combine_user_and_tool_messages() { - let p = prompt(vec![ - msg( - Role::Tool, - vec![ContentPart::tool_result( - "tool-call-1".to_string(), - json!({ "type": "json", "value": { "test": "This is a tool message" } }), - )], - ), - msg(Role::User, vec![text_part("This is a user message")]), - ]); - let (system, messages) = convert_prompt_to_anthropic(&p); - assert!(system.is_none()); - assert_eq!( - messages, - vec![json!({ - "role": "user", - "content": [ - { - "type": "tool_result", - "tool_use_id": "tool-call-1", - "content": "{\"test\":\"This is a tool message\"}", - }, - { "type": "text", "text": "This is a user message" }, - ], - })] - ); - } - - #[test] - fn should_combine_multiple_sequential_assistant_messages_into_a_single_message() { - let p = prompt(vec![ - msg(Role::User, vec![text_part("Hi!")]), - msg(Role::Assistant, vec![text_part("Hello")]), - msg(Role::Assistant, vec![text_part("World")]), - msg(Role::Assistant, vec![text_part("!")]), - ]); - let (system, messages) = convert_prompt_to_anthropic(&p); - assert!(system.is_none()); - assert_eq!( - messages, - vec![ - json!({ "role": "user", "content": [{ "type": "text", "text": "Hi!" }] }), - json!({ - "role": "assistant", - "content": [ - { "type": "text", "text": "Hello" }, - { "type": "text", "text": "World" }, - { "type": "text", "text": "!" }, - ], - }), - ] - ); - } - - #[test] - fn should_wrap_non_object_invalid_tool_call_input_in_an_object() { - let p = prompt(vec![msg( - Role::Assistant, - vec![ContentPart::tool_call( - "call-1".to_string(), - "cityAttractions".to_string(), - // malformed JSON the model produced, kept as a raw string - json!("{ \"city\": \"San Francisco\", }"), - )], - )]); - let (system, messages) = convert_prompt_to_anthropic(&p); - assert!(system.is_none()); - assert_eq!( - messages, - vec![json!({ - "role": "assistant", - "content": [{ - "type": "tool_use", - "name": "cityAttractions", - "id": "call-1", - "input": { "rawInvalidInput": "{ \"city\": \"San Francisco\", }" }, - }], - })] - ); - } - - #[test] - fn should_convert_user_assistant_tool_assistant_user_message_sequence_with_multiple_tool_calls() - { - let p = prompt(vec![ - msg( - Role::User, - vec![text_part("weather for berlin, london and paris")], - ), - msg( - Role::Assistant, - vec![ - text_part( - "I will use the weather tool to get the weather for berlin, london and paris", - ), - ContentPart::tool_call( - "weather-call-1".to_string(), - "weather".to_string(), - json!({ "location": "berlin" }), - ), - ContentPart::tool_call( - "weather-call-2".to_string(), - "weather".to_string(), - json!({ "location": "london" }), - ), - ContentPart::tool_call( - "weather-call-3".to_string(), - "weather".to_string(), - json!({ "location": "paris" }), - ), - ], - ), - msg( - Role::Tool, - vec![ - ContentPart::tool_result( - "weather-call-1".to_string(), - json!({ "type": "json", "value": { "weather": "sunny" } }), - ), - ContentPart::tool_result( - "weather-call-2".to_string(), - json!({ "type": "json", "value": { "weather": "cloudy" } }), - ), - ContentPart::tool_result( - "weather-call-3".to_string(), - json!({ "type": "json", "value": { "weather": "rainy" } }), - ), - ], - ), - msg( - Role::Assistant, - vec![text_part( - "The weather for berlin is sunny, the weather for london is cloudy, and the weather for paris is rainy", - )], - ), - msg(Role::User, vec![text_part("and for new york?")]), - ]); - let (system, messages) = convert_prompt_to_anthropic(&p); - assert!(system.is_none()); - assert_eq!( - messages, - vec![ - json!({ - "role": "user", - "content": [{ "type": "text", "text": "weather for berlin, london and paris" }], - }), - json!({ - "role": "assistant", - "content": [ - { "type": "text", "text": "I will use the weather tool to get the weather for berlin, london and paris" }, - { "type": "tool_use", "id": "weather-call-1", "name": "weather", "input": { "location": "berlin" } }, - { "type": "tool_use", "id": "weather-call-2", "name": "weather", "input": { "location": "london" } }, - { "type": "tool_use", "id": "weather-call-3", "name": "weather", "input": { "location": "paris" } }, - ], - }), - json!({ - "role": "user", - "content": [ - { "type": "tool_result", "tool_use_id": "weather-call-1", "content": "{\"weather\":\"sunny\"}" }, - { "type": "tool_result", "tool_use_id": "weather-call-2", "content": "{\"weather\":\"cloudy\"}" }, - { "type": "tool_result", "tool_use_id": "weather-call-3", "content": "{\"weather\":\"rainy\"}" }, - ], - }), - json!({ - "role": "assistant", - "content": [{ "type": "text", "text": "The weather for berlin is sunny, the weather for london is cloudy, and the weather for paris is rainy" }], - }), - json!({ - "role": "user", - "content": [{ "type": "text", "text": "and for new york?" }], - }), - ] - ); - } -} diff --git a/aimux-providers/tests/anthropic_model_test.rs b/aimux-providers/tests/anthropic_model_test.rs deleted file mode 100644 index 62f92b92e..000000000 --- a/aimux-providers/tests/anthropic_model_test.rs +++ /dev/null @@ -1,1957 +0,0 @@ -//! Rust port of the Anthropic provider `doGenerate` response-parsing tests and -//! `doStream` streaming tests. -//! -//! Translated from the Vercel AI SDK TypeScript test suite: -//! - `packages/anthropic/src/anthropic-language-model.test.ts` -//! `describe('doGenerate')` response-parsing subset (text / usage / -//! stop_reason / tool_calls / request body / headers / error status codes). -//! - `packages/anthropic/src/anthropic-language-model.test.ts` -//! `describe('doStream')` streaming subset (text deltas, tool-call streaming, -//! usage, finish-reason mapping, response metadata, ping, in-stream errors, -//! pre-stream HTTP errors). -//! -//! HTTP is mocked with `wiremock` (a real loopback HTTP server), replacing the -//! TS MSW-based `createTestServer`. Each test starts its own `MockServer` so -//! parallel `#[tokio::test]` runs do not collide. -//! -//! Tests that depend on features absent from the Rust data model (reasoning / -//! thinking config, refusal `stop_details`, citations, provider-executed tools, -//! MCP servers, code execution, JSON output-format tool wrapping, `raw` usage -//! field, `providerMetadata` on the finish part) are intentionally omitted and -//! documented in the task summary. - -use aimux_core::tool::{RawToolCall, ToolResult}; -use futures::StreamExt; -use serde_json::{Value, json}; -use wiremock::matchers::{method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -use aimux_core::content::ContentPart; -use aimux_core::generate::{GenerateTextOptions, generate_text, stream_text}; -use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::{MessageContent, Role}; -use aimux_core::options::{CallOptions, Tool, ToolChoice}; -use aimux_core::result::{GenerateContent, StreamResult}; -use aimux_core::stream_part::StreamPart; -use aimux_core::tool::{FunctionTool, ProviderTool}; -use aimux_core::types::{FinishReasonUnified, ResponseMetadata}; - -use aimux_providers::anthropic::AnthropicConfig; -use aimux_providers::anthropic::model::AnthropicModel; - -// ───────────────────────────────────────────────────────────────────────────── -// Helpers -// ───────────────────────────────────────────────────────────────────────────── - -/// The TS `TEST_PROMPT`: a single user text message "Hello". -fn test_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] -} - -/// Build `CallOptions` with everything unset except `prompt`. -fn default_options(prompt: LanguageModelPrompt) -> CallOptions { - CallOptions::new(prompt) -} - -/// Build an `AnthropicModel` whose base URL points at the wiremock server. -fn make_model(server: &MockServer) -> AnthropicModel { - AnthropicModel::new( - "claude-3-haiku-20240307".to_string(), - AnthropicConfig::new("test-api-key").with_base_url(server.uri()), - ) -} - -/// Mount a JSON response on `/v1/messages`. -async fn mock_json(server: &MockServer, status: u16, body: Value) { - Mock::given(method("POST")) - .and(path("/v1/messages")) - .respond_with(ResponseTemplate::new(status).set_body_json(body)) - .mount(server) - .await; -} - -/// Mount an SSE stream response on `/v1/messages`. -async fn mock_sse(server: &MockServer, sse_body: &str) { - Mock::given(method("POST")) - .and(path("/v1/messages")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(sse_body.to_string()), - ) - .mount(server) - .await; -} - -/// Format a single Anthropic SSE event as `data: {json}\n\n`. -fn sse(data: &Value) -> String { - format!("data: {data}\n\n") -} - -/// Concatenate an ordered list of SSE events into one response body. -fn sse_stream(events: &[Value]) -> String { - events.iter().map(sse).collect() -} - -/// Drain a `StreamResult` into a `Vec`, panicking on any error. -async fn collect_stream(result: StreamResult) -> Vec { - let mut parts = Vec::new(); - let mut stream = result.stream; - while let Some(part) = stream.next().await { - match part { - Ok(p) => parts.push(p), - Err(e) => panic!("stream error: {e:?}"), - } - } - parts -} - -/// A minimal text response body (mirrors the TS `anthropic-text` shape). -fn text_response(text: &str) -> Value { - json!({ - "id": "msg_017TfcQ4AgGxKyBduUpqYPZn", - "type": "message", - "role": "assistant", - "content": [{ "type": "text", "text": text }], - "model": "claude-3-haiku-20240307", - "stop_reason": "end_turn", - "stop_sequence": null, - "usage": { "input_tokens": 4, "output_tokens": 30 }, - }) -} - -/// Helper to extract the text from a `GenerateContent::Text` item. -fn as_text(item: &GenerateContent) -> &str { - match item { - GenerateContent::Text { text, .. } => text, - _ => panic!("expected Text content, got {item:?}"), - } -} - -/// Helper to destructure a `GenerateContent::ToolCall`. -fn as_tool_call(item: &GenerateContent) -> (&str, &str, &str) { - match item { - GenerateContent::ToolCall(RawToolCall { - tool_call_id, - tool_name, - input, - .. - }) => (tool_call_id, tool_name, input), - _ => panic!("expected ToolCall content, got {item:?}"), - } -} - -/// A function tool with the common TS test schema `{ value: string }`. -fn value_tool() -> Tool { - Tool::Function(FunctionTool::new( - "test-tool".to_string(), - json!({ - "type": "object", - "properties": { "value": { "type": "string" } }, - "required": ["value"], - "additionalProperties": false, - }), - )) -} - -// ═════════════════════════════════════════════════════════════════════════════ -// doGenerate — response parsing -// (anthropic-language-model.test.ts → describe('doGenerate')) -// ═════════════════════════════════════════════════════════════════════════════ - -mod do_generate { - use super::*; - - // ── content extraction ────────────────────────────────────────────────── - - /// TS: "should extract text response" - #[tokio::test] - async fn should_extract_text_response() { - let server = MockServer::start().await; - mock_json( - &server, - 200, - json!({ - "id": "msg_017TfcQ4AgGxKyBduUpqYPZn", - "type": "message", - "role": "assistant", - "content": [{ "type": "text", "text": "Hello, World!" }], - "model": "claude-3-haiku-20240307", - "stop_reason": "end_turn", - "stop_sequence": null, - "usage": { "input_tokens": 4, "output_tokens": 30 }, - }), - ) - .await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!(result.content.len(), 1); - assert_eq!(as_text(&result.content[0]), "Hello, World!"); - } - - /// TS: "should extract usage" - #[tokio::test] - async fn should_extract_usage() { - let server = MockServer::start().await; - mock_json( - &server, - 200, - json!({ - "id": "msg_017TfcQ4AgGxKyBduUpqYPZn", - "type": "message", - "role": "assistant", - "content": [{ "type": "text", "text": "" }], - "model": "claude-3-haiku-20240307", - "stop_reason": "end_turn", - "stop_sequence": null, - "usage": { "input_tokens": 20, "output_tokens": 5 }, - }), - ) - .await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!(result.usage.input_tokens.total, Some(20)); - assert_eq!(result.usage.output_tokens.total, Some(5)); - } - - /// TS: "should send additional response information" - #[tokio::test] - async fn should_send_additional_response_information() { - let server = MockServer::start().await; - mock_json( - &server, - 200, - json!({ - "id": "test-id", - "type": "message", - "role": "assistant", - "content": [{ "type": "text", "text": "" }], - "model": "test-model", - "stop_reason": "end_turn", - "stop_sequence": null, - "usage": { "input_tokens": 4, "output_tokens": 30 }, - }), - ) - .await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!(result.response.id.as_deref(), Some("test-id")); - assert_eq!(result.response.model_id.as_deref(), Some("test-model")); - assert!(result.response.timestamp.is_none()); - } - - /// TS: "should extract tool calls" — text + tool_use, finish_reason tool-calls. - #[tokio::test] - async fn should_extract_tool_calls() { - let server = MockServer::start().await; - mock_json( - &server, - 200, - json!({ - "id": "msg_017TfcQ4AgGxKyBduUpqYPZn", - "type": "message", - "role": "assistant", - "content": [ - { "type": "text", "text": "Some text\n\n" }, - { - "type": "tool_use", - "id": "toolu_1", - "name": "test-tool", - "input": { "value": "example value" }, - }, - ], - "model": "claude-3-haiku-20240307", - "stop_reason": "tool_use", - "stop_sequence": null, - "usage": { "input_tokens": 4, "output_tokens": 30 }, - }), - ) - .await; - - let model = make_model(&server); - let opts = CallOptions { - tools: Some(vec![value_tool()]), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&opts).await.expect("do_generate ok"); - - assert_eq!(result.content.len(), 2); - assert_eq!(as_text(&result.content[0]), "Some text\n\n"); - let (id, name, input) = as_tool_call(&result.content[1]); - assert_eq!(id, "toolu_1"); - assert_eq!(name, "test-tool"); - assert_eq!(input, r#"{"value":"example value"}"#); - - assert_eq!(result.finish_reason.unified, FinishReasonUnified::ToolCalls); - assert_eq!(result.finish_reason.raw.as_deref(), Some("tool_use")); - } - - // ── stop_reason mapping ───────────────────────────────────────────────── - - /// `end_turn` → `Stop`. - #[tokio::test] - async fn should_map_end_turn_to_stop() { - let server = MockServer::start().await; - mock_json(&server, 200, text_response("hi")).await; - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - assert_eq!(result.finish_reason.unified, FinishReasonUnified::Stop); - assert_eq!(result.finish_reason.raw.as_deref(), Some("end_turn")); - } - - /// `max_tokens` → `Length`. - #[tokio::test] - async fn should_map_max_tokens_to_length() { - let server = MockServer::start().await; - let mut body = text_response("hi"); - body["stop_reason"] = json!("max_tokens"); - mock_json(&server, 200, body).await; - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - assert_eq!(result.finish_reason.unified, FinishReasonUnified::Length); - assert_eq!(result.finish_reason.raw.as_deref(), Some("max_tokens")); - } - - /// `tool_use` → `ToolCalls`. - #[tokio::test] - async fn should_map_tool_use_to_tool_calls() { - let server = MockServer::start().await; - let mut body = text_response("hi"); - body["stop_reason"] = json!("tool_use"); - mock_json(&server, 200, body).await; - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - assert_eq!(result.finish_reason.unified, FinishReasonUnified::ToolCalls); - assert_eq!(result.finish_reason.raw.as_deref(), Some("tool_use")); - } - - /// Unknown stop_reason → `Other`. - #[tokio::test] - async fn should_map_unknown_stop_reason_to_other() { - let server = MockServer::start().await; - let mut body = text_response("hi"); - body["stop_reason"] = json!("something_new"); - mock_json(&server, 200, body).await; - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - assert_eq!(result.finish_reason.unified, FinishReasonUnified::Other); - assert_eq!(result.finish_reason.raw.as_deref(), Some("something_new")); - } - - /// Missing stop_reason → `Other` with `raw: None` (current Rust default). - #[tokio::test] - async fn should_default_finish_reason_to_other_when_missing() { - let server = MockServer::start().await; - let mut body = text_response("hi"); - body["stop_reason"] = Value::Null; - mock_json(&server, 200, body).await; - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - assert_eq!(result.finish_reason.unified, FinishReasonUnified::Other); - assert!(result.finish_reason.raw.is_none()); - } - - // ── multi-block content ───────────────────────────────────────────────── - - /// Multiple text blocks are preserved in order. - #[tokio::test] - async fn should_handle_multiple_text_blocks() { - let server = MockServer::start().await; - mock_json( - &server, - 200, - json!({ - "id": "msg_1", - "type": "message", - "role": "assistant", - "content": [ - { "type": "text", "text": "First" }, - { "type": "text", "text": "Second" }, - ], - "model": "claude-3-haiku-20240307", - "stop_reason": "end_turn", - "stop_sequence": null, - "usage": { "input_tokens": 4, "output_tokens": 30 }, - }), - ) - .await; - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - assert_eq!(result.content.len(), 2); - assert_eq!(as_text(&result.content[0]), "First"); - assert_eq!(as_text(&result.content[1]), "Second"); - } - - /// Multiple tool_use blocks (parallel tool calls) are preserved in order. - #[tokio::test] - async fn should_handle_multiple_tool_use_blocks() { - let server = MockServer::start().await; - mock_json( - &server, - 200, - json!({ - "id": "msg_1", - "type": "message", - "role": "assistant", - "content": [ - { - "type": "tool_use", - "id": "toolu_a", - "name": "tool-a", - "input": { "x": 1 }, - }, - { - "type": "tool_use", - "id": "toolu_b", - "name": "tool-b", - "input": { "y": 2 }, - }, - ], - "model": "claude-3-haiku-20240307", - "stop_reason": "tool_use", - "stop_sequence": null, - "usage": { "input_tokens": 4, "output_tokens": 30 }, - }), - ) - .await; - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - assert_eq!(result.content.len(), 2); - let (id_a, name_a, input_a) = as_tool_call(&result.content[0]); - assert_eq!(id_a, "toolu_a"); - assert_eq!(name_a, "tool-a"); - assert_eq!(input_a, r#"{"x":1}"#); - let (id_b, name_b, input_b) = as_tool_call(&result.content[1]); - assert_eq!(id_b, "toolu_b"); - assert_eq!(name_b, "tool-b"); - assert_eq!(input_b, r#"{"y":2}"#); - } - - /// Tool input may be a nested object. - #[tokio::test] - async fn should_handle_tool_use_with_complex_nested_input() { - let server = MockServer::start().await; - mock_json( - &server, - 200, - json!({ - "id": "msg_1", - "type": "message", - "role": "assistant", - "content": [{ - "type": "tool_use", - "id": "toolu_1", - "name": "test-tool", - "input": { - "nested": { "arr": [1, 2, 3], "flag": true }, - "items": ["a", "b"], - }, - }], - "model": "claude-3-haiku-20240307", - "stop_reason": "tool_use", - "stop_sequence": null, - "usage": { "input_tokens": 4, "output_tokens": 30 }, - }), - ) - .await; - let model = make_model(&server); - let opts = CallOptions { - tools: Some(vec![value_tool()]), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&opts).await.unwrap(); - let (_, _, input) = as_tool_call(&result.content[0]); - assert_eq!( - input, - r#"{"nested":{"arr":[1,2,3],"flag":true},"items":["a","b"]}"# - ); - } - - /// Empty content array → no content items, still a valid result. - #[tokio::test] - async fn should_handle_empty_content_array() { - let server = MockServer::start().await; - mock_json( - &server, - 200, - json!({ - "id": "msg_1", - "type": "message", - "role": "assistant", - "content": [], - "model": "claude-3-haiku-20240307", - "stop_reason": "end_turn", - "stop_sequence": null, - "usage": { "input_tokens": 4, "output_tokens": 30 }, - }), - ) - .await; - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - assert!(result.content.is_empty()); - assert_eq!(result.finish_reason.unified, FinishReasonUnified::Stop); - } - - // ── request body & headers ────────────────────────────────────────────── - - /// The request body carries the model id and the single user message. - #[tokio::test] - async fn should_send_model_id_in_request_body() { - let server = MockServer::start().await; - mock_json(&server, 200, text_response("hi")).await; - let model = make_model(&server); - let _ = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - - let requests = server.received_requests().await.expect("requests recorded"); - assert_eq!(requests.len(), 1); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["model"], "claude-3-haiku-20240307"); - assert_eq!(body["messages"][0]["role"], "user"); - assert_eq!(body["messages"][0]["content"][0]["text"], "Hello"); - // Non-streaming request sets stream: false. - assert_eq!(body["stream"], false); - } - - /// Anthropic system message is a top-level `system` field, not in messages. - #[tokio::test] - async fn should_send_system_message_as_top_level_field() { - let server = MockServer::start().await; - mock_json(&server, 200, text_response("hi")).await; - let model = make_model(&server); - - let prompt: LanguageModelPrompt = vec![ - LanguageModelPromptMessage { - role: Role::System, - content: vec![ContentPart::text("You are a helpful assistant.")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }, - ]; - let _ = model.do_generate(&default_options(prompt)).await.unwrap(); - - let requests = server.received_requests().await.unwrap(); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - // system is a top-level array of text blocks. - assert_eq!( - body["system"], - json!([{ "type": "text", "text": "You are a helpful assistant." }]) - ); - // messages array must not contain a system role. - for msg in body["messages"].as_array().unwrap() { - assert_ne!(msg["role"], "system", "system leaked into messages"); - } - } - - /// The x-api-key and anthropic-version headers are sent on every request. - #[tokio::test] - async fn should_send_api_key_and_version_headers() { - let server = MockServer::start().await; - mock_json(&server, 200, text_response("hi")).await; - let model = make_model(&server); - let _ = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - - let requests = server.received_requests().await.unwrap(); - let headers = &requests[0].headers; - assert_eq!( - headers.get("x-api-key").and_then(|v| v.to_str().ok()), - Some("test-api-key") - ); - assert_eq!( - headers - .get("anthropic-version") - .and_then(|v| v.to_str().ok()), - Some("2023-06-01") - ); - } - - // ── error status codes ────────────────────────────────────────────────── - - /// 401 → `AiMuxError::ApiCall` (401 in `status_code`). - #[tokio::test] - async fn should_return_auth_error_on_401() { - let server = MockServer::start().await; - mock_json( - &server, - 401, - json!({ "error": { "message": "invalid api key", "type": "authentication_error" } }), - ) - .await; - let model = make_model(&server); - let err = model - .do_generate(&default_options(test_prompt())) - .await - .expect_err("401 should error"); - assert!(matches!(err, ref e if e.status_code() == Some(401))); - } - - /// 429 → `AiMuxError::ApiCall` (429 in `status_code`). - #[tokio::test] - async fn should_return_rate_limited_on_429() { - let server = MockServer::start().await; - mock_json( - &server, - 429, - json!({ "error": { "message": "rate limited", "type": "rate_limit_error" } }), - ) - .await; - let model = make_model(&server); - let err = model - .do_generate(&default_options(test_prompt())) - .await - .expect_err("429 should error"); - assert!(matches!(err, ref e if e.status_code() == Some(429))); - } - - /// 404 → `AiMuxError::ApiCall` (404 in `status_code`). - #[tokio::test] - async fn should_return_model_not_found_on_404() { - let server = MockServer::start().await; - mock_json( - &server, - 404, - json!({ "error": { "message": "model not found", "type": "not_found_error" } }), - ) - .await; - let model = make_model(&server); - let err = model - .do_generate(&default_options(test_prompt())) - .await - .expect_err("404 should error"); - assert!(matches!(err, ref e if e.status_code() == Some(404))); - } - - /// 500 → `AiMuxError::ApiCall`. - #[tokio::test] - async fn should_return_provider_error_on_500() { - let server = MockServer::start().await; - mock_json( - &server, - 500, - json!({ "error": { "message": "internal error", "type": "api_error" } }), - ) - .await; - let model = make_model(&server); - let err = model - .do_generate(&default_options(test_prompt())) - .await - .expect_err("500 should error"); - assert!(matches!(err, ref e if e.status_code().is_some_and(|s| s >= 500))); - } - - // ── request body options ──────────────────────────────────────────────── - - /// Run `do_generate` with the given options against a fresh mock server and - /// return the serialized request body. - async fn gen_body(options: CallOptions) -> Value { - let server = MockServer::start().await; - mock_json(&server, 200, text_response("hi")).await; - let model = make_model(&server); - model.do_generate(&options).await.unwrap(); - let requests = server.received_requests().await.expect("requests recorded"); - serde_json::from_slice(&requests[0].body).unwrap() - } - - #[tokio::test] - async fn should_send_max_output_tokens() { - let mut opts = default_options(test_prompt()); - opts.max_output_tokens = Some(1234); - let body = gen_body(opts).await; - assert_eq!(body["max_tokens"], 1234); - } - - #[tokio::test] - async fn should_default_max_tokens_to_4096_when_unset() { - let body = gen_body(default_options(test_prompt())).await; - assert_eq!(body["max_tokens"], 4096); - } - - #[tokio::test] - async fn should_send_temperature() { - let mut opts = default_options(test_prompt()); - opts.temperature = Some(0.5); - let body = gen_body(opts).await; - assert_eq!(body["temperature"], 0.5); - } - - #[tokio::test] - async fn should_send_top_p() { - let mut opts = default_options(test_prompt()); - opts.top_p = Some(0.25); - let body = gen_body(opts).await; - assert_eq!(body["top_p"], 0.25); - } - - #[tokio::test] - async fn should_send_stop_sequences() { - let mut opts = default_options(test_prompt()); - opts.stop_sequences = Some(vec!["STOP".to_string(), "END".to_string()]); - let body = gen_body(opts).await; - assert_eq!(body["stop_sequences"], json!(["STOP", "END"])); - } - - #[tokio::test] - async fn should_send_tools_and_tool_choice_auto() { - let mut opts = default_options(test_prompt()); - opts.tools = Some(vec![value_tool()]); - opts.tool_choice = ToolChoice::Auto; - let body = gen_body(opts).await; - assert_eq!(body["tools"][0]["name"], "test-tool"); - assert_eq!(body["tool_choice"], json!({ "type": "auto" })); - } - - #[tokio::test] - async fn should_send_tool_choice_required_as_any() { - let mut opts = default_options(test_prompt()); - opts.tools = Some(vec![value_tool()]); - opts.tool_choice = ToolChoice::Required; - let body = gen_body(opts).await; - assert_eq!(body["tool_choice"], json!({ "type": "any" })); - } - - #[tokio::test] - async fn should_send_tool_choice_tool() { - let mut opts = default_options(test_prompt()); - opts.tools = Some(vec![value_tool()]); - opts.tool_choice = ToolChoice::Tool { - tool_name: "test-tool".to_string(), - }; - let body = gen_body(opts).await; - assert_eq!( - body["tool_choice"], - json!({ "type": "tool", "name": "test-tool" }) - ); - } - - #[tokio::test] - async fn should_omit_tools_and_tool_choice_when_no_tools() { - let body = gen_body(default_options(test_prompt())).await; - assert!(body.get("tools").is_none()); - assert!(body.get("tool_choice").is_none()); - } - - #[tokio::test] - async fn programmatic_tool_caller_reaches_the_public_result() { - let server = MockServer::start().await; - mock_json( - &server, - 200, - json!({ - "id": "msg_programmatic", - "type": "message", - "role": "assistant", - "content": [{ - "type": "tool_use", - "id": "toolu_programmatic", - "name": "test-tool", - "input": { "value": "from code" }, - "caller": { - "type": "code_execution_20250825", - "tool_id": "srvtoolu_code", - }, - }], - "model": "claude-3-haiku-20240307", - "stop_reason": "tool_use", - "usage": { "input_tokens": 100, "output_tokens": 50 }, - }), - ) - .await; - let model = make_model(&server); - - let result = generate_text( - &model, - "Run the tool", - GenerateTextOptions { - tools: Some(vec![value_tool()]), - ..GenerateTextOptions::default() - }, - ) - .await - .unwrap(); - - let metadata = result.tool_calls[0] - .provider_metadata - .as_ref() - .expect("caller metadata"); - assert_eq!( - metadata["anthropic"]["caller"], - json!({ - "type": "code_execution_20250825", - "toolId": "srvtoolu_code", - }) - ); - let response_metadata = match &result.response_messages[0].content { - MessageContent::Parts(parts) => parts.iter().find_map(|part| match part { - ContentPart::ToolCall { - provider_options, .. - } => provider_options.as_ref(), - _ => None, - }), - MessageContent::Text(_) => None, - }; - assert_eq!(response_metadata, Some(metadata)); - } -} - -// ═════════════════════════════════════════════════════════════════════════════ -// doStream — streaming -// (anthropic-language-model.test.ts → describe('doStream')) -// ═════════════════════════════════════════════════════════════════════════════ - -mod do_stream { - use super::*; - - /// The canonical Anthropic text-only stream (mirrors the `anthropic-text` - /// fixture shape), with two text deltas. - fn text_stream() -> String { - sse_stream(&[ - json!({ - "type": "message_start", - "message": { - "id": "msg_1", - "model": "claude-3-haiku-20240307", - "usage": { "input_tokens": 17, "output_tokens": 1 }, - }, - }), - json!({"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}), - json!({"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}), - json!({"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"!"}}), - json!({"type":"content_block_stop","index":0}), - json!({"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":227}}), - json!({"type":"message_stop"}), - ]) - } - - // ── text streaming ────────────────────────────────────────────────────── - - /// TS: "raw chunks" text-only stream — stream-start, response-metadata, - /// text-start, two text-deltas, text-end, finish. - #[tokio::test] - async fn should_stream_text_deltas() { - let server = MockServer::start().await; - mock_sse(&server, &text_stream()).await; - let model = make_model(&server); - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - let parts = collect_stream(result).await; - - // Expected sequence: StreamStart, ResponseMetadata, TextStart, - // TextDelta("Hello"), TextDelta("!"), TextEnd, Finish. - assert_eq!(parts.len(), 7, "parts = {parts:?}"); - - assert!(matches!( - &parts[0], - StreamPart::StreamStart { warnings } if warnings.is_empty() - )); - match &parts[1] { - StreamPart::ResponseMetadata(ResponseMetadata { id, model_id, .. }) => { - assert_eq!(id.as_deref(), Some("msg_1")); - assert_eq!(model_id.as_deref(), Some("claude-3-haiku-20240307")); - } - other => panic!("expected ResponseMetadata, got {other:?}"), - } - match &parts[2] { - StreamPart::TextStart { id, .. } => assert_eq!(id, "0"), - other => panic!("expected TextStart, got {other:?}"), - } - match &parts[3] { - StreamPart::TextDelta { id, delta, .. } => { - assert_eq!(id, "0"); - assert_eq!(delta, "Hello"); - } - other => panic!("expected TextDelta Hello, got {other:?}"), - } - match &parts[4] { - StreamPart::TextDelta { id, delta, .. } => { - assert_eq!(id, "0"); - assert_eq!(delta, "!"); - } - other => panic!("expected TextDelta !, got {other:?}"), - } - match &parts[5] { - StreamPart::TextEnd { id, .. } => assert_eq!(id, "0"), - other => panic!("expected TextEnd, got {other:?}"), - } - match &parts[6] { - StreamPart::Finish { finish_reason, .. } => { - assert_eq!(finish_reason.unified, FinishReasonUnified::Stop); - assert_eq!(finish_reason.raw.as_deref(), Some("end_turn")); - } - other => panic!("expected Finish, got {other:?}"), - } - } - - /// Response metadata is emitted exactly once, right after message_start. - #[tokio::test] - async fn should_emit_response_metadata_after_message_start() { - let server = MockServer::start().await; - mock_sse(&server, &text_stream()).await; - let model = make_model(&server); - let parts = collect_stream( - model - .do_stream(&default_options(test_prompt())) - .await - .unwrap(), - ) - .await; - - let meta_count = parts - .iter() - .filter(|p| matches!(p, StreamPart::ResponseMetadata(_))) - .count(); - assert_eq!(meta_count, 1); - - // The metadata part must come right after StreamStart. - assert!(matches!(parts[0], StreamPart::StreamStart { .. })); - assert!(matches!(parts[1], StreamPart::ResponseMetadata(_))); - } - - /// Usage: input_tokens from message_start, output_tokens from message_delta. - #[tokio::test] - async fn should_extract_usage_from_message_start_and_message_delta() { - let server = MockServer::start().await; - mock_sse(&server, &text_stream()).await; - let model = make_model(&server); - let parts = collect_stream( - model - .do_stream(&default_options(test_prompt())) - .await - .unwrap(), - ) - .await; - - let finish = parts - .iter() - .find_map(|p| match p { - StreamPart::Finish { usage, .. } => Some(usage), - _ => None, - }) - .expect("a Finish part"); - assert_eq!(finish.input_tokens.total, Some(17)); - // message_delta carries output_tokens: 227 (overrides message_start's 1). - assert_eq!(finish.output_tokens.total, Some(227)); - } - - /// `end_turn` stop_reason in message_delta → `Stop`. - #[tokio::test] - async fn should_map_end_turn_stop_reason_in_stream() { - let server = MockServer::start().await; - mock_sse(&server, &text_stream()).await; - let model = make_model(&server); - let parts = collect_stream( - model - .do_stream(&default_options(test_prompt())) - .await - .unwrap(), - ) - .await; - let finish_reason = parts - .iter() - .find_map(|p| match p { - StreamPart::Finish { finish_reason, .. } => Some(finish_reason), - _ => None, - }) - .unwrap(); - assert_eq!(finish_reason.unified, FinishReasonUnified::Stop); - } - - /// `tool_use` stop_reason in message_delta → `ToolCalls`. - #[tokio::test] - async fn should_map_tool_use_stop_reason_in_stream() { - let server = MockServer::start().await; - let sse_body = sse_stream(&[ - json!({ - "type": "message_start", - "message": { - "id": "msg_1", - "model": "claude-3-haiku-20240307", - "usage": { "input_tokens": 10 }, - }, - }), - json!({"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}), - json!({"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"done"}}), - json!({"type":"content_block_stop","index":0}), - json!({"type":"message_delta","delta":{"stop_reason":"tool_use"},"usage":{"output_tokens":5}}), - json!({"type":"message_stop"}), - ]); - mock_sse(&server, &sse_body).await; - let model = make_model(&server); - let parts = collect_stream( - model - .do_stream(&default_options(test_prompt())) - .await - .unwrap(), - ) - .await; - let finish_reason = parts - .iter() - .find_map(|p| match p { - StreamPart::Finish { finish_reason, .. } => Some(finish_reason), - _ => None, - }) - .unwrap(); - assert_eq!(finish_reason.unified, FinishReasonUnified::ToolCalls); - } - - /// When no message_delta carries a stop_reason, finish defaults to `Stop`. - #[tokio::test] - async fn should_default_finish_reason_to_stop_when_missing() { - let server = MockServer::start().await; - let sse_body = sse_stream(&[ - json!({ - "type": "message_start", - "message": { - "id": "msg_1", - "model": "claude-3-haiku-20240307", - "usage": { "input_tokens": 10 }, - }, - }), - json!({"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}), - json!({"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hi"}}), - json!({"type":"content_block_stop","index":0}), - // message_delta with no stop_reason. - json!({"type":"message_delta","delta":{}}), - json!({"type":"message_stop"}), - ]); - mock_sse(&server, &sse_body).await; - let model = make_model(&server); - let parts = collect_stream( - model - .do_stream(&default_options(test_prompt())) - .await - .unwrap(), - ) - .await; - let finish_reason = parts - .iter() - .find_map(|p| match p { - StreamPart::Finish { finish_reason, .. } => Some(finish_reason), - _ => None, - }) - .unwrap(); - assert_eq!(finish_reason.unified, FinishReasonUnified::Stop); - assert!(finish_reason.raw.is_none()); - } - - // ── tool-call streaming ───────────────────────────────────────────────── - - /// TS: "should stream tool deltas" — text block then a tool_use block with - /// input_json_delta fragments. Verifies ToolInputStart, ToolInputDelta*, - /// ToolInputEnd, and the final ToolCall with the parsed JSON. - #[tokio::test] - async fn should_stream_tool_input_deltas() { - let server = MockServer::start().await; - - // Five partial_json fragments that reconstruct - // `{"value":"Sparkle Day"}`. Defined as plain Rust strings so the - // escaping is unambiguous. - let frags: [&str; 5] = ["{\"value", "\":", "\"Spark", "le", " Day\"}"]; - assert_eq!(frags.concat(), r#"{"value":"Sparkle Day"}"#); - - let mut events: Vec = vec![ - json!({ - "type": "message_start", - "message": { - "id": "msg_01GouTqNCGXzrj5LQ5jEkw67", - "model": "claude-3-haiku-20240307", - "usage": { "input_tokens": 441, "output_tokens": 2 }, - }, - }), - json!({"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}), - json!({"type":"ping"}), - json!({"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Okay"}}), - json!({"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"!"}}), - json!({"type":"content_block_stop","index":0}), - json!({"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_01DBsB4vvYLnBDzZ5rBSxSLs","name":"test-tool","input":{}}}), - // Leading empty partial_json — must NOT produce a tool-input-delta. - json!({"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":""}}), - ]; - for frag in &frags { - events.push(json!({ - "type": "content_block_delta", - "index": 1, - "delta": { "type": "input_json_delta", "partial_json": frag } - })); - } - events.push(json!({"type":"content_block_stop","index":1})); - events.push(json!({"type":"message_delta","delta":{"stop_reason":"tool_use"},"usage":{"output_tokens":65}})); - events.push(json!({"type":"message_stop"})); - - let sse_body = sse_stream(&events); - mock_sse(&server, &sse_body).await; - let model = make_model(&server); - let opts = CallOptions { - tools: Some(vec![value_tool()]), - ..default_options(test_prompt()) - }; - let parts = collect_stream(model.do_stream(&opts).await.unwrap()).await; - - // Collect the tool-input-delta fragments (excluding the empty one). - let tool_deltas: Vec = parts - .iter() - .filter_map(|p| match p { - StreamPart::ToolInputDelta { id, delta, .. } => { - assert_eq!(id, "toolu_01DBsB4vvYLnBDzZ5rBSxSLs"); - Some(delta.clone()) - } - _ => None, - }) - .collect(); - assert_eq!( - tool_deltas, - frags - .iter() - .map(std::string::ToString::to_string) - .collect::>() - ); - - // ToolInputStart. - assert!(parts.iter().any(|p| matches!( - p, - StreamPart::ToolInputStart { id, tool_name, .. } - if id == "toolu_01DBsB4vvYLnBDzZ5rBSxSLs" && tool_name == "test-tool" - ))); - - // ToolInputEnd. - assert!(parts.iter().any(|p| matches!( - p, - StreamPart::ToolInputEnd { id, .. } - if id == "toolu_01DBsB4vvYLnBDzZ5rBSxSLs" - ))); - - // Provider output stays raw until the Core stream wrapper parses it. - let tool_call = parts - .iter() - .find_map(|p| match p { - StreamPart::ToolCall(RawToolCall { - tool_call_id, - tool_name, - input, - .. - }) => Some((tool_call_id, tool_name, input)), - _ => None, - }) - .expect("a ToolCall part"); - assert_eq!(tool_call.0, "toolu_01DBsB4vvYLnBDzZ5rBSxSLs"); - assert_eq!(tool_call.1, "test-tool"); - assert_eq!(tool_call.2, r#"{"value":"Sparkle Day"}"#); - - // Finish reason reflects tool_use. - let finish = parts - .iter() - .find_map(|p| match p { - StreamPart::Finish { - finish_reason, - usage, - .. - } => Some((finish_reason, usage)), - _ => None, - }) - .unwrap(); - assert_eq!(finish.0.unified, FinishReasonUnified::ToolCalls); - assert_eq!(finish.1.input_tokens.total, Some(441)); - assert_eq!(finish.1.output_tokens.total, Some(65)); - } - - #[tokio::test] - async fn renamed_client_provider_tool_is_consistent_across_stream_boundary() { - let server = MockServer::start().await; - let sse_body = sse_stream(&[ - json!({ - "type": "message_start", - "message": { - "id": "msg_1", - "model": "claude-3-5-sonnet-latest", - "usage": { "input_tokens": 10, "output_tokens": 1 }, - }, - }), - json!({ - "type": "content_block_start", - "index": 0, - "content_block": { - "type": "tool_use", - "id": "tool_1", - "name": "computer", - "input": {}, - }, - }), - json!({ - "type": "content_block_delta", - "index": 0, - "delta": { - "type": "input_json_delta", - "partial_json": "{\"action\":\"screenshot\"}", - }, - }), - json!({ "type": "content_block_stop", "index": 0 }), - json!({ - "type": "message_delta", - "delta": { "stop_reason": "tool_use" }, - "usage": { "output_tokens": 8 }, - }), - json!({ "type": "message_stop" }), - ]); - mock_sse(&server, &sse_body).await; - let model = make_model(&server); - let tool = Tool::Provider(ProviderTool { - id: "anthropic.computer_20250124".to_string(), - name: "myComputer".to_string(), - args: json!({ "displayWidthPx": 1280, "displayHeightPx": 720 }), - }); - let raw_options = CallOptions { - tools: Some(vec![tool.clone()]), - ..default_options(test_prompt()) - }; - - let raw_parts = collect_stream(model.do_stream(&raw_options).await.unwrap()).await; - assert!(raw_parts.iter().any(|part| matches!( - part, - StreamPart::ToolInputStart { tool_name, .. } if tool_name == "myComputer" - ))); - assert!(raw_parts.iter().any(|part| matches!( - part, - StreamPart::ToolCall(RawToolCall { tool_name, .. }) if tool_name == "myComputer" - ))); - - let result = stream_text( - &model, - "Take a screenshot", - GenerateTextOptions { - tools: Some(vec![tool]), - ..GenerateTextOptions::default() - }, - ) - .await - .expect("stream_text should start") - .consume() - .await - .expect("renamed provider tool should pass Core validation"); - let call = result.tool_calls.first().expect("computer tool call"); - assert_eq!(call.tool_name, "myComputer"); - assert_eq!(call.input, json!({ "action": "screenshot" })); - assert_eq!(call.invalid, None); - } - - #[tokio::test] - async fn implicit_streamed_code_execution_is_dynamic_and_keeps_partial_input() { - let server = MockServer::start().await; - let sse_body = sse_stream(&[ - json!({ - "type": "message_start", - "message": { - "id": "msg_1", - "model": "claude-sonnet-4-5", - "usage": { "input_tokens": 10, "output_tokens": 1 }, - }, - }), - json!({ - "type": "content_block_start", - "index": 0, - "content_block": { - "type": "server_tool_use", - "id": "tool_1", - "name": "code_execution", - "input": {}, - }, - }), - json!({ - "type": "content_block_delta", - "index": 0, - "delta": { - "type": "input_json_delta", - "partial_json": "{\"code\":\"print(2)\"}", - }, - }), - json!({ "type": "content_block_stop", "index": 0 }), - json!({ - "type": "message_delta", - "delta": { "stop_reason": "end_turn" }, - "usage": { "output_tokens": 8 }, - }), - json!({ "type": "message_stop" }), - ]); - mock_sse(&server, &sse_body).await; - let model = make_model(&server); - let web_tool = Tool::Provider(ProviderTool { - id: "anthropic.web_search_20260209".to_string(), - name: "mySearch".to_string(), - args: json!({}), - }); - let options = CallOptions { - tools: Some(vec![web_tool.clone()]), - ..default_options(test_prompt()) - }; - - let raw_parts = collect_stream(model.do_stream(&options).await.unwrap()).await; - assert!(raw_parts.iter().any(|part| matches!( - part, - StreamPart::ToolInputStart { - provider_executed: Some(true), - dynamic: Some(true), - .. - } - ))); - let raw_call = raw_parts - .iter() - .find_map(|part| match part { - StreamPart::ToolCall(RawToolCall { - tool_name, - input, - dynamic, - .. - }) => Some((tool_name, input, dynamic)), - _ => None, - }) - .expect("code execution tool call"); - assert_eq!(raw_call.0, "code_execution"); - let raw_input: Value = serde_json::from_str(raw_call.1).expect("valid tool input JSON"); - assert_eq!(raw_input["type"], "programmatic-tool-call"); - assert_eq!(raw_input["code"], "print(2)"); - assert_eq!(raw_call.2, &Some(true)); - - let result = stream_text( - &model, - "Search and analyze", - GenerateTextOptions { - tools: Some(vec![web_tool]), - ..GenerateTextOptions::default() - }, - ) - .await - .expect("stream_text should start") - .consume() - .await - .expect("dynamic code execution should bypass local lookup"); - let call = result.tool_calls.first().expect("code execution call"); - assert_eq!(call.tool_name, "code_execution"); - assert_eq!(call.input["type"], "programmatic-tool-call"); - assert_eq!(call.dynamic, Some(true)); - assert_eq!(call.invalid, None); - } - - /// TS: "should support tools with empty parameters in streaming" — a - /// tool_use block with only the leading empty `input_json_delta` produces - /// a ToolCall whose input is an empty JSON object. - #[tokio::test] - async fn should_stream_tool_with_empty_input() { - let server = MockServer::start().await; - let sse_body = sse_stream(&[ - json!({ - "type": "message_start", - "message": { - "id": "msg_1", - "model": "claude-3-haiku-20240307", - "usage": { "input_tokens": 565, "output_tokens": 7 }, - }, - }), - json!({"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_01QE","name":"updateIssueList","input":{}}}), - // Only the leading empty partial_json — no real fragments. - json!({"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":""}}), - json!({"type":"content_block_stop","index":0}), - json!({"type":"message_delta","delta":{"stop_reason":"tool_use"},"usage":{"output_tokens":48}}), - json!({"type":"message_stop"}), - ]); - mock_sse(&server, &sse_body).await; - let model = make_model(&server); - let opts = CallOptions { - tools: Some(vec![value_tool()]), - ..default_options(test_prompt()) - }; - let parts = collect_stream(model.do_stream(&opts).await.unwrap()).await; - - // No tool-input-delta should be emitted (the only fragment is empty). - assert!( - !parts - .iter() - .any(|p| matches!(p, StreamPart::ToolInputDelta { .. })), - "no tool-input-delta expected, parts = {parts:?}" - ); - - let tool_call = parts - .iter() - .find_map(|p| match p { - StreamPart::ToolCall(RawToolCall { input, .. }) => Some(input.clone()), - _ => None, - }) - .expect("a ToolCall part"); - assert_eq!(tool_call, "{}"); - } - - // ── misc SSE scenarios ────────────────────────────────────────────────── - - /// `ping` events are silently ignored (no stream part emitted). - #[tokio::test] - async fn should_handle_ping_events() { - let server = MockServer::start().await; - let sse_body = sse_stream(&[ - json!({ - "type": "message_start", - "message": { - "id": "msg_1", - "model": "claude-3-haiku-20240307", - "usage": { "input_tokens": 10 }, - }, - }), - json!({"type":"ping"}), - json!({"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}), - json!({"type":"ping"}), - json!({"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hi"}}), - json!({"type":"content_block_stop","index":0}), - json!({"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":5}}), - json!({"type":"message_stop"}), - ]); - mock_sse(&server, &sse_body).await; - let model = make_model(&server); - let parts = collect_stream( - model - .do_stream(&default_options(test_prompt())) - .await - .unwrap(), - ) - .await; - - // ping must not surface as any stream part; only the expected - // text-stream sequence remains. - let text_deltas: Vec = parts - .iter() - .filter_map(|p| match p { - StreamPart::TextDelta { delta, .. } => Some(delta.clone()), - _ => None, - }) - .collect(); - assert_eq!(text_deltas, vec!["hi".to_string()]); - } - - /// Two consecutive text blocks (index 0 then index 1) each get their own - /// TextStart/TextDelta/TextEnd with the correct index-keyed id. - #[tokio::test] - async fn should_handle_multiple_text_blocks_in_sequence() { - let server = MockServer::start().await; - let sse_body = sse_stream(&[ - json!({ - "type": "message_start", - "message": { - "id": "msg_1", - "model": "claude-3-haiku-20240307", - "usage": { "input_tokens": 10 }, - }, - }), - json!({"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}), - json!({"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"A"}}), - json!({"type":"content_block_stop","index":0}), - json!({"type":"content_block_start","index":1,"content_block":{"type":"text","text":""}}), - json!({"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"B"}}), - json!({"type":"content_block_stop","index":1}), - json!({"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":5}}), - json!({"type":"message_stop"}), - ]); - mock_sse(&server, &sse_body).await; - let model = make_model(&server); - let parts = collect_stream( - model - .do_stream(&default_options(test_prompt())) - .await - .unwrap(), - ) - .await; - - // Two TextStart ids "0" and "1", two TextEnd ids, deltas keyed by id. - let starts: Vec = parts - .iter() - .filter_map(|p| match p { - StreamPart::TextStart { id, .. } => Some(id.clone()), - _ => None, - }) - .collect(); - assert_eq!(starts, vec!["0".to_string(), "1".to_string()]); - let ends: Vec = parts - .iter() - .filter_map(|p| match p { - StreamPart::TextEnd { id, .. } => Some(id.clone()), - _ => None, - }) - .collect(); - assert_eq!(ends, vec!["0".to_string(), "1".to_string()]); - } - - /// A text block followed by a tool_use block (the canonical mixed response). - #[tokio::test] - async fn should_handle_text_then_tool_use() { - let server = MockServer::start().await; - let sse_body = sse_stream(&[ - json!({ - "type": "message_start", - "message": { - "id": "msg_1", - "model": "claude-3-haiku-20240307", - "usage": { "input_tokens": 10 }, - }, - }), - json!({"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}), - json!({"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Calling tool"}}), - json!({"type":"content_block_stop","index":0}), - json!({"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_1","name":"test-tool","input":{}}}), - json!({"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"value\":"}}), - json!({"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"\"x\"}"}}), - json!({"type":"content_block_stop","index":1}), - json!({"type":"message_delta","delta":{"stop_reason":"tool_use"},"usage":{"output_tokens":12}}), - json!({"type":"message_stop"}), - ]); - mock_sse(&server, &sse_body).await; - let model = make_model(&server); - let opts = CallOptions { - tools: Some(vec![value_tool()]), - ..default_options(test_prompt()) - }; - let parts = collect_stream(model.do_stream(&opts).await.unwrap()).await; - - // Text block: TextStart(0) / TextDelta / TextEnd(0). - assert!(parts.iter().any(|p| matches!( - p, - StreamPart::TextStart { id, .. } if id == "0" - ))); - assert!(parts.iter().any(|p| matches!( - p, - StreamPart::TextEnd { id, .. } if id == "0" - ))); - // Tool block: ToolInputStart / ToolInputDelta* / ToolInputEnd / ToolCall. - let tool_call = parts - .iter() - .find_map(|p| match p { - StreamPart::ToolCall(RawToolCall { - tool_call_id, - tool_name, - input, - .. - }) => Some((tool_call_id, tool_name, input)), - _ => None, - }) - .unwrap(); - assert_eq!(tool_call.0, "toolu_1"); - assert_eq!(tool_call.1, "test-tool"); - assert_eq!(tool_call.2, r#"{"value":"x"}"#); - } - - /// Two parallel tool_use blocks (index 0 and index 1) each produce their - /// own ToolInputStart/Delta/End/ToolCall sequence. - #[tokio::test] - async fn should_stream_multiple_tool_calls_parallel() { - let server = MockServer::start().await; - let sse_body = sse_stream(&[ - json!({ - "type": "message_start", - "message": { - "id": "msg_1", - "model": "claude-3-haiku-20240307", - "usage": { "input_tokens": 10 }, - }, - }), - json!({"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_a","name":"test-tool","input":{}}}), - json!({"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"a\":"}}), - json!({"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"1}"}}), - json!({"type":"content_block_stop","index":0}), - json!({"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_b","name":"test-tool","input":{}}}), - json!({"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"b\":"}}), - json!({"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"2}"}}), - json!({"type":"content_block_stop","index":1}), - json!({"type":"message_delta","delta":{"stop_reason":"tool_use"},"usage":{"output_tokens":20}}), - json!({"type":"message_stop"}), - ]); - mock_sse(&server, &sse_body).await; - let model = make_model(&server); - let opts = CallOptions { - tools: Some(vec![value_tool()]), - ..default_options(test_prompt()) - }; - let parts = collect_stream(model.do_stream(&opts).await.unwrap()).await; - - let tool_calls: Vec<(String, String)> = parts - .iter() - .filter_map(|p| match p { - StreamPart::ToolCall(RawToolCall { - tool_call_id, - input, - .. - }) => Some((tool_call_id.clone(), input.clone())), - _ => None, - }) - .collect(); - assert_eq!(tool_calls.len(), 2); - assert_eq!(tool_calls[0].0, "toolu_a"); - assert_eq!(tool_calls[0].1, r#"{"a":1}"#); - assert_eq!(tool_calls[1].0, "toolu_b"); - assert_eq!(tool_calls[1].1, r#"{"b":2}"#); - } - - // ── pre-stream HTTP errors ────────────────────────────────────────────── - - /// 401 before streaming → `AiMuxError::ApiCall` (401 in `status_code`) (no stream is produced). - #[tokio::test] - async fn should_return_auth_error_on_401_before_streaming() { - let server = MockServer::start().await; - mock_json( - &server, - 401, - json!({ "error": { "message": "invalid api key", "type": "authentication_error" } }), - ) - .await; - let model = make_model(&server); - let err = model - .do_stream(&default_options(test_prompt())) - .await - .expect_err("401 should error before streaming"); - assert!(matches!(err, ref e if e.status_code() == Some(401))); - } - - /// 500 before streaming → `Provider` with a 5xx status. - #[tokio::test] - async fn should_return_provider_error_on_500_before_streaming() { - let server = MockServer::start().await; - mock_json( - &server, - 500, - json!({ "error": { "message": "internal error", "type": "api_error" } }), - ) - .await; - let model = make_model(&server); - let err = model - .do_stream(&default_options(test_prompt())) - .await - .expect_err("500 should error before streaming"); - assert!(matches!(err, ref e if e.status_code().is_some_and(|s| s >= 500))); - } - - // ── in-stream error event ─────────────────────────────────────────────── - - /// An Anthropic mid-stream `error` event (e.g. `overloaded_error`) is - /// surfaced as a `StreamPart::Error` and the stream terminates without a - /// `Finish`. Mirrors the TS "first stream chunk is an overloaded error" / - /// "forward overloaded error" cases. - #[tokio::test] - async fn should_forward_in_stream_error_event() { - let server = MockServer::start().await; - let sse_body = sse_stream(&[ - json!({ - "type": "message_start", - "message": { - "id": "msg_1", - "model": "claude-3-haiku-20240307", - "usage": { "input_tokens": 10 }, - }, - }), - json!({"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}), - json!({"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"partial"}}), - // Mid-stream error event. - json!({ - "type": "error", - "error": { - "type": "overloaded_error", - "message": "Overloaded", - }, - }), - ]); - mock_sse(&server, &sse_body).await; - let model = make_model(&server); - let parts = collect_stream( - model - .do_stream(&default_options(test_prompt())) - .await - .unwrap(), - ) - .await; - - // The stream must contain a StreamPart::Error carrying the provider - // message, followed by an error-finish (Finish is the final-chunk - // contract, matching openai/google — see #112). - let error_idx = parts - .iter() - .position(|p| matches!(p, StreamPart::Error { .. })); - let error_idx = error_idx.expect("an Error stream part"); - match &parts[error_idx] { - StreamPart::Error { error } => { - let msg = error.to_string(); - assert!(msg.contains("Overloaded"), "msg = {msg}"); - } - _ => unreachable!(), - } - // Error is followed by exactly one Finish with unified=Error. - assert_eq!(parts.len(), error_idx + 2); - assert!(matches!( - &parts[error_idx + 1], - StreamPart::Finish { finish_reason, .. } - if matches!(finish_reason.unified, FinishReasonUnified::Error) - )); - } - - /// Extended-thinking streams: `signature_delta` pieces accumulate on the - /// thinking block and are attached to the concluding ReasoningEnd as - /// provider_metadata (`{"anthropic": {"signature": ..}}`), matching the - /// non-streaming path (#113). - #[tokio::test] - async fn should_attach_thinking_signature_to_reasoning_end() { - let server = MockServer::start().await; - let sse_body = sse_stream(&[ - json!({ - "type": "message_start", - "message": { - "id": "msg_1", - "model": "claude-3-haiku-20240307", - "usage": { "input_tokens": 10 }, - }, - }), - json!({"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}), - json!({"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"Let me think."}}), - json!({"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"sig-part-1"}}), - json!({"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"sig-part-2"}}), - json!({"type":"content_block_stop","index":0}), - json!({"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":5}}), - json!({"type":"message_stop"}), - ]); - mock_sse(&server, &sse_body).await; - let model = make_model(&server); - let parts = collect_stream( - model - .do_stream(&default_options(test_prompt())) - .await - .unwrap(), - ) - .await; - - assert!( - parts.iter().any(|p| matches!(p, - StreamPart::ReasoningDelta { delta, .. } if delta == "Let me think.")), - "expected the thinking text delta, got {parts:?}" - ); - let meta = parts.iter().find_map(|p| match p { - StreamPart::ReasoningEnd { - provider_metadata, .. - } => provider_metadata.clone(), - _ => None, - }); - let meta = meta.expect("ReasoningEnd with provider_metadata"); - // Incremental signature pieces are concatenated. - assert_eq!( - meta["anthropic"]["signature"].as_str(), - Some("sig-part-1sig-part-2") - ); - } - - #[tokio::test] - async fn streamed_tool_search_results_follow_their_call_ids() { - let server = MockServer::start().await; - let sse_body = sse_stream(&[ - json!({ - "type": "message_start", - "message": { - "id": "msg_tool_search", - "model": "claude-sonnet-4-5", - "usage": { "input_tokens": 10 }, - }, - }), - json!({ - "type": "content_block_start", - "index": 0, - "content_block": { - "type": "server_tool_use", - "id": "bm25_call", - "name": "tool_search_tool_bm25", - "input": { "query": "weather" }, - }, - }), - json!({ "type": "content_block_stop", "index": 0 }), - json!({ - "type": "content_block_start", - "index": 1, - "content_block": { - "type": "tool_search_tool_result", - "tool_use_id": "bm25_call", - "content": { - "type": "tool_search_tool_search_result", - "tool_references": [{ - "type": "tool_reference", - "tool_name": "get_weather", - }], - }, - }, - }), - json!({ "type": "content_block_stop", "index": 1 }), - json!({ - "type": "content_block_start", - "index": 2, - "content_block": { - "type": "server_tool_use", - "id": "regex_call", - "name": "tool_search_tool_regex", - "input": { "pattern": "forecast.*" }, - }, - }), - json!({ "type": "content_block_stop", "index": 2 }), - json!({ - "type": "content_block_start", - "index": 3, - "content_block": { - "type": "tool_search_tool_result", - "tool_use_id": "regex_call", - "content": { - "type": "tool_search_tool_search_result", - "tool_references": [{ - "type": "tool_reference", - "tool_name": "get_forecast", - }], - }, - }, - }), - json!({ "type": "content_block_stop", "index": 3 }), - json!({ - "type": "message_delta", - "delta": { "stop_reason": "end_turn" }, - "usage": { "output_tokens": 20 }, - }), - json!({ "type": "message_stop" }), - ]); - mock_sse(&server, &sse_body).await; - let model = make_model(&server); - let options = CallOptions { - tools: Some(vec![ - Tool::Provider(ProviderTool { - id: "anthropic.tool_search_regex_20251119".to_string(), - name: "regexSearch".to_string(), - args: json!({}), - }), - Tool::Provider(ProviderTool { - id: "anthropic.tool_search_bm25_20251119".to_string(), - name: "semanticSearch".to_string(), - args: json!({}), - }), - ]), - ..default_options(test_prompt()) - }; - - let parts = collect_stream(model.do_stream(&options).await.unwrap()).await; - let result_names: std::collections::HashMap<&str, &str> = parts - .iter() - .filter_map(|part| match part { - StreamPart::ToolResult(ToolResult { - tool_call_id, - tool_name, - .. - }) => Some((tool_call_id.as_str(), tool_name.as_str())), - _ => None, - }) - .collect(); - assert_eq!(result_names.get("bm25_call"), Some(&"semanticSearch")); - assert_eq!(result_names.get("regex_call"), Some(&"regexSearch")); - } - - #[tokio::test] - async fn streamed_programmatic_tool_call_keeps_caller_metadata() { - let server = MockServer::start().await; - let sse_body = sse_stream(&[ - json!({ - "type": "message_start", - "message": { - "id": "msg_programmatic", - "model": "claude-3-haiku-20240307", - "usage": { "input_tokens": 100 }, - }, - }), - json!({ - "type": "content_block_start", - "index": 0, - "content_block": { - "type": "tool_use", - "id": "toolu_programmatic", - "name": "test-tool", - "input": { "value": "from code" }, - "caller": { - "type": "code_execution_20260120", - "tool_id": "srvtoolu_code", - }, - }, - }), - json!({ "type": "content_block_stop", "index": 0 }), - json!({ - "type": "message_delta", - "delta": { "stop_reason": "tool_use" }, - "usage": { "output_tokens": 50 }, - }), - json!({ "type": "message_stop" }), - ]); - mock_sse(&server, &sse_body).await; - let model = make_model(&server); - let options = CallOptions { - tools: Some(vec![value_tool()]), - ..default_options(test_prompt()) - }; - - let parts = collect_stream(model.do_stream(&options).await.unwrap()).await; - let metadata = parts.iter().find_map(|part| match part { - StreamPart::ToolCall(RawToolCall { - provider_metadata, .. - }) => provider_metadata.as_ref(), - _ => None, - }); - assert_eq!( - metadata.expect("caller metadata")["anthropic"]["caller"], - json!({ - "type": "code_execution_20260120", - "toolId": "srvtoolu_code", - }) - ); - } -} diff --git a/aimux-providers/tests/anthropic_provider_tools_test.rs b/aimux-providers/tests/anthropic_provider_tools_test.rs deleted file mode 100644 index 5df2c81aa..000000000 --- a/aimux-providers/tests/anthropic_provider_tools_test.rs +++ /dev/null @@ -1,2071 +0,0 @@ -//! Rust port of the provider-defined-tools section of -//! `anthropic-language-model.test.ts` (doGenerate). -//! -//! Translated from the Vercel AI SDK TypeScript test suite: -//! - `packages/anthropic/src/anthropic-language-model.test.ts` -//! `describe('web search tool')` (L3191-3747) -//! `describe('web fetch tool')` (L3748-3928) -//! `describe('tool search tool')` (L3929-4237) -//! `describe('advisor tool')` (L4238-4409) -//! `describe('mcp servers')` (L4410-4485) -//! `describe('code execution file uploads')` (L4486-4664) -//! `describe('agent skills')` (L4665-4980) -//! `describe('memory 20250818')` (L4981-5049) -//! `describe('code execution 20250825')` (L5050-5116) -//! `describe('code execution 20260120')` (L5155-5221) -//! `describe('code execution 20250522')` (L5223-5507) -//! -//! HTTP is mocked with `wiremock` (a real loopback HTTP server), replacing the -//! TS MSW-based `createTestServer`. Each test starts its own `MockServer` so -//! parallel `#[tokio::test]` runs do not collide. -//! -//! ## Blocking issues -//! -//! Most tests are marked `#[ignore]` because: -//! 1. **`CallOptions.tools` is `Option>`** �?it cannot carry -//! provider-defined tools (`AnthropicTool::Provider`). TS tests pass -//! `tools: [{ type: 'provider', id: 'anthropic.web_search_20250305', ... }]`. -//! Until `CallOptions.tools` is extended to support provider tools (or a -//! separate field is added), these tests cannot be run. -//! 2. ~~**`GenerateContent` lacks server-tool variants**~~ �?resolved. The -//! result blocks are surfaced as `GenerateContent::ToolResult`, and -//! `web_search` hits additionally become `Source` items. -//! 3. **`build_request_body` doesn't read `providerOptions.anthropic.mcpServers` -//! / `container` / `thinking`** �?tests that pass these through -//! `CallOptions.provider_options` compile and run but will fail on -//! assertions until the feature is implemented. -//! 4. **Snapshot tests** �?TS uses `toMatchSnapshot()` / `toMatchInlineSnapshot()`. -//! Rust doesn't use snapshot testing; inline-snapshot tests are translated -//! to explicit assertions where possible. - -use aimux_core::tool::{RawToolCall, ToolResult}; -use std::collections::HashMap; - -use serde_json::{Value, json}; -use wiremock::matchers::{method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -use aimux_core::content::ContentPart; -use aimux_core::generate::{GenerateTextOptions, generate_text}; -use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; -use aimux_core::options::CallOptions; -use aimux_core::result::{GenerateContent, GenerateResult}; -use aimux_core::shared::{SharedProviderOptions, provider_namespace}; -use aimux_core::tool::{FunctionTool, ProviderTool, Tool}; - -use aimux_providers::anthropic::AnthropicConfig; -use aimux_providers::anthropic::model::AnthropicModel; - -// ───────────────────────────────────────────────────────────────────────────── -// Helpers (mirrors anthropic_model_test.rs) -// ───────────────────────────────────────────────────────────────────────────── - -/// The TS `TEST_PROMPT`: a single user text message "Hello". -fn test_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] -} - -/// Build `CallOptions` with everything unset except `prompt`. -fn default_options(prompt: LanguageModelPrompt) -> CallOptions { - CallOptions::new(prompt) -} - -/// Build an `AnthropicModel` whose base URL points at the wiremock server. -fn make_model(server: &MockServer) -> AnthropicModel { - AnthropicModel::new( - "claude-3-haiku-20240307".to_string(), - AnthropicConfig::new("test-api-key").with_base_url(server.uri()), - ) -} - -/// Build an `AnthropicModel` with a specific model id. -fn make_model_with_id(server: &MockServer, model_id: &str) -> AnthropicModel { - AnthropicModel::new( - model_id.to_string(), - AnthropicConfig::new("test-api-key").with_base_url(server.uri()), - ) -} - -/// Mount a JSON response on `/v1/messages`. -async fn mock_json(server: &MockServer, status: u16, body: Value) { - Mock::given(method("POST")) - .and(path("/v1/messages")) - .respond_with(ResponseTemplate::new(status).set_body_json(body)) - .mount(server) - .await; -} - -/// A minimal text response body. -fn text_response(text: &str) -> Value { - json!({ - "id": "msg_test", - "type": "message", - "role": "assistant", - "content": [{ "type": "text", "text": text }], - "model": "claude-3-haiku-20240307", - "stop_reason": "end_turn", - "stop_sequence": null, - "usage": { "input_tokens": 10, "output_tokens": 20 }, - }) -} - -/// Run `do_generate` and return the serialized request body. -#[allow(dead_code)] -async fn gen_body(options: CallOptions) -> Value { - let server = MockServer::start().await; - mock_json(&server, 200, text_response("hi")).await; - let model = make_model(&server); - let _ = model.do_generate(&options).await.unwrap(); - let requests = server.received_requests().await.expect("requests recorded"); - serde_json::from_slice(&requests[0].body).unwrap() -} - -/// Run `do_generate` with a specific model id and return (request_body, result). -#[allow(dead_code)] -async fn gen_with_model( - model_id: &str, - options: CallOptions, - response: Value, -) -> (Value, GenerateResult) { - let server = MockServer::start().await; - mock_json(&server, 200, response).await; - let model = make_model_with_id(&server, model_id); - let result = model.do_generate(&options).await.unwrap(); - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - (body, result) -} - -/// Get the request headers from the first request to the server. -async fn get_request_headers(server: &MockServer) -> Vec<(String, String)> { - let requests = server.received_requests().await.expect("requests recorded"); - requests[0] - .headers - .iter() - .map(|(k, v)| (k.as_str().to_string(), v.to_str().unwrap().to_string())) - .collect() -} - -/// Helper to build `provider_options` from a JSON value. -fn anthropic_opts(value: Value) -> Option { - Some(provider_namespace("anthropic", value)) -} - -/// Build a provider-defined tool: `Tool::Provider(ProviderTool { id, name, args })`. -fn provider_tool(id: &str, name: &str, args: Value) -> Tool { - Tool::Provider(ProviderTool { - id: id.to_string(), - name: name.to_string(), - args, - }) -} - -/// Build a `calculator` function tool. When `eager` is true, sets the -/// Anthropic `eagerInputStreaming` provider option (mirrors the TS test that -/// mixes a client-side tool with a provider-defined tool). -fn calculator_tool(description: &str, eager: bool) -> Tool { - let provider_options = if eager { - anthropic_opts(json!({ "eagerInputStreaming": true })) - } else { - None - }; - Tool::Function(FunctionTool { - name: "calculator".to_string(), - description: Some(description.to_string()), - input_schema: json!({ "type": "object", "properties": {} }), - strict: None, - provider_options, - input_examples: None, - }) -} - -// ════════════════════════════════════════════════════════════════════════════�? -// web search tool -// (anthropic-language-model.test.ts L3191-3747) -// ════════════════════════════════════════════════════════════════════════════�? - -mod web_search_tool { - use super::*; - - /// TS: "should send request body with include and tool" (L3217) - /// - /// Verifies the request body contains the web_search_20250305 tool with - /// the correct max_uses and user_location fields. - #[tokio::test] - async fn should_send_request_body_with_include_and_tool() { - let server = MockServer::start().await; - // TS uses fixture 'anthropic-web-search-tool.1'; we use a minimal text response. - mock_json(&server, 200, text_response("Search results")).await; - let model = make_model(&server); - - // TS: tools: [{ type: 'provider', id: 'anthropic.web_search_20250305', - // name: 'web_search', args: { maxUses: 1, userLocation: { type: - // 'approximate', country: 'US' } } }] - let mut options = default_options(test_prompt()); - options.tools = Some(vec![provider_tool( - "anthropic.web_search_20250305", - "web_search", - json!({ "maxUses": 1, "userLocation": { "type": "approximate", "country": "US" } }), - )]); - let _result = model.do_generate(&options).await.unwrap(); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["tools"][0]["type"], "web_search_20250305"); - assert_eq!(body["tools"][0]["name"], "web_search"); - assert_eq!(body["tools"][0]["max_uses"], 1); - assert_eq!(body["tools"][0]["user_location"]["country"], "US"); - } - - /// TS: "should include web search tool call and result in content" (L3248) - #[tokio::test] - async fn should_include_web_search_tool_call_and_result_in_content() { - // Snapshot test �?requires fixture + response parsing for server tools. - } - - /// TS: "should enable server-side web search when using - /// anthropic.tools.webSearch_20250305" (L3262) - #[tokio::test] - async fn should_enable_server_side_web_search_20250305() { - let server = MockServer::start().await; - mock_json( - &server, - 200, - json!({ - "type": "message", - "id": "msg_test", - "content": [{ "type": "text", "text": "Quantum computing breakthroughs." }], - "stop_reason": "end_turn", - "usage": { "input_tokens": 10, "output_tokens": 20 }, - }), - ) - .await; - let model = make_model(&server); - - // TS: tools: [{ type: 'provider', id: 'anthropic.web_search_20250305', - // name: 'web_search', args: { maxUses: 3, allowedDomains: ['arxiv.org', - // 'nature.com', 'mit.edu'] } }] - let mut options = default_options(test_prompt()); - options.tools = Some(vec![provider_tool( - "anthropic.web_search_20250305", - "web_search", - json!({ "maxUses": 3, "allowedDomains": ["arxiv.org", "nature.com", "mit.edu"] }), - )]); - let _result = model.do_generate(&options).await.unwrap(); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["tools"].as_array().unwrap().len(), 1); - assert_eq!( - body["tools"][0], - json!({ - "type": "web_search_20250305", - "name": "web_search", - "max_uses": 3, - "allowed_domains": ["arxiv.org", "nature.com", "mit.edu"], - }) - ); - } - - /// TS: "should enable server-side web search when using - /// anthropic.tools.webSearch_20260209" (L3305) - #[tokio::test] - async fn should_enable_server_side_web_search_20260209() { - let server = MockServer::start().await; - mock_json( - &server, - 200, - json!({ - "type": "message", - "id": "msg_test", - "content": [{ "type": "text", "text": "Quantum computing breakthroughs." }], - "stop_reason": "end_turn", - "usage": { "input_tokens": 10, "output_tokens": 20 }, - }), - ) - .await; - let model = make_model(&server); - - // TS: tools: [{ type: 'provider', id: 'anthropic.web_search_20260209', - // name: 'web_search', args: { maxUses: 3, allowedDomains: [...] } }] - let mut options = default_options(test_prompt()); - options.tools = Some(vec![provider_tool( - "anthropic.web_search_20260209", - "web_search", - json!({ "maxUses": 3, "allowedDomains": ["arxiv.org", "nature.com", "mit.edu"] }), - )]); - let _result = model.do_generate(&options).await.unwrap(); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["tools"].as_array().unwrap().len(), 1); - assert_eq!( - body["tools"][0], - json!({ - "type": "web_search_20260209", - "name": "web_search", - "max_uses": 3, - "allowed_domains": ["arxiv.org", "nature.com", "mit.edu"], - }) - ); - // TS: expect(server.calls[0].requestHeaders['anthropic-beta']) - // .toBe('code-execution-web-tools-2026-02-09') - let headers = get_request_headers(&server).await; - let beta = headers - .iter() - .find(|(k, _)| k == "anthropic-beta") - .map(|(_, v)| v.as_str()); - assert_eq!(beta, Some("code-execution-web-tools-2026-02-09")); - } - - /// TS: "should pass web search configuration with blocked domains" (L3351) - #[tokio::test] - async fn should_pass_web_search_configuration_with_blocked_domains() { - let server = MockServer::start().await; - mock_json( - &server, - 200, - json!({ - "type": "message", - "id": "msg_test", - "content": [{ "type": "text", "text": "Stock market trends." }], - "stop_reason": "end_turn", - "usage": { "input_tokens": 10, "output_tokens": 20 }, - }), - ) - .await; - let model = make_model(&server); - - // TS: tools: [{ type: 'provider', id: 'anthropic.web_search_20250305', - // name: 'web_search', args: { maxUses: 2, blockedDomains: ['reddit.com'] } }] - let mut options = default_options(test_prompt()); - options.tools = Some(vec![provider_tool( - "anthropic.web_search_20250305", - "web_search", - json!({ "maxUses": 2, "blockedDomains": ["reddit.com"] }), - )]); - let _result = model.do_generate(&options).await.unwrap(); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["tools"].as_array().unwrap().len(), 1); - assert_eq!( - body["tools"][0], - json!({ - "type": "web_search_20250305", - "name": "web_search", - "max_uses": 2, - "blocked_domains": ["reddit.com"], - }) - ); - } - - /// TS: "should handle web search with user location" (L3394) - #[tokio::test] - async fn should_handle_web_search_with_user_location() { - let server = MockServer::start().await; - mock_json( - &server, - 200, - json!({ - "type": "message", - "id": "msg_test", - "content": [{ "type": "text", "text": "Local tech events." }], - "stop_reason": "end_turn", - "usage": { "input_tokens": 10, "output_tokens": 20 }, - }), - ) - .await; - let model = make_model(&server); - - // TS: tools with userLocation: { type: 'approximate', city: 'New York', - // region: 'New York', country: 'US', timezone: 'America/New_York' } - let mut options = default_options(test_prompt()); - options.tools = Some(vec![provider_tool( - "anthropic.web_search_20250305", - "web_search", - json!({ - "userLocation": { - "type": "approximate", - "city": "New York", - "region": "New York", - "country": "US", - "timezone": "America/New_York", - } - }), - )]); - let _result = model.do_generate(&options).await.unwrap(); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["tools"].as_array().unwrap().len(), 1); - assert_eq!( - body["tools"][0]["user_location"], - json!({ - "type": "approximate", - "city": "New York", - "region": "New York", - "country": "US", - "timezone": "America/New_York", - }) - ); - } - - /// TS: "should handle web search with partial user location (city + country)" (L3444) - #[tokio::test] - async fn should_handle_web_search_with_partial_user_location() { - let server = MockServer::start().await; - mock_json( - &server, - 200, - json!({ - "type": "message", - "id": "msg_test", - "content": [{ "type": "text", "text": "Local events." }], - "stop_reason": "end_turn", - "usage": { "input_tokens": 10, "output_tokens": 20 }, - }), - ) - .await; - let model = make_model(&server); - - // TS: userLocation: { type: 'approximate', city: 'London', country: 'GB' } - let mut options = default_options(test_prompt()); - options.tools = Some(vec![provider_tool( - "anthropic.web_search_20250305", - "web_search", - json!({ - "userLocation": { "type": "approximate", "city": "London", "country": "GB" } - }), - )]); - let _result = model.do_generate(&options).await.unwrap(); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["tools"].as_array().unwrap().len(), 1); - assert_eq!( - body["tools"][0]["user_location"], - json!({ - "type": "approximate", - "city": "London", - "country": "GB", - }) - ); - } - - /// TS: "should handle web search with minimal user location (country only)" (L3490) - #[tokio::test] - async fn should_handle_web_search_with_minimal_user_location() { - let server = MockServer::start().await; - mock_json( - &server, - 200, - json!({ - "type": "message", - "id": "msg_test", - "content": [{ "type": "text", "text": "Global events." }], - "stop_reason": "end_turn", - "usage": { "input_tokens": 10, "output_tokens": 20 }, - }), - ) - .await; - let model = make_model(&server); - - // TS: userLocation: { type: 'approximate', country: 'US' } - let mut options = default_options(test_prompt()); - options.tools = Some(vec![provider_tool( - "anthropic.web_search_20250305", - "web_search", - json!({ "userLocation": { "type": "approximate", "country": "US" } }), - )]); - let _result = model.do_generate(&options).await.unwrap(); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["tools"].as_array().unwrap().len(), 1); - assert_eq!( - body["tools"][0]["user_location"], - json!({ - "type": "approximate", - "country": "US", - }) - ); - } - - /// TS: "should handle server-side web search results with citations" (L3534) - #[tokio::test] - async fn should_handle_server_side_web_search_results_with_citations() { - let server = MockServer::start().await; - mock_json( - &server, - 200, - json!({ - "type": "message", - "id": "msg_test", - "content": [ - { - "type": "server_tool_use", - "id": "tool_1", - "name": "web_search", - "input": { "query": "latest AI news" }, - }, - { - "type": "web_search_tool_result", - "tool_use_id": "tool_1", - "content": [ - { - "type": "web_search_result", - "url": "https://example.com/ai-news", - "title": "Latest AI Developments", - "encrypted_content": "encrypted_content_123", - "page_age": "January 15, 2025", - }, - ], - }, - { - "type": "text", - "text": "Based on recent articles, AI continues to advance rapidly.", - }, - ], - "stop_reason": "end_turn", - "usage": { - "input_tokens": 10, - "output_tokens": 20, - "server_tool_use": { "web_search_requests": 1 }, - }, - }), - ) - .await; - let model = make_model_with_id(&server, "claude-3-5-sonnet-latest"); - - // TS: tools: [{ type: 'provider', id: 'anthropic.web_search_20250305', - // name: 'web_search', args: { maxUses: 5 } }] - let mut options = default_options(test_prompt()); - options.tools = Some(vec![provider_tool( - "anthropic.web_search_20250305", - "mySearch", - json!({ "maxUses": 5 }), - )]); - let raw_result = model.do_generate(&options).await.unwrap(); - - // TS expects content to contain: - // 1. tool-call (providerExecuted: true, toolName: 'web_search') - // 2. tool-result (web_search_result array) - // 3. source (url, title, pageAge in providerMetadata) - // 4. text - // The server_tool_use block is surfaced as a tool-call so the turn - // round-trips; the result/source mapping is not yet asserted here. - assert!(raw_result.content.iter().any( - |c| matches!(c, GenerateContent::ToolCall(RawToolCall { tool_name, .. }) if tool_name == "mySearch") - )); - - let result = generate_text( - &model, - "Hello", - GenerateTextOptions { - tools: options.tools.clone(), - ..GenerateTextOptions::default() - }, - ) - .await - .expect("renamed server tool should pass Core validation"); - let call = result.tool_calls.first().expect("web search tool call"); - assert_eq!(call.tool_name, "mySearch"); - assert_eq!(call.provider_executed, Some(true)); - assert_eq!(call.invalid, None); - } - - #[tokio::test] - async fn client_executed_provider_tool_uses_caller_name_at_core_boundary() { - let server = MockServer::start().await; - mock_json( - &server, - 200, - json!({ - "type": "message", - "id": "msg_test", - "model": "claude-3-5-sonnet-latest", - "content": [{ - "type": "tool_use", - "id": "tool_1", - "name": "computer", - "input": { "action": "screenshot" }, - }], - "stop_reason": "tool_use", - "usage": { "input_tokens": 10, "output_tokens": 20 }, - }), - ) - .await; - let model = make_model_with_id(&server, "claude-3-5-sonnet-latest"); - let tools = vec![provider_tool( - "anthropic.computer_20250124", - "myComputer", - json!({ "displayWidthPx": 1280, "displayHeightPx": 720 }), - )]; - - let result = generate_text( - &model, - "Take a screenshot", - GenerateTextOptions { - tools: Some(tools), - ..GenerateTextOptions::default() - }, - ) - .await - .expect("renamed client-executed provider tool should validate"); - - let call = result.tool_calls.first().expect("computer tool call"); - assert_eq!(call.tool_name, "myComputer"); - assert_eq!(call.provider_executed, None); - assert_eq!(call.input, json!({ "action": "screenshot" })); - assert_eq!(call.invalid, None); - } - - #[tokio::test] - async fn implicit_code_execution_from_2026_web_tool_is_dynamic() { - let server = MockServer::start().await; - mock_json( - &server, - 200, - json!({ - "type": "message", - "id": "msg_test", - "model": "claude-sonnet-4-5", - "content": [{ - "type": "server_tool_use", - "id": "tool_1", - "name": "code_execution", - "input": { "code": "print(2)" }, - }], - "stop_reason": "end_turn", - "usage": { "input_tokens": 10, "output_tokens": 20 }, - }), - ) - .await; - let model = make_model_with_id(&server, "claude-sonnet-4-5"); - - let result = generate_text( - &model, - "Search, then analyze", - GenerateTextOptions { - tools: Some(vec![provider_tool( - "anthropic.web_search_20260209", - "mySearch", - json!({}), - )]), - ..GenerateTextOptions::default() - }, - ) - .await - .expect("implicit code execution should bypass local tool lookup"); - - let call = result.tool_calls.first().expect("code execution call"); - assert_eq!(call.tool_name, "code_execution"); - assert_eq!(call.provider_executed, Some(true)); - assert_eq!(call.dynamic, Some(true)); - assert_eq!(call.invalid, None); - assert_eq!(call.input["type"], "programmatic-tool-call"); - } - - /// TS: "should handle server-side web search errors" (L3636) - #[tokio::test] - async fn should_handle_server_side_web_search_errors() { - let server = MockServer::start().await; - mock_json( - &server, - 200, - json!({ - "type": "message", - "id": "msg_test", - "content": [ - { - "type": "web_search_tool_result", - "tool_use_id": "tool_1", - "content": { - "type": "web_search_tool_result_error", - "error_code": "max_uses_exceeded", - }, - }, - { - "type": "text", - "text": "I cannot search further due to limits.", - }, - ], - "stop_reason": "end_turn", - "usage": { "input_tokens": 10, "output_tokens": 20 }, - }), - ) - .await; - let model = make_model(&server); - - // TS: tools: [{ type: 'provider', id: 'anthropic.web_search_20250305', - // name: 'web_search', args: { maxUses: 1 } }] - let mut options = default_options(test_prompt()); - options.tools = Some(vec![provider_tool( - "anthropic.web_search_20250305", - "web_search", - json!({ "maxUses": 1 }), - )]); - let result = model.do_generate(&options).await.unwrap(); - - // TS expects content to contain: - // 1. tool-result (isError: true, errorCode: 'max_uses_exceeded') - // 2. text - let tool_result = result - .content - .iter() - .find_map(|c| match c { - GenerateContent::ToolResult(ToolResult { - result, is_error, .. - }) => Some((result, is_error)), - _ => None, - }) - .expect("the error result block must be surfaced"); - assert_eq!(*tool_result.1, Some(true)); - assert_eq!( - tool_result.0["errorCode"], - json!("max_uses_exceeded"), - "`error_code` is re-keyed to `errorCode`" - ); - assert!( - result - .content - .iter() - .any(|c| matches!(c, GenerateContent::Text { .. })), - "the trailing text must still be surfaced" - ); - } - - /// TS: "should work alongside regular client-side tools" (L3695) - #[tokio::test] - async fn should_work_alongside_regular_client_side_tools() { - let server = MockServer::start().await; - mock_json( - &server, - 200, - json!({ - "type": "message", - "id": "msg_test", - "content": [{ "type": "text", "text": "I can search and calculate." }], - "stop_reason": "end_turn", - "usage": { "input_tokens": 10, "output_tokens": 20 }, - }), - ) - .await; - let model = make_model(&server); - - // TS: tools: [ - // { type: 'function', name: 'calculator', description: 'Calculate math', - // inputSchema: { type: 'object', properties: {} }, - // providerOptions: { anthropic: { eagerInputStreaming: true } } }, - // { type: 'provider', id: 'anthropic.web_search_20250305', - // name: 'web_search', args: { maxUses: 1 } }, - // ] - let mut options = default_options(test_prompt()); - options.tools = Some(vec![ - calculator_tool("Calculate math", true), - provider_tool( - "anthropic.web_search_20250305", - "web_search", - json!({ "maxUses": 1 }), - ), - ]); - let _result = model.do_generate(&options).await.unwrap(); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["tools"].as_array().unwrap().len(), 2); - // tools[0] is the function tool 'calculator' - assert_eq!(body["tools"][0]["name"], "calculator"); - assert_eq!(body["tools"][0]["eager_input_streaming"], true); - // tools[1] is the web_search provider tool - assert_eq!(body["tools"][1]["type"], "web_search_20250305"); - assert_eq!(body["tools"][1]["max_uses"], 1); - } -} - -// ════════════════════════════════════════════════════════════════════════════�? -// web fetch tool -// (anthropic-language-model.test.ts L3748-3928) -// ════════════════════════════════════════════════════════════════════════════�? - -mod web_fetch_tool { - use super::*; - - /// TS: "should send request body with include and tool" (L3768) - #[tokio::test] - async fn should_send_request_body_with_include_and_tool() { - let server = MockServer::start().await; - // TS uses fixture 'anthropic-web-fetch-tool.1' - mock_json(&server, 200, text_response("Fetched content")).await; - let model = make_model(&server); - - // TS: tools: [{ type: 'provider', id: 'anthropic.web_fetch_20250910', - // name: 'web_fetch', args: { maxUses: 1 } }] - let mut options = default_options(test_prompt()); - options.tools = Some(vec![provider_tool( - "anthropic.web_fetch_20250910", - "web_fetch", - json!({ "maxUses": 1 }), - )]); - let _result = model.do_generate(&options).await.unwrap(); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["tools"][0]["type"], "web_fetch_20250910"); - assert_eq!(body["tools"][0]["name"], "web_fetch"); - assert_eq!(body["tools"][0]["max_uses"], 1); - } - - /// TS: "should include web fetch tool call and result in content" (L3795) - #[tokio::test] - async fn should_include_web_fetch_tool_call_and_result_in_content() { - // Snapshot test �?requires fixture + response parsing for server tools. - } - - /// TS: "should use web_fetch_20260209 for anthropic.tools.webFetch_20260209" (L3800) - #[tokio::test] - async fn should_use_web_fetch_20260209() { - let server = MockServer::start().await; - mock_json( - &server, - 200, - json!({ - "type": "message", - "id": "msg_test", - "content": [{ "type": "text", "text": "Fetched result." }], - "stop_reason": "end_turn", - "usage": { "input_tokens": 10, "output_tokens": 20 }, - }), - ) - .await; - let model = make_model(&server); - - // TS: tools: [{ type: 'provider', id: 'anthropic.web_fetch_20260209', - // name: 'web_fetch', args: { maxUses: 1 } }] - let mut options = default_options(test_prompt()); - options.tools = Some(vec![provider_tool( - "anthropic.web_fetch_20260209", - "web_fetch", - json!({ "maxUses": 1 }), - )]); - let _result = model.do_generate(&options).await.unwrap(); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["tools"].as_array().unwrap().len(), 1); - assert_eq!( - body["tools"][0], - json!({ - "type": "web_fetch_20260209", - "name": "web_fetch", - "max_uses": 1, - }) - ); - let headers = get_request_headers(&server).await; - let beta = headers - .iter() - .find(|(k, _)| k == "anthropic-beta") - .map(|(_, v)| v.as_str()); - assert_eq!(beta, Some("code-execution-web-tools-2026-02-09")); - } - - /// TS: "should include web fetch tool call with input in content" (L3861, 20260209 variant) - #[tokio::test] - async fn should_include_web_fetch_tool_call_with_input_in_content() { - // TS uses fixture 'anthropic-web-fetch-tool-20260209.1' - // Expects content to contain a tool-call with: - // toolName: 'web_fetch', input: '{"url":"https://example.com"}', - // providerExecuted: true - } - - /// TS: "should include web fetch 20260209 tool call and result in content" (L3875) - #[tokio::test] - async fn should_include_web_fetch_20260209_tool_call_and_result_in_content() { - // Snapshot test �?requires fixture + response parsing for server tools. - } - - /// TS: "should include web fetch tool call and result in content" (L3899, without title) - #[tokio::test] - async fn should_include_web_fetch_tool_call_and_result_without_title() { - // Snapshot test �?requires fixture + response parsing for server tools. - } - - /// TS: "should include web fetch tool call and result in content" (L3923, unavailable error) - #[tokio::test] - async fn should_include_web_fetch_tool_call_and_result_unavailable_error() { - // Snapshot test �?requires fixture + response parsing for server tools. - } -} - -// ════════════════════════════════════════════════════════════════════════════�? -// tool search tool -// (anthropic-language-model.test.ts L3929-4237) -// ════════════════════════════════════════════════════════════════════════════�? - -mod tool_search_tool { - use super::*; - - /// Helper: a function tool with deferLoading provider option. - #[allow(dead_code)] - fn deferred_function_tool(name: &str, desc: &str) -> FunctionTool { - FunctionTool { - name: name.to_string(), - description: Some(desc.to_string()), - input_schema: json!({ - "type": "object", - "properties": { "location": { "type": "string" } }, - }), - strict: None, - provider_options: anthropic_opts(json!({ "deferLoading": true })), - input_examples: None, - } - } - - /// TS: "should send request body with tool search tool and deferred tools" (L3971, regex) - #[tokio::test] - async fn should_send_request_body_with_tool_search_regex_and_deferred_tools() { - let server = MockServer::start().await; - // TS uses fixture 'anthropic-tool-search-regex.1' - mock_json(&server, 200, text_response("Weather data")).await; - let model = make_model_with_id(&server, "claude-sonnet-4-5"); - - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Find out weather data in SF")], - ..Default::default() - }]; - let mut options = default_options(prompt); - options.tools = Some(vec![ - provider_tool( - "anthropic.tool_search_regex_20251119", - "tool_search", - json!({}), - ), - Tool::Function(deferred_function_tool("get_temp_data", "For a location")), - ]); - let _result = model.do_generate(&options).await.unwrap(); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["max_tokens"], 64000); - assert_eq!(body["model"], "claude-sonnet-4-5"); - assert_eq!(body["tools"].as_array().unwrap().len(), 2); - assert_eq!( - body["tools"][0], - json!({ - "name": "tool_search_tool_regex", - "type": "tool_search_tool_regex_20251119", - }) - ); - assert_eq!(body["tools"][1]["defer_loading"], true); - assert_eq!(body["tools"][1]["name"], "get_temp_data"); - } - - /// TS: "should include advanced-tool-use beta header" (L4017, regex) - #[tokio::test] - async fn should_include_beta_header_regex() { - let server = MockServer::start().await; - mock_json(&server, 200, text_response("hi")).await; - let model = make_model_with_id(&server, "claude-sonnet-4-5"); - - let mut options = default_options(test_prompt()); - options.tools = Some(vec![ - provider_tool( - "anthropic.tool_search_regex_20251119", - "tool_search", - json!({}), - ), - Tool::Function(deferred_function_tool("get_temp_data", "For a location")), - ]); - let _result = model.do_generate(&options).await.unwrap(); - - let headers = get_request_headers(&server).await; - let beta = headers - .iter() - .find(|(k, _)| k == "anthropic-beta") - .map(|(_, v)| v.as_str()); - // claude-sonnet-4-5 supports structured outputs, so a function tool in - // the request triggers the structured-outputs beta. - assert_eq!(beta, Some("structured-outputs-2025-11-13")); - } - - /// TS: "should include tool search tool call and result in content" (L4028, regex) - #[tokio::test] - async fn should_include_tool_search_regex_tool_call_and_result_in_content() { - // Snapshot test �?requires fixture + response parsing for server tools. - } - - /// TS: "should send request body with tool search bm25 tool" (L4077) - #[tokio::test] - async fn should_send_request_body_with_tool_search_bm25() { - let server = MockServer::start().await; - // TS uses fixture 'anthropic-tool-search-bm25.1' - mock_json(&server, 200, text_response("Weather data")).await; - let model = make_model_with_id(&server, "claude-sonnet-4-5"); - - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("What is the weather in San Francisco?")], - ..Default::default() - }]; - let mut options = default_options(prompt); - options.tools = Some(vec![ - provider_tool( - "anthropic.tool_search_bm25_20251119", - "tool_search", - json!({}), - ), - Tool::Function(deferred_function_tool( - "get_weather", - "Get the current weather", - )), - ]); - let _result = model.do_generate(&options).await.unwrap(); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["max_tokens"], 64000); - assert_eq!(body["model"], "claude-sonnet-4-5"); - assert_eq!(body["tools"].as_array().unwrap().len(), 2); - assert_eq!( - body["tools"][0], - json!({ - "name": "tool_search_tool_bm25", - "type": "tool_search_tool_bm25_20251119", - }) - ); - assert_eq!(body["tools"][1]["defer_loading"], true); - assert_eq!(body["tools"][1]["name"], "get_weather"); - } - - /// TS: "should include advanced-tool-use beta header" (L4123, bm25) - // TODO: requires CallOptions.tools to support provider tools - #[tokio::test] - async fn should_include_beta_header_bm25() { - let server = MockServer::start().await; - mock_json(&server, 200, text_response("hi")).await; - let model = make_model_with_id(&server, "claude-sonnet-4-5"); - - let mut options = default_options(test_prompt()); - options.tools = Some(vec![ - provider_tool( - "anthropic.tool_search_bm25_20251119", - "tool_search", - json!({}), - ), - Tool::Function(deferred_function_tool( - "get_weather", - "Get the current weather", - )), - ]); - let _result = model.do_generate(&options).await.unwrap(); - - let headers = get_request_headers(&server).await; - let beta = headers - .iter() - .find(|(k, _)| k == "anthropic-beta") - .map(|(_, v)| v.as_str()); - assert_eq!(beta, Some("structured-outputs-2025-11-13")); - } - - /// TS: "should include tool search tool call and result in content" (L4134, bm25) - #[tokio::test] - #[ignore = "snapshot test �?requires fixture + response parsing for server tools"] - async fn should_include_tool_search_bm25_tool_call_and_result_in_content() {} - - #[tokio::test] - async fn tool_search_results_follow_their_call_ids_when_both_variants_are_configured() { - let server = MockServer::start().await; - mock_json( - &server, - 200, - json!({ - "type": "message", - "id": "msg_tool_search", - "model": "claude-sonnet-4-5", - "content": [ - { - "type": "server_tool_use", - "id": "bm25_call", - "name": "tool_search_tool_bm25", - "input": { "query": "weather" }, - }, - { - "type": "tool_search_tool_result", - "tool_use_id": "bm25_call", - "content": { - "type": "tool_search_tool_search_result", - "tool_references": [{ - "type": "tool_reference", - "tool_name": "get_weather", - }], - }, - }, - { - "type": "server_tool_use", - "id": "regex_call", - "name": "tool_search_tool_regex", - "input": { "pattern": "forecast.*" }, - }, - { - "type": "tool_search_tool_result", - "tool_use_id": "regex_call", - "content": { - "type": "tool_search_tool_search_result", - "tool_references": [{ - "type": "tool_reference", - "tool_name": "get_forecast", - }], - }, - }, - ], - "stop_reason": "end_turn", - "usage": { "input_tokens": 10, "output_tokens": 20 }, - }), - ) - .await; - let model = make_model_with_id(&server, "claude-sonnet-4-5"); - let tools = vec![ - provider_tool( - "anthropic.tool_search_regex_20251119", - "regexSearch", - json!({}), - ), - provider_tool( - "anthropic.tool_search_bm25_20251119", - "semanticSearch", - json!({}), - ), - ]; - - let result = generate_text( - &model, - "Find weather tools", - GenerateTextOptions { - tools: Some(tools), - ..GenerateTextOptions::default() - }, - ) - .await - .expect("both provider tools should validate at the Core boundary"); - - let result_names: HashMap<&str, &str> = result - .raw - .content - .iter() - .filter_map(|part| match part { - GenerateContent::ToolResult(ToolResult { - tool_call_id, - tool_name, - .. - }) => Some((tool_call_id.as_str(), tool_name.as_str())), - _ => None, - }) - .collect(); - assert_eq!(result_names.get("bm25_call"), Some(&"semanticSearch")); - assert_eq!(result_names.get("regex_call"), Some(&"regexSearch")); - - let call_names: HashMap<&str, &str> = result - .tool_calls - .iter() - .map(|call| (call.tool_call_id.as_str(), call.tool_name.as_str())) - .collect(); - assert_eq!(call_names.get("bm25_call"), Some(&"semanticSearch")); - assert_eq!(call_names.get("regex_call"), Some(&"regexSearch")); - assert!(result.tool_calls.iter().all(|call| call.invalid.is_none())); - } - - /// TS: "should correctly map tool_search_tool_result when result comes without - /// server_tool_use in same response" (L4140, bm25 deferred) - // TODO: requires GenerateContent variants for tool_search_tool_result - #[tokio::test] - async fn should_map_deferred_tool_search_result_bm25() { - // TS uses fixture 'anthropic-tool-search-deferred-bm25.2' - // Expects a tool-result with toolName: 'tool_search' - } - - /// TS: "should correctly map tool_search_tool_result when result comes without - /// server_tool_use in same response" (L4190, regex deferred) - // TODO: requires GenerateContent variants for tool_search_tool_result - #[tokio::test] - async fn should_map_deferred_tool_search_result_regex() { - // TS uses fixture 'anthropic-tool-search-deferred-regex.2' - // Expects a tool-result with toolName: 'tool_search' - } -} - -// ════════════════════════════════════════════════════════════════════════════�? -// advisor tool -// (anthropic-language-model.test.ts L4238-4409) -// ════════════════════════════════════════════════════════════════════════════�? - -mod advisor_tool { - use super::*; - - /// TS: "should send the advisor tool in the request body" (L4258) - // TODO: requires CallOptions.tools to support provider tools - #[tokio::test] - async fn should_send_advisor_tool_in_request_body() { - let server = MockServer::start().await; - // TS uses fixture 'anthropic-advisor-20260301.1' - mock_json(&server, 200, text_response("Advisor result")).await; - let model = make_model_with_id(&server, "claude-sonnet-4-6"); - - // TS: tools: [{ type: 'provider', id: 'anthropic.advisor_20260301', - // name: 'advisor', args: { model: 'claude-opus-4-7' } }] - let mut options = default_options(test_prompt()); - options.tools = Some(vec![provider_tool( - "anthropic.advisor_20260301", - "advisor", - json!({ "model": "claude-opus-4-7" }), - )]); - let _result = model.do_generate(&options).await.unwrap(); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["max_tokens"], 128000); - assert_eq!(body["model"], "claude-sonnet-4-6"); - assert_eq!(body["tools"].as_array().unwrap().len(), 1); - assert_eq!( - body["tools"][0], - json!({ - "model": "claude-opus-4-7", - "name": "advisor", - "type": "advisor_20260301", - }) - ); - } - - /// TS: "should parse advisor calls and results as provider-executed tool parts" (L4285) - // TODO: requires GenerateContent variants for server_tool_use / advisor_tool_result - #[tokio::test] - async fn should_parse_advisor_calls_and_results() { - // TS uses fixture 'anthropic-advisor-20260301.1' - // Expects tool-call (providerExecuted: true) + tool-result (advisor_result) - } - - /// TS: "should expose advisor usage iterations in provider metadata" (L4313) - // TODO: requires providerMetadata on GenerateResult - #[tokio::test] - #[ignore = "requires providerMetadata on GenerateResult for advisor iterations"] - async fn should_expose_advisor_usage_iterations() { - // TS uses fixture 'anthropic-advisor-20260301.1' - // Expects result.providerMetadata.anthropic.iterations array - } - - /// TS: "should emit a tool-call for the advisor server_tool_use so it - /// round-trips on follow-up turns" (L4338) - // TODO: requires GenerateContent variants for server_tool_use / advisor_tool_result - #[tokio::test] - async fn should_emit_tool_call_for_advisor_server_tool_use() { - let server = MockServer::start().await; - mock_json( - &server, - 200, - json!({ - "type": "message", - "id": "msg_advisor", - "model": "claude-sonnet-4-6", - "role": "assistant", - "content": [ - { - "type": "server_tool_use", - "id": "srvtoolu_advisor_1", - "name": "advisor", - "input": {}, - }, - { - "type": "advisor_tool_result", - "tool_use_id": "srvtoolu_advisor_1", - "content": { - "type": "advisor_result", - "text": "Outline the design first; pick Submit semantics upfront.", - }, - }, - { - "type": "text", - "text": "Here is the design outline...", - }, - ], - "stop_reason": "end_turn", - "usage": { "input_tokens": 10, "output_tokens": 20 }, - }), - ) - .await; - let model = make_model_with_id(&server, "claude-sonnet-4-6"); - - // TS: tools: [{ type: 'provider', id: 'anthropic.advisor_20260301', ... }] - let _options = default_options(test_prompt()); - // TODO: options.tools = Some(vec![provider_tool(...)]); - let _result = model.do_generate(&_options).await.unwrap(); - - // TS expects content: - // 1. tool-call (input: '{}', providerExecuted: true, toolCallId: 'srvtoolu_advisor_1') - // 2. tool-result (advisor_result text) - // 3. text - // TODO: assert once GenerateContent has the needed variants. - } -} - -// ════════════════════════════════════════════════════════════════════════════�? -// mcp servers -// (anthropic-language-model.test.ts L4410-4485) -// ════════════════════════════════════════════════════════════════════════════�? - -mod mcp_servers { - use super::*; - - /// TS: "should send request body with include and tool" (L4411) - /// - /// This test passes `providerOptions.anthropic.mcpServers` through - /// `CallOptions.provider_options`, so it compiles. It will fail on - /// assertions until `build_request_body` reads `mcpServers`. - #[tokio::test] - async fn should_send_request_body_with_mcp_servers() { - let server = MockServer::start().await; - // TS uses fixture 'anthropic-mcp.1'; we use a minimal text response. - mock_json(&server, 200, text_response("MCP response")).await; - let model = make_model(&server); - - let mut options = default_options(test_prompt()); - options.provider_options = anthropic_opts(json!({ - "mcpServers": [ - { - "type": "url", - "name": "echo", - "url": "https://echo.mcp.inevitable.fyi/mcp", - }, - ], - })); - - let _result = model.do_generate(&options).await.unwrap(); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - // TS expects mcp_servers in the request body. - assert_eq!( - body["mcp_servers"], - json!([ - { - "name": "echo", - "type": "url", - "url": "https://echo.mcp.inevitable.fyi/mcp", - }, - ]) - ); - assert_eq!(body["max_tokens"], 4096); - assert_eq!(body["model"], "claude-3-haiku-20240307"); - - // TS expects anthropic-beta: "mcp-client-2025-04-04" - let headers = get_request_headers(&server).await; - let beta = headers - .iter() - .find(|(k, _)| k == "anthropic-beta") - .map(|(_, v)| v.as_str()); - assert_eq!(beta, Some("mcp-client-2025-04-04")); - } - - /// TS: "should include mcp tool call and result in content" (L4464) - #[tokio::test] - #[ignore = "snapshot test �?requires fixture + response parsing for MCP tools"] - async fn should_include_mcp_tool_call_and_result_in_content() {} -} - -// ════════════════════════════════════════════════════════════════════════════�? -// code execution file uploads -// (anthropic-language-model.test.ts L4486-4664) -// ════════════════════════════════════════════════════════════════════════════�? - -mod code_execution_file_uploads { - use super::*; - - /// TS: "should send container upload content with code execution tool and - /// parse results" (L4487) - // TODO: requires CallOptions.tools to support provider tools + container_upload part type - #[tokio::test] - async fn should_send_container_upload_with_code_execution_tool() { - let server = MockServer::start().await; - // TS uses fixture 'anthropic-code-execution-file-upload.1' - mock_json(&server, 200, text_response("Code execution result")).await; - let model = make_model(&server); - - // TS: prompt includes a file part with providerOptions.anthropic.containerUpload: true - // TS: tools: [{ type: 'provider', id: 'anthropic.code_execution_20250825', ... }] - let mut options = default_options(test_prompt()); - options.tools = Some(vec![provider_tool( - "anthropic.code_execution_20250825", - "code_execution", - json!({}), - )]); - let _result = model.do_generate(&options).await.unwrap(); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!( - body["tools"][0], - json!({ - "name": "code_execution", - "type": "code_execution_20250825", - }) - ); - // TS expects a container_upload part in the message content. - // TS expects headers: "files-api-2025-04-14,code-execution-2025-08-25" - } - - /// TS: "should send container id for a follow-up code execution turn" (L4606) - // TODO: requires CallOptions.tools to support provider tools + container in providerOptions - #[tokio::test] - async fn should_send_container_id_for_follow_up() { - let server = MockServer::start().await; - mock_json(&server, 200, text_response("hi")).await; - let model = make_model(&server); - - let mut options = default_options(test_prompt()); - options.provider_options = anthropic_opts(json!({ - "container": { "id": "container_12345" }, - })); - options.tools = Some(vec![provider_tool( - "anthropic.code_execution_20250825", - "code_execution", - json!({}), - )]); - let _result = model.do_generate(&options).await.unwrap(); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - // TS expects container: "container_12345" in the request body. - assert_eq!(body["container"], "container_12345"); - assert_eq!(body["max_tokens"], 4096); - assert_eq!( - body["tools"][0], - json!({ - "name": "code_execution", - "type": "code_execution_20250825", - }) - ); - } -} - -// ════════════════════════════════════════════════════════════════════════════�? -// agent skills -// (anthropic-language-model.test.ts L4665-4980) -// ════════════════════════════════════════════════════════════════════════════�? - -mod agent_skills { - use super::*; - - /// TS: "should send request body with skills in container" (L4666) - // TODO: requires CallOptions.tools to support provider tools + container.skills - #[tokio::test] - async fn should_send_request_body_with_skills_in_container() { - let server = MockServer::start().await; - // TS uses fixture 'anthropic-code-execution-20250825.pptx-skill' - mock_json(&server, 200, text_response("Skill result")).await; - let model = make_model(&server); - - let mut options = default_options(test_prompt()); - options.provider_options = anthropic_opts(json!({ - "container": { - "id": "test-container-id", - "skills": [ - { - "type": "anthropic", - "skillId": "pptx", - "version": "latest", - }, - { - "type": "custom", - "providerReference": { - "anthropic": "skill_01Xud7kLMsjLfc7Aa6RvigZf", - }, - "version": "1.0", - }, - ], - }, - })); - // TODO: options.tools = Some(vec![provider_tool code_execution_20250825]); - let _result = model.do_generate(&options).await.unwrap(); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!( - body["container"], - json!({ - "id": "test-container-id", - "skills": [ - { - "skill_id": "pptx", - "type": "anthropic", - "version": "latest", - }, - { - "skill_id": "skill_01Xud7kLMsjLfc7Aa6RvigZf", - "type": "custom", - "version": "1.0", - }, - ], - }) - ); - assert_eq!(body["max_tokens"], 4096); - // TS expects result.warnings to be empty. - } - - /// TS: "should add a warning when the code execution tool is not present" (L4746) - // TODO: requires container.skills processing in build_request_body - #[tokio::test] - #[ignore = "requires container.skills processing in build_request_body"] - async fn should_add_warning_when_code_execution_tool_not_present() { - let server = MockServer::start().await; - mock_json(&server, 200, text_response("hi")).await; - let model = make_model(&server); - - let mut options = default_options(test_prompt()); - options.provider_options = anthropic_opts(json!({ - "container": { - "id": "test-container-id", - "skills": [ - { - "type": "anthropic", - "skillId": "pptx", - "version": "latest", - }, - { - "type": "custom", - "providerReference": { - "anthropic": "skill_01Xud7kLMsjLfc7Aa6RvigZf", - }, - "version": "1.0", - }, - ], - }, - })); - // No tools �?code execution tool is not present. - let result = model.do_generate(&options).await.unwrap(); - - // TS expects warnings: [{ message: "code execution tool is required when using skills", type: "other" }] - assert!(result.warnings.iter().any( - |w| matches!(w, aimux_core::types::Warning::Other { message, .. } - if message.contains("code execution tool is required when using skills")) - )); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - // Container should still be in the body even without the code execution tool. - assert!(body["container"]["skills"].is_array()); - } - - /// TS: "should include beta headers when skills are configured" (L4819) - // TODO: requires CallOptions.tools to support provider tools + skills beta headers - #[tokio::test] - async fn should_include_beta_headers_when_skills_configured() { - let server = MockServer::start().await; - mock_json(&server, 200, text_response("hi")).await; - let model = make_model(&server); - - let mut options = default_options(test_prompt()); - options.provider_options = anthropic_opts(json!({ - "container": { - "skills": [ - { - "type": "anthropic", - "skillId": "pptx", - "version": "latest", - }, - ], - }, - })); - // TODO: options.tools = Some(vec![provider_tool code_execution_20250825]); - let _result = model.do_generate(&options).await.unwrap(); - - let headers = get_request_headers(&server).await; - let beta = headers - .iter() - .find(|(k, _)| k == "anthropic-beta") - .map(|(_, v)| v.as_str()); - // TS expects: "code-execution-2025-08-25,skills-2025-10-02,files-api-2025-04-14" - assert!(beta.is_some_and(|b| b.contains("skills-2025-10-02") - && b.contains("code-execution-2025-08-25") - && b.contains("files-api-2025-04-14"))); - } - - /// TS: "should expose container information as provider metadata" (L4859) - #[tokio::test] - #[ignore = "snapshot test �?requires providerMetadata for container info"] - async fn should_expose_container_info_as_provider_metadata() {} - - /// TS: "should resolve custom skill provider references at the Anthropic boundary" (L4892) - // TODO: requires CallOptions.tools to support provider tools + skill resolution - #[tokio::test] - async fn should_resolve_custom_skill_provider_references() { - let server = MockServer::start().await; - mock_json(&server, 200, text_response("hi")).await; - let model = make_model(&server); - - let mut options = default_options(test_prompt()); - options.provider_options = anthropic_opts(json!({ - "container": { - "skills": [ - { - "type": "custom", - "providerReference": { - "anthropic": "skill_01Xud7kLMsjLfc7Aa6RvigZf", - }, - }, - ], - }, - })); - // TODO: options.tools = Some(vec![provider_tool code_execution_20250825]); - let _result = model.do_generate(&options).await.unwrap(); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!( - body["container"], - json!({ - "skills": [ - { - "skill_id": "skill_01Xud7kLMsjLfc7Aa6RvigZf", - "type": "custom", - }, - ], - }) - ); - } - - /// TS: "should throw when a custom skill provider reference does not include anthropic" (L4956) - // TODO: requires skill provider reference validation - #[tokio::test] - #[ignore = "requires skill provider reference validation (NoSuchProviderReferenceError)"] - async fn should_throw_when_custom_skill_reference_missing_anthropic() { - let server = MockServer::start().await; - mock_json(&server, 200, text_response("hi")).await; - let model = make_model(&server); - - let mut options = default_options(test_prompt()); - options.provider_options = anthropic_opts(json!({ - "container": { - "skills": [ - { - "type": "custom", - "providerReference": { - "openai": "skill_abc", - }, - }, - ], - }, - })); - - // TS expects this to throw NoSuchProviderReferenceError. - let result = model.do_generate(&options).await; - assert!(result.is_err()); - } -} - -// ════════════════════════════════════════════════════════════════════════════�? -// memory 20250818 -// (anthropic-language-model.test.ts L4981-5049) -// ════════════════════════════════════════════════════════════════════════════�? - -mod memory_20250818 { - use super::*; - - /// TS: "should send request body with include and tool" (L4982) - // TODO: requires CallOptions.tools to support provider tools - #[tokio::test] - async fn should_send_request_body_with_memory_tool() { - let server = MockServer::start().await; - // TS uses fixture 'anthropic-memory-20250818.1' - mock_json(&server, 200, text_response("Memory response")).await; - let model = make_model(&server); - - // TS: tools: [{ type: 'provider', id: 'anthropic.memory_20250818', - // name: 'memory', args: {} }] - let mut options = default_options(test_prompt()); - options.tools = Some(vec![provider_tool( - "anthropic.memory_20250818", - "memory", - json!({}), - )]); - let _result = model.do_generate(&options).await.unwrap(); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["max_tokens"], 4096); - assert_eq!(body["model"], "claude-3-haiku-20240307"); - assert_eq!( - body["tools"][0], - json!({ - "name": "memory", - "type": "memory_20250818", - }) - ); - - let headers = get_request_headers(&server).await; - let beta = headers - .iter() - .find(|(k, _)| k == "anthropic-beta") - .map(|(_, v)| v.as_str()); - assert_eq!(beta, Some("context-management-2025-06-27")); - } - - /// TS: "should include memory tool call and result in content" (L5031) - #[tokio::test] - #[ignore = "snapshot test �?requires fixture + response parsing for memory tools"] - async fn should_include_memory_tool_call_and_result_in_content() {} -} - -// ════════════════════════════════════════════════════════════════════════════�? -// code execution 20250825 -// (anthropic-language-model.test.ts L5050-5116) -// ════════════════════════════════════════════════════════════════════════════�? - -mod code_execution_20250825 { - use super::*; - - /// TS: "should send request body with include and tool" (L5051) - // TODO: requires CallOptions.tools to support provider tools - #[tokio::test] - async fn should_send_request_body_with_code_execution_20250825() { - let server = MockServer::start().await; - // TS uses fixture 'anthropic-code-execution-20250825.1' - mock_json(&server, 200, text_response("Code execution")).await; - let model = make_model(&server); - - // TS: tools: [{ type: 'provider', id: 'anthropic.code_execution_20250825', - // name: 'code_execution', args: {} }] - let mut options = default_options(test_prompt()); - options.tools = Some(vec![provider_tool( - "anthropic.code_execution_20250825", - "code_execution", - json!({}), - )]); - let _result = model.do_generate(&options).await.unwrap(); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["max_tokens"], 4096); - assert_eq!(body["model"], "claude-3-haiku-20240307"); - assert_eq!( - body["tools"][0], - json!({ - "name": "code_execution", - "type": "code_execution_20250825", - }) - ); - - let headers = get_request_headers(&server).await; - let beta = headers - .iter() - .find(|(k, _)| k == "anthropic-beta") - .map(|(_, v)| v.as_str()); - assert_eq!(beta, Some("code-execution-2025-08-25")); - } - - /// TS: "should include code execution tool call and result in content" (L5100) - #[tokio::test] - #[ignore = "snapshot test �?requires fixture + response parsing for code execution tools"] - async fn should_include_code_execution_tool_call_and_result_in_content() {} - - /// TS: "should expose container information as provider metadata" (L5118) - #[tokio::test] - #[ignore = "snapshot test �?requires providerMetadata for container info"] - async fn should_expose_container_info_as_provider_metadata() {} - - /// TS: "should include file id list in code execution tool generate call result" (L5136) - #[tokio::test] - #[ignore = "snapshot test �?requires fixture + providerMetadata for file ids"] - async fn should_include_file_id_list_in_code_execution_result() {} -} - -// ════════════════════════════════════════════════════════════════════════════�? -// code execution 20260120 -// (anthropic-language-model.test.ts L5155-5221) -// ════════════════════════════════════════════════════════════════════════════�? - -mod code_execution_20260120 { - use super::*; - - /// TS: "should send request body with tool and no beta header" (L5156) - // TODO: requires CallOptions.tools to support provider tools - #[tokio::test] - async fn should_send_request_body_with_code_execution_20260120_no_beta() { - let server = MockServer::start().await; - // TS uses fixture 'anthropic-code-execution-20250825.1' (reused) - mock_json(&server, 200, text_response("Code execution")).await; - let model = make_model(&server); - - // TS: tools: [{ type: 'provider', id: 'anthropic.code_execution_20260120', - // name: 'code_execution', args: {} }] - let mut options = default_options(test_prompt()); - options.tools = Some(vec![provider_tool( - "anthropic.code_execution_20260120", - "code_execution", - json!({}), - )]); - let _result = model.do_generate(&options).await.unwrap(); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["max_tokens"], 4096); - assert_eq!(body["model"], "claude-3-haiku-20240307"); - assert_eq!( - body["tools"][0], - json!({ - "name": "code_execution", - "type": "code_execution_20260120", - }) - ); - - // TS expects NO anthropic-beta header (20260120 needs no beta). - let headers = get_request_headers(&server).await; - let has_beta = headers.iter().any(|(k, _)| k == "anthropic-beta"); - assert!(!has_beta); - } - - /// TS: "should include code execution tool call and result in content" (L5204) - #[tokio::test] - #[ignore = "snapshot test �?requires fixture + response parsing for code execution tools"] - async fn should_include_code_execution_20260120_tool_call_and_result_in_content() {} -} - -// ════════════════════════════════════════════════════════════════════════════�? -// code execution 20250522 -// (anthropic-language-model.test.ts L5223-5507) -// ════════════════════════════════════════════════════════════════════════════�? - -mod code_execution_20250522 { - use super::*; - - /// The TS TEST_PROMPT for this block: "Write a Python function to calculate factorial". - fn factorial_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text( - "Write a Python function to calculate factorial", - )], - ..Default::default() - }] - } - - /// TS: "should enable server-side code execution when using - /// anthropic.tools.codeExecution_20250522" (L5236) - // TODO: requires CallOptions.tools to support provider tools - #[tokio::test] - async fn should_enable_server_side_code_execution_20250522() { - let server = MockServer::start().await; - mock_json( - &server, - 200, - json!({ - "type": "message", - "id": "msg_test", - "content": [ - { - "type": "text", - "text": "Here is a Python function to calculate factorial", - }, - ], - "stop_reason": "end_turn", - "usage": { "input_tokens": 10, "output_tokens": 20 }, - }), - ) - .await; - let model = make_model(&server); - - // TS: tools: [{ type: 'provider', id: 'anthropic.code_execution_20250522', - // name: 'code_execution', args: {} }] - let mut options = default_options(factorial_prompt()); - options.tools = Some(vec![provider_tool( - "anthropic.code_execution_20250522", - "code_execution", - json!({}), - )]); - let _result = model.do_generate(&options).await.unwrap(); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["max_tokens"], 4096); - assert_eq!(body["model"], "claude-3-haiku-20240307"); - assert_eq!( - body["tools"][0], - json!({ - "name": "code_execution", - "type": "code_execution_20250522", - }) - ); - - let headers = get_request_headers(&server).await; - let beta = headers - .iter() - .find(|(k, _)| k == "anthropic-beta") - .map(|(_, v)| v.as_str()); - assert_eq!(beta, Some("code-execution-2025-05-22")); - } - - /// TS: "should handle server-side code execution results" (L5299) - // TODO: requires GenerateContent variants for server_tool_use / code_execution_tool_result - #[tokio::test] - async fn should_handle_server_side_code_execution_results() { - let server = MockServer::start().await; - mock_json( - &server, - 200, - json!({ - "type": "message", - "id": "msg_test", - "content": [ - { - "type": "server_tool_use", - "id": "tool_1", - "name": "code_execution", - "input": { "code": "print(\"Hello, World!\")" }, - }, - { - "type": "code_execution_tool_result", - "tool_use_id": "tool_1", - "content": { - "type": "code_execution_result", - "stdout": "Hello, World!\n", - "stderr": "", - "return_code": 0, - }, - }, - { - "type": "text", - "text": "The code executed successfully with output: Hello, World!", - }, - ], - "stop_reason": "end_turn", - "usage": { - "input_tokens": 15, - "output_tokens": 25, - "server_tool_use": { "code_execution_requests": 1 }, - }, - }), - ) - .await; - let model = make_model(&server); - - let _options = default_options(factorial_prompt()); - // TODO: options.tools = Some(vec![provider_tool code_execution_20250522]); - let _result = model.do_generate(&_options).await.unwrap(); - - // TS expects content: - // 1. tool-call (input: '{"type":"programmatic-tool-call","code":"print(\"Hello, World!\")"}', - // providerExecuted: true, toolCallId: 'tool_1') - // 2. tool-result (code_execution_result: stdout, stderr, return_code) - // 3. text - // TODO: assert once GenerateContent has the needed variants. - } - - /// TS: "should handle server-side code execution errors" (L5378) - // TODO: requires GenerateContent variants for code_execution_tool_result_error - #[tokio::test] - async fn should_handle_server_side_code_execution_errors() { - let server = MockServer::start().await; - mock_json( - &server, - 200, - json!({ - "type": "message", - "id": "msg_test", - "content": [ - { - "type": "code_execution_tool_result", - "tool_use_id": "tool_1", - "content": { - "type": "code_execution_tool_result_error", - "error_code": "unavailable", - }, - }, - { - "type": "text", - "text": "The code execution service is currently unavailable.", - }, - ], - "stop_reason": "end_turn", - "usage": { "input_tokens": 10, "output_tokens": 20 }, - }), - ) - .await; - let model = make_model(&server); - - let _options = default_options(factorial_prompt()); - // TODO: options.tools = Some(vec![provider_tool code_execution_20250522]); - let _result = model.do_generate(&_options).await.unwrap(); - - // TS expects content: - // 1. tool-result (isError: true, errorCode: 'unavailable') - // 2. text - // TODO: assert once GenerateContent has the needed variants. - } - - /// TS: "should work alongside regular client-side tools" (L5435) - // TODO: requires CallOptions.tools to support mixed function + provider tools - #[tokio::test] - async fn should_work_alongside_regular_client_side_tools() { - let server = MockServer::start().await; - mock_json( - &server, - 200, - json!({ - "type": "message", - "id": "msg_test", - "content": [{ "type": "text", "text": "I can execute code and calculate." }], - "stop_reason": "end_turn", - "usage": { "input_tokens": 10, "output_tokens": 20 }, - }), - ) - .await; - let model = make_model(&server); - - // TS: tools: [ - // { type: 'function', name: 'calculator', description: 'Calculate math expressions', - // inputSchema: { type: 'object', properties: {} } }, - // { type: 'provider', id: 'anthropic.code_execution_20250522', - // name: 'code_execution', args: {} }, - // ] - let mut options = default_options(factorial_prompt()); - options.tools = Some(vec![ - Tool::Function( - FunctionTool::new( - "calculator".to_string(), - json!({ "type": "object", "properties": {} }), - ) - .with_description("Calculate math expressions".to_string()), - ), - provider_tool( - "anthropic.code_execution_20250522", - "code_execution", - json!({}), - ), - ]); - let _result = model.do_generate(&options).await.unwrap(); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["tools"].as_array().unwrap().len(), 2); - // tools[0] is the function tool 'calculator' - assert_eq!(body["tools"][0]["name"], "calculator"); - assert_eq!( - body["tools"][0]["description"], - "Calculate math expressions" - ); - // tools[1] is the code_execution provider tool - assert_eq!(body["tools"][1]["type"], "code_execution_20250522"); - - let headers = get_request_headers(&server).await; - let beta = headers - .iter() - .find(|(k, _)| k == "anthropic-beta") - .map(|(_, v)| v.as_str()); - assert_eq!(beta, Some("code-execution-2025-05-22")); - } -} diff --git a/aimux-providers/tests/anthropic_remaining_test.rs b/aimux-providers/tests/anthropic_remaining_test.rs deleted file mode 100644 index ed6c7545b..000000000 --- a/aimux-providers/tests/anthropic_remaining_test.rs +++ /dev/null @@ -1,675 +0,0 @@ -//! Remaining Anthropic provider tests — ported from the TS SDK suite. -//! -//! Covers the TS tests not already exercised by the existing six Anthropic -//! test files (`anthropic_model_test.rs`, `anthropic_convert_test.rs`, -//! `anthropic_convert_full_test.rs`, `anthropic_prepare_tools_test.rs`, -//! `anthropic_provider_tools_test.rs`, `anthropic_cache_control_test.rs`, -//! `anthropic_aws_model_test.rs`): -//! -//! - `anthropic-provider.test.ts` — provider configuration (baseURL, auth, -//! custom provider name, `supportedUrls`). -//! - `anthropic-unknown-model-max-output-tokens.test.ts` — unknown-model -//! `max_tokens` defaulting + compatibility warning. -//! - `sanitize-json-schema.test.ts` — JSON Schema sanitization (the -//! `sanitize_json_schema.rs` implementation had no dedicated tests). -//! - `anthropic-language-model.test.ts` → `mid-conversation tool changes` -//! describe block. -//! -//! HTTP is mocked with `wiremock` (a real loopback HTTP server). Env-var -//! tests are `#[serial]` and bracketed with `unsafe` `set_var`/`remove_var` -//! per the workspace convention. -//! -//! Tests for behaviour the Rust implementation does not yet expose -//! (`supportedUrls`, `toolChanges`, empty-`baseURL` rejection) are written -//! against the intended contract and marked `#[ignore]` with a -//! `// TODO: implementation gap` note, so they document the gap without -//! breaking the suite. - -use serde_json::{Value, json}; -use serial_test::serial; -use wiremock::matchers::{method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -use aimux_core::content::ContentPart; -use aimux_core::error::AiMuxError; -use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; -use aimux_core::options::CallOptions; -use aimux_core::types::Warning; - -use aimux_providers::anthropic::AnthropicConfig; -use aimux_providers::anthropic::model::AnthropicModel; -use aimux_providers::anthropic::sanitize_json_schema::sanitize_json_schema; - -// ───────────────────────────────────────────────────────────────────────────── -// Shared helpers -// ───────────────────────────────────────────────────────────────────────────── - -/// The TS `TEST_PROMPT`: a single user text message "Hello". -fn test_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] -} - -/// Build `CallOptions` with everything unset except `prompt`. -fn default_options(prompt: LanguageModelPrompt) -> CallOptions { - CallOptions::new(prompt) -} - -/// Build an `AnthropicModel` backed by `config` (model id `claude-3-haiku-20240307`). -fn make_model_with_config(config: AnthropicConfig) -> AnthropicModel { - AnthropicModel::new("claude-3-haiku-20240307".to_string(), config) -} - -/// A minimal Anthropic text response body (mirrors the TS `anthropic-text` fixture). -fn text_response(text: &str) -> Value { - json!({ - "id": "msg_123", - "type": "message", - "role": "assistant", - "content": [{ "type": "text", "text": text }], - "model": "claude-3-haiku-20240307", - "stop_reason": "end_turn", - "stop_sequence": null, - "usage": { "input_tokens": 1, "output_tokens": 1 }, - }) -} - -/// Mount a JSON 200 response on `POST /v1/messages`. -async fn mock_messages_ok(server: &MockServer, body: Value) { - Mock::given(method("POST")) - .and(path("/v1/messages")) - .respond_with(ResponseTemplate::new(200).set_body_json(body)) - .mount(server) - .await; -} - -/// Remove the Anthropic env vars consulted by `AnthropicConfig::from_env` -/// (test isolation for `#[serial]` env tests). -fn clear_anthropic_env() { - unsafe { - std::env::remove_var("ANTHROPIC_BASE_URL"); - std::env::remove_var("ANTHROPIC_API_KEY"); - } -} - -/// Set the API key (always required by `from_env`) plus an optional base URL. -fn set_anthropic_env(api_key: &str, base_url: Option<&str>) { - unsafe { - std::env::set_var("ANTHROPIC_API_KEY", api_key); - match base_url { - Some(url) => std::env::set_var("ANTHROPIC_BASE_URL", url), - None => std::env::remove_var("ANTHROPIC_BASE_URL"), - } - } -} - -// ═════════════════════════════════════════════════════════════════════════════ -// baseURL configuration (TS: anthropic-provider.test.ts → 'baseURL configuration') -// ═════════════════════════════════════════════════════════════════════════════ - -/// TS: "uses the default Anthropic base URL when not provided". -/// -/// The TS test mocks `fetch` and asserts the request URL is -/// `https://api.anthropic.com/v1/messages`. The Rust provider builds the -/// endpoint as `{base_url}/v1/messages`, so the equivalent observable -/// behaviour is: the default config's `base_url` is the bare Anthropic URL, -/// and a request against a stubbed server lands on `/v1/messages`. -#[tokio::test] -async fn default_base_url_when_not_provided() { - // Config-level: default base URL is the bare Anthropic API URL. - let config = AnthropicConfig::new("test-api-key"); - assert_eq!(config.base_url, "https://api.anthropic.com"); - - // HTTP-level: the endpoint path is `/v1/messages`. - let server = MockServer::start().await; - mock_messages_ok(&server, text_response("Hi")).await; - let model = - make_model_with_config(AnthropicConfig::new("test-api-key").with_base_url(server.uri())); - let _ = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - let requests = server.received_requests().await.expect("requests recorded"); - assert_eq!(requests.len(), 1); - assert_eq!(requests[0].url.path(), "/v1/messages"); -} - -/// TS: "uses ANTHROPIC_BASE_URL when set". -/// -/// The TS test mocks `fetch` and asserts the request URL is -/// `https://proxy.anthropic.example/v1/messages`. The Rust provider builds the -/// endpoint as `{base_url}/v1/messages`, so the observable behaviour is that -/// `from_env` picks up `ANTHROPIC_BASE_URL` and normalizes it into `base_url`. -/// (The HTTP `/v1/messages` path round-trip is covered by -/// `default_base_url_when_not_provided`.) -#[tokio::test] -#[serial] -async fn uses_anthropic_base_url_env_when_set() { - clear_anthropic_env(); - set_anthropic_env("test-api-key", Some("https://proxy.anthropic.example/v1/")); - - let config = AnthropicConfig::from_env().expect("from_env"); - // Trailing slash + `/v1` segment normalized away; the endpoint becomes - // `https://proxy.anthropic.example/v1/messages`. - assert_eq!(config.base_url, "https://proxy.anthropic.example"); - - clear_anthropic_env(); -} - -/// TS: "normalizes a bare Anthropic API URL from ANTHROPIC_BASE_URL". -#[tokio::test] -#[serial] -async fn normalizes_bare_anthropic_url_from_env() { - clear_anthropic_env(); - set_anthropic_env("test-api-key", Some("https://api.anthropic.com/")); - let config = AnthropicConfig::from_env().expect("from_env"); - // Trailing slash stripped; endpoint becomes `https://api.anthropic.com/v1/messages`. - assert_eq!(config.base_url, "https://api.anthropic.com"); - clear_anthropic_env(); -} - -/// TS: "normalizes a bare Anthropic API URL from the baseURL option". -#[tokio::test] -async fn normalizes_bare_anthropic_url_from_base_url_option() { - let config = AnthropicConfig::new("test-api-key").with_base_url("https://api.anthropic.com/"); - assert_eq!(config.base_url, "https://api.anthropic.com"); -} - -/// TS: "prefers the baseURL option over ANTHROPIC_BASE_URL". -#[tokio::test] -#[serial] -async fn prefers_base_url_option_over_env() { - clear_anthropic_env(); - set_anthropic_env("test-api-key", Some("https://env.anthropic.example/v1")); - let config = AnthropicConfig::from_env() - .expect("from_env") - .with_base_url("https://option.anthropic.example/v1/"); - // The explicit option wins; its trailing slash and `/v1` are normalized away. - assert_eq!(config.base_url, "https://option.anthropic.example"); - clear_anthropic_env(); -} - -/// TS: "rejects an empty baseURL option during provider creation". -// TODO: implementation gap — the Rust `normalize_base_url` returns the default -// Anthropic URL for an empty input instead of rejecting it with -// `InvalidArgumentError` (`argument: "baseURL"`, message: "baseURL must be a -// non-empty string."). Re-enable once `with_base_url` / the builder validate -// emptiness. -#[tokio::test] -#[ignore] -async fn rejects_empty_base_url_option() { - let result = AnthropicConfig::builder() - .api_key("test-api-key") - .base_url("") - .build(); - match result { - Err(AiMuxError::InvalidArgument(msg)) => { - assert_eq!(msg, "baseURL must be a non-empty string."); - } - other => panic!("expected InvalidArgument for empty baseURL, got {other:?}"), - } -} - -// ═════════════════════════════════════════════════════════════════════════════ -// Authentication (TS: anthropic-provider.test.ts → 'authentication') -// ═════════════════════════════════════════════════════════════════════════════ - -/// TS: "sends Authorization Bearer header when authToken is provided". -#[tokio::test] -async fn sends_authorization_bearer_when_auth_token_provided() { - let server = MockServer::start().await; - mock_messages_ok(&server, text_response("Hi")).await; - - let config = AnthropicConfig::builder() - .auth_token("test-auth-token") - .base_url(server.uri()) - .build() - .expect("builder build"); - let model = make_model_with_config(config); - - let _ = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - let requests = server.received_requests().await.expect("requests recorded"); - assert_eq!(requests.len(), 1); - assert_eq!( - requests[0] - .headers - .get("authorization") - .and_then(|v| v.to_str().ok()), - Some("Bearer test-auth-token"), - "Authorization Bearer header missing or wrong" - ); - // When an auth token is set, the `x-api-key` header must NOT be sent. - assert!( - requests[0].headers.get("x-api-key").is_none(), - "x-api-key should not be sent when authToken is provided" - ); -} - -/// TS: "throws error when both apiKey and authToken options are provided". -#[test] -fn throws_when_both_api_key_and_auth_token_provided() { - let result = AnthropicConfig::builder() - .api_key("test-api-key") - .auth_token("test-auth-token") - .build(); - match result { - Err(AiMuxError::InvalidArgument(msg)) => { - assert_eq!( - msg, - "Both apiKey and authToken were provided. \ - Please use only one authentication method." - ); - } - other => { - panic!("expected InvalidArgument for conflicting apiKey + authToken, got {other:?}") - } - } -} - -// ═════════════════════════════════════════════════════════════════════════════ -// Custom provider name (TS: anthropic-provider.test.ts → 'custom provider name') -// ═════════════════════════════════════════════════════════════════════════════ - -/// TS: "should use custom provider name when specified". -#[test] -fn uses_custom_provider_name_when_specified() { - let config = AnthropicConfig::new("test-api-key").with_name("my-claude-proxy"); - let model = make_model_with_config(config); - assert_eq!(model.provider(), "my-claude-proxy"); -} - -/// TS: "should default to anthropic.messages when name not specified". -#[test] -fn defaults_to_anthropic_messages_when_not_specified() { - let config = AnthropicConfig::new("test-api-key"); - let model = make_model_with_config(config); - assert_eq!(model.provider(), "anthropic.messages"); -} - -// ═════════════════════════════════════════════════════════════════════════════ -// supportedUrls (TS: anthropic-provider.test.ts → 'supportedUrls') -// ═════════════════════════════════════════════════════════════════════════════ - -/// TS: "should support image/* URLs". -// TODO: implementation gap — the Rust `LanguageModel` trait has no -// `supported_urls` surface, so there is nothing to assert against. Re-enable -// once `supported_urls` is added to the trait and the Anthropic model exposes -// its image/PDF URL allow-list. -#[tokio::test] -#[ignore] -async fn supports_image_urls() { - let config = AnthropicConfig::new("test-api-key"); - let model = make_model_with_config(config); - // Intended: `model.supported_urls()["image/*"]` matches - // `https://example.com/image.png`. - let _ = model.provider(); -} - -/// TS: "should support application/pdf URLs". -// TODO: implementation gap — see `supports_image_urls`. -#[tokio::test] -#[ignore] -async fn supports_pdf_urls() { - let config = AnthropicConfig::new("test-api-key"); - let model = make_model_with_config(config); - let _ = model.provider(); -} - -// ═════════════════════════════════════════════════════════════════════════════ -// Unknown-model max output tokens (TS: anthropic-unknown-model-max-output-tokens.test.ts) -// ═════════════════════════════════════════════════════════════════════════════ - -/// Build a model with the given model id pointing at the wiremock server. -fn make_model_with_id(server: &MockServer, model_id: &str) -> AnthropicModel { - AnthropicModel::new( - model_id.to_string(), - AnthropicConfig::new("test-api-key").with_base_url(server.uri()), - ) -} - -/// TS: "should warn when using the default max output token limit". -#[tokio::test] -async fn warns_when_using_default_max_output_token_limit() { - let server = MockServer::start().await; - mock_messages_ok(&server, text_response("Hello!")).await; - - let model = make_model_with_id(&server, "future-model"); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["model"], "future-model"); - assert_eq!(body["max_tokens"], 4096); - - assert_eq!(result.warnings.len(), 1); - match &result.warnings[0] { - Warning::Compatibility { feature, details } => { - assert_eq!(feature, "maxOutputTokens"); - assert_eq!( - details.as_deref(), - Some( - "The model \"future-model\" is unknown. The max output tokens have been \ - limited to 4096. Set maxOutputTokens explicitly to override this limit." - ) - ); - } - other => panic!("expected Compatibility warning, got {other:?}"), - } -} - -/// TS: "should not warn when max output tokens are provided". -#[tokio::test] -async fn does_not_warn_when_max_output_tokens_provided() { - let server = MockServer::start().await; - mock_messages_ok(&server, text_response("Hello!")).await; - - let model = make_model_with_id(&server, "future-model"); - let mut opts = default_options(test_prompt()); - opts.max_output_tokens = Some(123456); - let result = model - .do_generate(&opts) - .await - .expect("do_generate should succeed"); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["model"], "future-model"); - assert_eq!(body["max_tokens"], 123456); - assert!( - result.warnings.is_empty(), - "warnings: {:?}", - result.warnings - ); -} - -/// TS: "should use the current-generation default and warn for an unknown Claude model". -#[tokio::test] -async fn uses_current_gen_default_and_warns_for_unknown_claude_model() { - let server = MockServer::start().await; - mock_messages_ok(&server, text_response("Hello!")).await; - - let model = make_model_with_id(&server, "claude-future-9"); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["model"], "claude-future-9"); - assert_eq!(body["max_tokens"], 128000); - - assert_eq!(result.warnings.len(), 1); - match &result.warnings[0] { - Warning::Compatibility { feature, details } => { - assert_eq!(feature, "maxOutputTokens"); - assert_eq!( - details.as_deref(), - Some( - "The model \"claude-future-9\" is unknown. The max output tokens have been \ - limited to 128000. Set maxOutputTokens explicitly to override this limit." - ) - ); - } - other => panic!("expected Compatibility warning, got {other:?}"), - } -} - -// ═════════════════════════════════════════════════════════════════════════════ -// sanitize-json-schema (TS: sanitize-json-schema.test.ts) -// ═════════════════════════════════════════════════════════════════════════════ - -/// TS: "strips unsupported number constraints and adds readable descriptions". -#[test] -fn sanitize_strips_unsupported_number_constraints() { - let schema = json!({ - "type": "object", - "properties": { - "recurringIntervalMinutes": { - "type": "number", - "exclusiveMinimum": 0, - "minimum": 1, - "maximum": 60, - "exclusiveMaximum": 120 - } - }, - "required": ["recurringIntervalMinutes"], - "additionalProperties": false - }); - - let expected = json!({ - "additionalProperties": false, - "properties": { - "recurringIntervalMinutes": { - "description": - "minimum: 1; maximum: 60; exclusive minimum: 0; exclusive maximum: 120.", - "type": "number" - } - }, - "required": ["recurringIntervalMinutes"], - "type": "object" - }); - - assert_eq!(sanitize_json_schema(&schema), expected); -} - -/// TS: "strips unsupported string constraints and unsupported formats". -#[test] -fn sanitize_strips_unsupported_string_constraints_and_formats() { - let schema = json!({ - "type": "object", - "properties": { - "slug": { - "type": "string", - "description": "A URL slug", - "minLength": 1, - "maxLength": 20, - "pattern": "^[a-z0-9-]+$", - "format": "regex" - } - } - }); - - let expected = json!({ - "additionalProperties": false, - "properties": { - "slug": { - "description": - "A URL slug\nmin length: 1; max length: 20; pattern: ^[a-z0-9-]+$; format: regex.", - "type": "string" - } - }, - "type": "object" - }); - - assert_eq!(sanitize_json_schema(&schema), expected); -} - -/// TS: "recursively sanitizes arrays, definitions, and composition schemas". -#[test] -fn sanitize_recursively_handles_arrays_defs_and_composition() { - let schema = json!({ - "type": "object", - "$defs": { - "PositiveInteger": { "type": "integer", "minimum": 1 } - }, - "properties": { - "count": { "$ref": "#/$defs/PositiveInteger" }, - "tags": { - "type": "array", - "minItems": 2, - "maxItems": 4, - "uniqueItems": true, - "items": { - "anyOf": [ - { "type": "string", "minLength": 1 }, - { "type": "number", "maximum": 10 } - ] - } - } - } - }); - - let expected = json!({ - "$defs": { - "PositiveInteger": { - "description": "minimum: 1.", - "type": "integer" - } - }, - "additionalProperties": false, - "properties": { - "count": { "$ref": "#/$defs/PositiveInteger" }, - "tags": { - "description": "min items: 2; max items: 4; unique items: true.", - "items": { - "anyOf": [ - { "description": "min length: 1.", "type": "string" }, - { "description": "maximum: 10.", "type": "number" } - ] - }, - "type": "array" - } - }, - "type": "object" - }); - - assert_eq!(sanitize_json_schema(&schema), expected); -} - -/// TS: "converts oneOf to anyOf". -#[test] -fn sanitize_converts_one_of_to_any_of() { - let schema = json!({ - "oneOf": [ - { "type": "string", "minLength": 1 }, - { "type": "number", "minimum": 0 } - ] - }); - - let expected = json!({ - "anyOf": [ - { "description": "min length: 1.", "type": "string" }, - { "description": "minimum: 0.", "type": "number" } - ] - }); - - assert_eq!(sanitize_json_schema(&schema), expected); -} - -/// TS: "does not mutate the input schema". -#[test] -fn sanitize_does_not_mutate_input_schema() { - let schema = json!({ - "type": "object", - "properties": { - "value": { "type": "number", "exclusiveMinimum": 0 } - } - }); - - let snapshot = schema.clone(); - let _ = sanitize_json_schema(&schema); - assert_eq!(schema, snapshot); -} - -// ═════════════════════════════════════════════════════════════════════════════ -// Mid-conversation tool changes -// (TS: anthropic-language-model.test.ts → 'mid-conversation tool changes') -// ═════════════════════════════════════════════════════════════════════════════ - -/// TS: "should send tool change blocks and the beta header". -/// -/// The TS test sends a system message carrying -/// `providerOptions.anthropic.toolChanges = [{ type: 'tool_removal', toolName: -/// 'get_weather' }]` and asserts: -/// - `messages` contains a `system` message whose content is -/// `[{ type: 'tool_removal', tool: { type: 'tool_reference', name: -/// 'get_weather' } }]`. -/// - the `anthropic-beta` request header contains -/// `mid-conversation-tool-changes-2026-07-01`. -// TODO: implementation gap — the Rust convert does not yet read -// `toolChanges` from provider options, emit `tool_removal`/`tool_addition` -// system blocks, or set the `mid-conversation-tool-changes-2026-07-01` beta -// header. Re-enable once that pipeline is implemented. -#[tokio::test] -#[ignore] -async fn sends_tool_change_blocks_and_beta_header() { - let server = MockServer::start().await; - mock_messages_ok(&server, text_response("OK")).await; - - let config = AnthropicConfig::new("test-api-key").with_base_url(server.uri()); - let model = AnthropicModel::new("claude-opus-4-8".to_string(), config); - - // NOTE: when implemented, `toolChanges` should be carried on the system - // message's provider options. The current `LanguageModelPromptMessage` - // / `CallOptions` shape may not yet expose the required provider-options - // slot; that is part of the implementation gap. - let prompt: LanguageModelPrompt = vec![ - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Say OK.")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::System, - content: vec![], - ..Default::default() - }, - ]; - let opts = default_options(prompt); - - let _ = model - .do_generate(&opts) - .await - .expect("do_generate should succeed"); - - let requests = server.received_requests().await.expect("requests recorded"); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - - // The system message should carry the tool_removal block. - let system_msgs: Vec<&Value> = body["messages"] - .as_array() - .expect("messages array") - .iter() - .filter(|m| m["role"] == "system") - .collect(); - assert!( - system_msgs.iter().any(|m| { - m["content"] - .as_array() - .map(|c| { - c.iter().any(|block| { - block["type"] == "tool_removal" - && block["tool"]["type"] == "tool_reference" - && block["tool"]["name"] == "get_weather" - }) - }) - .unwrap_or(false) - }), - "expected a system tool_removal block, got body: {body}" - ); - - // The beta header should advertise mid-conversation tool changes. - let beta = requests[0] - .headers - .get("anthropic-beta") - .and_then(|v| v.to_str().ok()) - .unwrap_or(""); - assert!( - beta.contains("mid-conversation-tool-changes-2026-07-01"), - "missing beta header, got: {beta}" - ); -} diff --git a/aimux-providers/tests/azure_model_test.rs b/aimux-providers/tests/azure_model_test.rs deleted file mode 100644 index c25609097..000000000 --- a/aimux-providers/tests/azure_model_test.rs +++ /dev/null @@ -1,917 +0,0 @@ -//! Wiremock tests for the Azure OpenAI provider. -//! -//! These cover the Azure-specific differences from the OpenAI provider: -//! - deployment-based URL construction with an `api-version` query parameter -//! - `api-key` header authentication (API key) -//! - `Authorization: Bearer ` authentication (Azure AD token provider) -//! - request/response handling (reusing the OpenAI conversion logic) -//! - streaming -//! - error status codes -//! - config validation (missing resource/base URL, missing auth) -//! -//! Each test spins up a `wiremock` mock server, points an `AzureProvider` at -//! it via `base_url`, and asserts on the issued request and/or the parsed -//! result. - -use aimux_core::tool::RawToolCall; -use std::sync::Arc; -use std::sync::atomic::{AtomicU32, Ordering}; - -use async_trait::async_trait; -use futures::StreamExt; -use serde_json::{Value, json}; -use wiremock::matchers::{method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -use aimux_core::content::ContentPart; -use aimux_core::error::AiMuxError; -use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; -use aimux_core::options::CallOptions; -use aimux_core::result::GenerateContent; -use aimux_core::stream_part::StreamPart; -use aimux_core::types::{FinishReasonUnified, ReasoningEffort}; - -use aimux_providers::{AzureConfig, AzureProvider, TokenProvider}; - -// ── helpers ───────────────────────────────────────────────────────────────── - -/// The TS `TEST_PROMPT`: a single user text message "Hello". -fn test_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] -} - -/// Build `CallOptions` with everything unset except `prompt`. -fn default_options(prompt: LanguageModelPrompt) -> CallOptions { - CallOptions::new(prompt) -} - -/// Standard mock for a JSON chat completion response on a given path. -async fn mock_json_response(server: &MockServer, request_path: &str, body: Value) { - Mock::given(method("POST")) - .and(path(request_path)) - .respond_with(ResponseTemplate::new(200).set_body_json(body)) - .mount(server) - .await; -} - -/// Mock for a JSON error response on a given path with a status code. -async fn mock_json_error(server: &MockServer, request_path: &str, status: u16, body: Value) { - Mock::given(method("POST")) - .and(path(request_path)) - .respond_with(ResponseTemplate::new(status).set_body_json(body)) - .mount(server) - .await; -} - -/// Standard mock for an SSE streaming response on a given path. -async fn mock_sse_response(server: &MockServer, request_path: &str, sse_body: &str) { - Mock::given(method("POST")) - .and(path(request_path)) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(sse_body.to_string()), - ) - .mount(server) - .await; -} - -/// A simple text chat-completion response body. -fn text_completion_response(text: &str) -> Value { - json!({ - "id": "chatcmpl-test", - "object": "chat.completion", - "created": 1711115037, - "model": "gpt-4o", - "choices": [{ - "index": 0, - "message": { "role": "assistant", "content": text }, - "finish_reason": "stop" - }], - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 } - }) -} - -/// Concatenate SSE events and append `[DONE]`. -fn sse_body(events: &[&str]) -> String { - let mut body = String::new(); - for event in events { - body.push_str(event); - } - body.push_str("data: [DONE]\n\n"); - body -} - -/// Collect all `StreamPart`s from a `StreamResult` into a `Vec`. -async fn collect_stream(result: aimux_core::result::StreamResult) -> Vec { - let mut parts = Vec::new(); - let mut stream = result.stream; - while let Some(part) = stream.next().await { - match part { - Ok(p) => parts.push(p), - Err(e) => panic!("stream error: {e:?}"), - } - } - parts -} - -/// Extract text deltas from a list of stream parts. -fn text_deltas(parts: &[StreamPart]) -> Vec { - parts - .iter() - .filter_map(|p| match p { - StreamPart::TextDelta { delta, .. } => Some(delta.clone()), - _ => None, - }) - .collect() -} - -/// A token provider that returns a fixed token. -struct StaticToken(String); -#[async_trait] -impl TokenProvider for StaticToken { - async fn get_token(&self) -> Result { - Ok(self.0.clone()) - } -} - -/// A token provider that returns a different token on each call -/// (`token-1`, `token-2`, …) so tests can assert it is invoked per request. -struct CountingToken { - count: AtomicU32, -} -#[async_trait] -impl TokenProvider for CountingToken { - async fn get_token(&self) -> Result { - let n = self.count.fetch_add(1, Ordering::SeqCst) + 1; - Ok(format!("token-{n}")) - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// URL construction -// ════════════════════════════════════════════════════════════════════════════ - -/// The default deployment-based URL form places the deployment in the path and -/// appends `?api-version=2024-10-21` (the default api version). -#[tokio::test] -async fn should_build_deployment_based_url_with_default_api_version() { - let server = MockServer::start().await; - mock_json_response( - &server, - "/deployments/gpt-4o/chat/completions", - text_completion_response("hi"), - ) - .await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("test-api-key"); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.deployment("gpt-4o"); - let _ = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - let requests = server.received_requests().await.expect("requests recorded"); - assert_eq!(requests.len(), 1); - let url = &requests[0].url; - assert_eq!(url.path(), "/deployments/gpt-4o/chat/completions"); - assert_eq!(url.query(), Some("api-version=2024-10-21")); -} - -/// A custom `api_version` is reflected in the `api-version` query parameter. -#[tokio::test] -async fn should_use_custom_api_version() { - let server = MockServer::start().await; - mock_json_response( - &server, - "/deployments/gpt-4o/chat/completions", - text_completion_response("hi"), - ) - .await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("test-api-key") - .with_api_version("2025-04-01-preview"); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.deployment("gpt-4o"); - let _ = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - let requests = server.received_requests().await.unwrap(); - assert_eq!( - requests[0].url.query(), - Some("api-version=2025-04-01-preview") - ); -} - -/// The v1 URL form uses `/v1/chat/completions` (no deployment in the path). -/// With a non-Azure gateway `base_url`, the gateway owns versioning and -/// `api-version` is omitted — mirroring the TS `useAzureOpenAIEndpoint` gate. -/// (The real-Azure v1 case — `api-version` appended — is covered by the unit -/// tests in `azure/model.rs`, which construct the URL from a `resource_name` -/// without needing a live HTTP endpoint.) -#[tokio::test] -async fn should_use_v1_url_form_on_gateway() { - let server = MockServer::start().await; - mock_json_response( - &server, - "/v1/chat/completions", - text_completion_response("hi"), - ) - .await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("test-api-key") - .use_v1_urls(); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.deployment("gpt-4o"); - let _ = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - let requests = server.received_requests().await.unwrap(); - assert_eq!(requests[0].url.path(), "/v1/chat/completions"); - // Non-Azure gateway owns its own versioning — no api-version query param. - assert_eq!(requests[0].url.query(), None); -} - -// ════════════════════════════════════════════════════════════════════════════ -// Authentication -// ════════════════════════════════════════════════════════════════════════════ - -/// API-key auth sends the `api-key` header (not a Bearer token). -#[tokio::test] -async fn should_send_api_key_header() { - let server = MockServer::start().await; - mock_json_response( - &server, - "/deployments/gpt-4o/chat/completions", - text_completion_response("hi"), - ) - .await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("test-api-key"); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.deployment("gpt-4o"); - let _ = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - let requests = server.received_requests().await.unwrap(); - let headers = &requests[0].headers; - assert_eq!( - headers.get("api-key").and_then(|v| v.to_str().ok()), - Some("test-api-key") - ); - // No Authorization header when using api-key auth. - assert!(headers.get("authorization").is_none()); -} - -/// A token provider supplies a Bearer token via the `Authorization` header. -#[tokio::test] -async fn should_send_bearer_token_from_token_provider() { - let server = MockServer::start().await; - mock_json_response( - &server, - "/deployments/gpt-4o/chat/completions", - text_completion_response("hi"), - ) - .await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_token_provider(Arc::new(StaticToken("test-azure-ad-token".to_string()))); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.deployment("gpt-4o"); - let _ = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - let requests = server.received_requests().await.unwrap(); - let headers = &requests[0].headers; - assert_eq!( - headers.get("authorization").and_then(|v| v.to_str().ok()), - Some("Bearer test-azure-ad-token") - ); - // No api-key header when using token auth. - assert!(headers.get("api-key").is_none()); -} - -/// The token provider is invoked on every request (tokens differ per call). -#[tokio::test] -async fn should_call_token_provider_per_request() { - let server = MockServer::start().await; - mock_json_response( - &server, - "/deployments/gpt-4o/chat/completions", - text_completion_response("hi"), - ) - .await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_token_provider(Arc::new(CountingToken { - count: AtomicU32::new(0), - })); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.deployment("gpt-4o"); - - let _ = model - .do_generate(&default_options(test_prompt())) - .await - .expect("first call"); - let _ = model - .do_generate(&default_options(test_prompt())) - .await - .expect("second call"); - - let requests = server.received_requests().await.unwrap(); - assert_eq!(requests.len(), 2); - assert_eq!( - requests[0] - .headers - .get("authorization") - .and_then(|v| v.to_str().ok()), - Some("Bearer token-1") - ); - assert_eq!( - requests[1] - .headers - .get("authorization") - .and_then(|v| v.to_str().ok()), - Some("Bearer token-2") - ); -} - -/// Provider-level and per-request headers are merged onto the auth headers. -#[tokio::test] -async fn should_merge_provider_and_request_headers() { - let server = MockServer::start().await; - mock_json_response( - &server, - "/deployments/gpt-4o/chat/completions", - text_completion_response("hi"), - ) - .await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("test-api-key") - .with_header("Custom-Provider-Header", "provider-header-value"); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.deployment("gpt-4o"); - - let mut options = default_options(test_prompt()); - let mut req_headers = std::collections::HashMap::new(); - req_headers.insert( - "Custom-Request-Header".to_string(), - "request-header-value".to_string(), - ); - options.headers = Some(req_headers); - - let _ = model.do_generate(&options).await.expect("do_generate"); - - let requests = server.received_requests().await.unwrap(); - let headers = &requests[0].headers; - assert_eq!( - headers.get("api-key").and_then(|v| v.to_str().ok()), - Some("test-api-key") - ); - assert_eq!( - headers - .get("custom-provider-header") - .and_then(|v| v.to_str().ok()), - Some("provider-header-value") - ); - assert_eq!( - headers - .get("custom-request-header") - .and_then(|v| v.to_str().ok()), - Some("request-header-value") - ); -} - -// ════════════════════════════════════════════════════════════════════════════ -// Response parsing (reuses OpenAI conversion logic) -// ════════════════════════════════════════════════════════════════════════════ - -/// `do_generate` extracts the assistant text content. -#[tokio::test] -async fn should_extract_text_response() { - let server = MockServer::start().await; - mock_json_response( - &server, - "/deployments/gpt-4o/chat/completions", - text_completion_response("Hello, World!"), - ) - .await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("test-api-key"); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.deployment("gpt-4o"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!(result.content.len(), 1); - match &result.content[0] { - GenerateContent::Text { text, .. } => assert_eq!(text, "Hello, World!"), - other => panic!("expected Text, got {other:?}"), - } - // Finish reason + response metadata. - assert_eq!(result.finish_reason.unified, FinishReasonUnified::Stop); - assert_eq!(result.response.id, Some("chatcmpl-test".to_string())); - assert_eq!(result.response.model_id, Some("gpt-4o".to_string())); -} - -/// `do_generate` maps Azure/OpenAI usage tokens. -#[tokio::test] -async fn should_extract_usage() { - let server = MockServer::start().await; - mock_json_response( - &server, - "/deployments/gpt-4o/chat/completions", - text_completion_response(""), - ) - .await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("test-api-key"); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.deployment("gpt-4o"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!(result.usage.input_tokens.total, Some(4)); - assert_eq!(result.usage.output_tokens.total, Some(30)); -} - -/// The request body carries the deployment as `model` plus the user message. -#[tokio::test] -async fn should_send_deployment_and_messages_in_request_body() { - let server = MockServer::start().await; - mock_json_response( - &server, - "/deployments/gpt-4o/chat/completions", - text_completion_response("hi"), - ) - .await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("test-api-key"); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.deployment("gpt-4o"); - let _ = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - - let requests = server.received_requests().await.unwrap(); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["model"], "gpt-4o"); - assert_eq!(body["messages"][0]["role"], "user"); - assert_eq!(body["messages"][0]["content"], "Hello"); -} - -// ════════════════════════════════════════════════════════════════════════════ -// Streaming -// ════════════════════════════════════════════════════════════════════════════ - -/// `do_stream` emits text deltas followed by a Finish part. -#[tokio::test] -async fn should_stream_text_deltas() { - let server = MockServer::start().await; - let sse = sse_body(&[ - "data: {\"id\":\"chatcmpl-1\",\"model\":\"gpt-4o\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hello\"},\"finish_reason\":null}]}\n\n", - "data: {\"id\":\"chatcmpl-1\",\"model\":\"gpt-4o\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\", World!\"},\"finish_reason\":null}]}\n\n", - "data: {\"id\":\"chatcmpl-1\",\"model\":\"gpt-4o\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":4,\"completion_tokens\":10,\"total_tokens\":14}}\n\n", - ]); - mock_sse_response(&server, "/deployments/gpt-4o/chat/completions", &sse).await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("test-api-key"); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.deployment("gpt-4o"); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - - let parts = collect_stream(result).await; - let deltas = text_deltas(&parts); - assert_eq!(deltas, vec!["Hello".to_string(), ", World!".to_string()]); - - // The stream must end with a Finish part carrying the stop reason + usage. - let finish = parts - .iter() - .rev() - .find(|p| matches!(p, StreamPart::Finish { .. })) - .expect("stream should end with Finish"); - match finish { - StreamPart::Finish { - finish_reason, - usage, - .. - } => { - assert_eq!(finish_reason.unified, FinishReasonUnified::Stop); - assert_eq!(usage.output_tokens.total, Some(10)); - } - _ => unreachable!(), - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// Errors & config validation -// ════════════════════════════════════════════════════════════════════════════ - -/// A 401 response maps to `AiMuxError::ApiCall` (401 in `status_code`). -#[tokio::test] -async fn should_return_auth_error_on_401() { - let server = MockServer::start().await; - mock_json_error( - &server, - "/deployments/gpt-4o/chat/completions", - 401, - json!({ "error": { "message": "invalid api key", "type": "authentication_error" } }), - ) - .await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("bad-key"); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.deployment("gpt-4o"); - - let err = model - .do_generate(&default_options(test_prompt())) - .await - .expect_err("401 should error"); - assert!( - matches!(err, ref e if e.status_code() == Some(401)), - "{err:?}" - ); -} - -/// Constructing a provider without `resource_name` or `base_url` is rejected. -#[tokio::test] -async fn should_reject_missing_resource_and_base_url() { - let config = AzureConfig::new().with_api_key("test-api-key"); - assert!(matches!( - AzureProvider::new(config), - Err(AiMuxError::InvalidArgument(_)) - )); -} - -/// Calling a model with no auth configured returns an `Auth` error at request -/// time. -#[tokio::test] -async fn should_error_when_no_auth_configured() { - let server = MockServer::start().await; - // No mock is mounted: the request must never be sent because auth fails - // before the HTTP call. - let config = AzureConfig::new().with_base_url(server.uri()); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.deployment("gpt-4o"); - - let err = model - .do_generate(&default_options(test_prompt())) - .await - .expect_err("no auth should error"); - assert!(matches!(err, AiMuxError::InvalidArgument(_)), "{err:?}"); - - // No request should have hit the server. - let requests = server.received_requests().await.unwrap(); - assert!(requests.is_empty()); -} - -// ════════════════════════════════════════════════════════════════════════════ -// Additional cases — tool calls, response format, reasoning, headers, errors. -// ════════════════════════════════════════════════════════════════════════════ - -/// A chat-completion response carrying a single tool call. -fn tool_call_completion_body() -> Value { - json!({ - "id": "chatcmpl-tool", - "object": "chat.completion", - "created": 1711115037, - "model": "gpt-4o", - "choices": [{ - "index": 0, - "message": { - "role": "assistant", - "tool_calls": [{ - "id": "call_abc", - "type": "function", - "function": { - "name": "get-weather", - "arguments": "{\"city\":\"SF\"}" - } - }] - }, - "finish_reason": "tool_calls" - }], - "usage": { "prompt_tokens": 10, "total_tokens": 20, "completion_tokens": 10 } - }) -} - -/// TS: tool-call extraction — `do_generate` surfaces a `ToolCall` content. -#[tokio::test] -async fn should_extract_tool_call() { - let server = MockServer::start().await; - mock_json_response( - &server, - "/deployments/gpt-4o/chat/completions", - tool_call_completion_body(), - ) - .await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("test-api-key"); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.deployment("gpt-4o"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!(result.content.len(), 1); - match &result.content[0] { - GenerateContent::ToolCall(RawToolCall { - tool_call_id, - tool_name, - input, - .. - }) => { - assert_eq!(tool_call_id, "call_abc"); - assert_eq!(tool_name, "get-weather"); - assert_eq!(input, r#"{"city":"SF"}"#); - } - other => panic!("expected ToolCall, got {other:?}"), - } - assert_eq!(result.finish_reason.unified, FinishReasonUnified::ToolCalls); -} - -/// TS: "should stream tool call content" — streaming emits a ToolCall part. -#[tokio::test] -async fn should_stream_tool_call() { - let server = MockServer::start().await; - let sse = sse_body(&[ - "data: {\"id\":\"chatcmpl-1\",\"model\":\"gpt-4o\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"id\":\"call_abc\",\"type\":\"function\",\"function\":{\"name\":\"get-weather\",\"arguments\":\"\"}}]},\"finish_reason\":null}]}\n\n", - "data: {\"id\":\"chatcmpl-1\",\"model\":\"gpt-4o\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"city\\\":\\\"SF\\\"}\"}}]},\"finish_reason\":null}]}\n\n", - "data: {\"id\":\"chatcmpl-1\",\"model\":\"gpt-4o\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":10,\"total_tokens\":20}}\n\n", - ]); - mock_sse_response(&server, "/deployments/gpt-4o/chat/completions", &sse).await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("test-api-key"); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.deployment("gpt-4o"); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - - let parts = collect_stream(result).await; - let tool_call = parts.iter().find_map(|p| match p { - StreamPart::ToolCall(RawToolCall { - tool_call_id, - tool_name, - input, - .. - }) => Some((tool_call_id.clone(), tool_name.clone(), input.clone())), - _ => None, - }); - let (id, name, input) = tool_call.expect("should have ToolCall"); - assert_eq!(id, "call_abc"); - assert_eq!(name, "get-weather"); - assert_eq!(input, r#"{"city":"SF"}"#); -} - -/// TS: "should send a json_schema response format for structured output" -#[tokio::test] -async fn should_send_json_schema_response_format() { - let server = MockServer::start().await; - mock_json_response( - &server, - "/deployments/gpt-4o/chat/completions", - text_completion_response("ok"), - ) - .await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("test-api-key"); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.deployment("gpt-4o"); - - let schema = json!({ - "type": "object", - "properties": { "sentiment": { "type": "string" } }, - "required": ["sentiment"], - "additionalProperties": false - }); - let mut options = default_options(test_prompt()); - options.response_format = Some(aimux_core::options::ResponseFormat::Json { - schema: Some(schema.clone()), - name: None, - description: None, - }); - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - assert_eq!(body["response_format"]["type"], json!("json_schema")); - assert_eq!(body["response_format"]["json_schema"]["schema"], schema); - assert_eq!( - body["response_format"]["json_schema"]["strict"], - json!(true) - ); -} - -/// TS: "should map top-level reasoning to Azure DeepSeek reasoning effort" — -/// a custom `reasoning` value maps to `reasoning_effort` in the request body. -#[tokio::test] -async fn should_map_reasoning_effort() { - let server = MockServer::start().await; - mock_json_response( - &server, - "/deployments/gpt-4o/chat/completions", - text_completion_response("ok"), - ) - .await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("test-api-key"); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.deployment("gpt-4o"); - - let mut options = default_options(test_prompt()); - options.reasoning = Some(ReasoningEffort::High); - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - assert_eq!(body["reasoning_effort"], json!("high")); -} - -/// TS: response headers are exposed on the generate result. -#[tokio::test] -async fn should_expose_response_headers() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/deployments/gpt-4o/chat/completions")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("test-header", "test-value") - .set_body_json(text_completion_response("hi")), - ) - .mount(&server) - .await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("test-api-key"); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.deployment("gpt-4o"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - let headers = result - .response_headers - .as_ref() - .expect("response_headers should be Some"); - assert_eq!(headers.get("test-header"), Some(&"test-value".to_string())); -} - -/// TS: response headers are exposed on the stream result. -#[tokio::test] -async fn should_expose_response_headers_stream() { - let server = MockServer::start().await; - let sse = sse_body(&[ - "data: {\"id\":\"chatcmpl-1\",\"model\":\"gpt-4o\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hi\"},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":1,\"total_tokens\":2}}\n\n", - ]); - Mock::given(method("POST")) - .and(path("/deployments/gpt-4o/chat/completions")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .insert_header("test-header", "test-value") - .set_body_string(sse), - ) - .mount(&server) - .await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("test-api-key"); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.deployment("gpt-4o"); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - - let headers = result - .response_headers - .as_ref() - .expect("response_headers should be Some"); - assert_eq!(headers.get("test-header"), Some(&"test-value".to_string())); -} - -/// TS: a `length` finish reason maps to `FinishReasonUnified::Length`. -#[tokio::test] -async fn should_map_length_finish_reason() { - let server = MockServer::start().await; - mock_json_response( - &server, - "/deployments/gpt-4o/chat/completions", - json!({ - "id": "chatcmpl-len", - "object": "chat.completion", - "created": 1711115037, - "model": "gpt-4o", - "choices": [{ - "index": 0, - "message": { "role": "assistant", "content": "truncated" }, - "finish_reason": "length" - }], - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 } - }), - ) - .await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("test-api-key"); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.deployment("gpt-4o"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - assert_eq!(result.finish_reason.unified, FinishReasonUnified::Length); - assert_eq!(result.finish_reason.raw.as_deref(), Some("length")); -} - -/// TS: a 429 response maps to `AiMuxError::ApiCall` (429 in `status_code`). -#[tokio::test] -async fn should_return_rate_limited_on_429() { - let server = MockServer::start().await; - mock_json_error( - &server, - "/deployments/gpt-4o/chat/completions", - 429, - json!({ "error": { "message": "Too many requests", "type": "rate_limit" } }), - ) - .await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("test-api-key"); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.deployment("gpt-4o"); - - let err = model - .do_generate(&default_options(test_prompt())) - .await - .expect_err("429 should error"); - assert!(matches!(err, ref e if e.status_code() == Some(429))); -} diff --git a/aimux-providers/tests/azure_responses_test.rs b/aimux-providers/tests/azure_responses_test.rs deleted file mode 100644 index 31df3d0a8..000000000 --- a/aimux-providers/tests/azure_responses_test.rs +++ /dev/null @@ -1,1052 +0,0 @@ -//! Wiremock tests for the Azure OpenAI Responses API provider. -//! -//! These cover the Azure-specific differences from the OpenAI Responses -//! provider: -//! - deployment-based URL construction with an `api-version` query parameter -//! - v1 URL form on a non-Azure gateway (no `api-version`) -//! - `api-key` header authentication (API key) -//! - `Authorization: Bearer ` authentication (Azure AD token provider) -//! - token provider invoked per request (tokens differ per call) -//! - custom provider/request headers + user-agent suffix -//! - Azure `assistant-` file ID prefix passthrough (file_id vs base64) -//! - doGenerate text extraction, usage, response metadata, provider metadata -//! - doStream text content streaming -//! - error status codes -//! - env-var config (serial) -//! -//! Each test spins up a `wiremock` mock server, points an `AzureProvider` at -//! it via `base_url`, and asserts on the issued request and/or the parsed -//! result. -//! -//! Translated from the TS test suite -//! `reference/ai/packages/azure/src/azure-openai-provider.test.ts` -//! (responses-specific cases). Not all 44 TS cases are translated — the focus -//! is on Azure-specific capabilities. - -use aimux_core::tool::RawToolCall; -use std::sync::Arc; -use std::sync::atomic::{AtomicU32, Ordering}; - -use async_trait::async_trait; -use futures::StreamExt; -use serde_json::{Value, json}; -use wiremock::matchers::{header, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -use aimux_core::content::ContentPart; -use aimux_core::error::AiMuxError; -use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; -use aimux_core::options::CallOptions; -use aimux_core::result::GenerateContent; -use aimux_core::stream_part::StreamPart; -use aimux_core::types::{FinishReasonUnified, ResponseMetadata}; - -use aimux_providers::{AzureConfig, AzureProvider, TokenProvider}; - -// ── helpers ───────────────────────────────────────────────────────────────── - -/// The TS `TEST_PROMPT`: a single user text message "Hello". -fn test_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] -} - -/// Build `CallOptions` with everything unset except `prompt`. -fn default_options(prompt: LanguageModelPrompt) -> CallOptions { - CallOptions::new(prompt) -} - -/// The default deployment-based path used by most tests. -const DEPLOYMENT_PATH: &str = "/deployments/test-deployment/responses"; - -/// Standard mock for a JSON responses-api response on the deployment path. -async fn mock_json_response(server: &MockServer, body: Value) { - Mock::given(method("POST")) - .and(path(DEPLOYMENT_PATH)) - .respond_with(ResponseTemplate::new(200).set_body_json(body)) - .mount(server) - .await; -} - -/// Mock for a JSON responses-api response with custom headers. -async fn mock_json_response_with_headers( - server: &MockServer, - body: Value, - headers: Vec<(&str, &str)>, -) { - let mut template = ResponseTemplate::new(200).set_body_json(body); - for (k, v) in headers { - template = template.insert_header(k, v); - } - Mock::given(method("POST")) - .and(path(DEPLOYMENT_PATH)) - .respond_with(template) - .mount(server) - .await; -} - -/// Standard mock for an SSE streaming response on the deployment path. -async fn mock_sse_response(server: &MockServer, sse_body: &str) { - Mock::given(method("POST")) - .and(path(DEPLOYMENT_PATH)) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(sse_body.to_string()), - ) - .mount(server) - .await; -} - -/// A simple text responses-api response body (single message output item). -fn text_response_body() -> Value { - json!({ - "id": "resp_67c97c0203188190a025beb4a75242bc", - "object": "response", - "created_at": 1741257730, - "status": "completed", - "error": null, - "incomplete_details": null, - "model": "gpt-4o", - "output": [ - { - "id": "msg_67c97c02656c81908e080dfdf4a03cd1", - "type": "message", - "status": "completed", - "role": "assistant", - "content": [ - { - "type": "output_text", - "text": "answer text", - "annotations": [] - } - ] - } - ], - "usage": { - "input_tokens": 345, - "input_tokens_details": { - "cached_tokens": 234 - }, - "output_tokens": 538, - "output_tokens_details": { - "reasoning_tokens": 123 - } - }, - "reasoning": { "effort": null, "summary": null, "context": "current_turn" } - }) -} - -/// A responses-api response body with a function_call output item. -fn tool_call_response_body() -> Value { - json!({ - "id": "resp_tool_call_123", - "object": "response", - "created_at": 1741257730, - "status": "completed", - "error": null, - "incomplete_details": null, - "model": "gpt-4o", - "output": [ - { - "id": "fc_001", - "type": "function_call", - "status": "completed", - "call_id": "call_abc123", - "name": "getWeather", - "arguments": "{\"location\": \"San Francisco\"}" - } - ], - "usage": { - "input_tokens": 50, - "output_tokens": 20, - "total_tokens": 70 - } - }) -} - -/// Concatenate SSE events and append `[DONE]`. -fn sse_body(events: &[&str]) -> String { - let mut body = String::new(); - for event in events { - body.push_str(event); - } - body.push_str("data: [DONE]\n\n"); - body -} - -/// Build an SSE event string from a JSON string. -fn sse_event(json_str: &str) -> String { - format!("data: {json_str}\n\n") -} - -/// Collect all `StreamPart`s from a `StreamResult` into a `Vec`. -async fn collect_stream(result: aimux_core::result::StreamResult) -> Vec { - let mut parts = Vec::new(); - let mut stream = result.stream; - while let Some(part) = stream.next().await { - match part { - Ok(p) => parts.push(p), - Err(e) => panic!("stream error: {e:?}"), - } - } - parts -} - -/// Extract text deltas from a list of stream parts. -fn text_deltas(parts: &[StreamPart]) -> Vec { - parts - .iter() - .filter_map(|p| match p { - StreamPart::TextDelta { delta, .. } => Some(delta.clone()), - _ => None, - }) - .collect() -} - -/// Extract the first recorded request body as a JSON value. -async fn first_request_body(server: &MockServer) -> Value { - let requests = server - .received_requests() - .await - .expect("no requests received"); - serde_json::from_slice(&requests[0].body).expect("invalid JSON body") -} - -/// A token provider that returns a fixed token. -struct StaticToken(String); -#[async_trait] -impl TokenProvider for StaticToken { - async fn get_token(&self) -> Result { - Ok(self.0.clone()) - } -} - -/// A token provider that returns a different token on each call -/// (`token-1`, `token-2`, …) so tests can assert it is invoked per request. -struct CountingToken { - count: AtomicU32, -} -#[async_trait] -impl TokenProvider for CountingToken { - async fn get_token(&self) -> Result { - let n = self.count.fetch_add(1, Ordering::SeqCst) + 1; - Ok(format!("token-{n}")) - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// URL construction & api-version -// ════════════════════════════════════════════════════════════════════════════ - -/// The default deployment-based URL form places the deployment in the path and -/// appends `?api-version=2024-10-21` (the Rust AzureConfig default). -#[tokio::test] -async fn should_build_deployment_url_with_default_api_version() { - let server = MockServer::start().await; - mock_json_response(&server, text_response_body()).await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("test-api-key"); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.responses_model("test-deployment"); - let _ = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - let requests = server.received_requests().await.expect("requests recorded"); - assert_eq!(requests.len(), 1); - assert_eq!(requests[0].url.path(), DEPLOYMENT_PATH); - assert_eq!(requests[0].url.query(), Some("api-version=2024-10-21")); -} - -/// A custom `api_version` is reflected in the `api-version` query parameter. -#[tokio::test] -async fn should_use_custom_api_version() { - let server = MockServer::start().await; - mock_json_response(&server, text_response_body()).await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("test-api-key") - .with_api_version("2025-04-01-preview"); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.responses_model("test-deployment"); - let _ = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - let requests = server.received_requests().await.unwrap(); - assert_eq!( - requests[0].url.query(), - Some("api-version=2025-04-01-preview") - ); -} - -/// The v1 form on a non-Azure gateway baseURL omits `api-version` -/// (the gateway owns its own versioning). -#[tokio::test] -async fn should_omit_api_version_on_gateway_v1_url() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/v1/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_response_body())) - .mount(&server) - .await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("test-api-key") - .use_v1_urls(); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.responses_model("test-deployment"); - let _ = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - let requests = server.received_requests().await.unwrap(); - assert_eq!(requests[0].url.path(), "/v1/responses"); - // Non-Azure gateway owns its own versioning — no api-version query param. - assert_eq!(requests[0].url.query(), None); -} - -// ════════════════════════════════════════════════════════════════════════════ -// Authentication -// ════════════════════════════════════════════════════════════════════════════ - -/// API-key auth sends the `api-key` header (not a Bearer token). -#[tokio::test] -async fn should_send_api_key_header() { - let server = MockServer::start().await; - mock_json_response(&server, text_response_body()).await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("test-api-key"); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.responses_model("test-deployment"); - let _ = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - let requests = server.received_requests().await.unwrap(); - let headers = &requests[0].headers; - assert_eq!( - headers.get("api-key").and_then(|v| v.to_str().ok()), - Some("test-api-key") - ); - // No Authorization header when using api-key auth. - assert!(headers.get("authorization").is_none()); -} - -/// A token provider supplies a Bearer token via the `Authorization` header. -#[tokio::test] -async fn should_send_bearer_token_from_token_provider() { - let server = MockServer::start().await; - mock_json_response(&server, text_response_body()).await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_token_provider(Arc::new(StaticToken("test-azure-ad-token".to_string()))); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.responses_model("test-deployment"); - let _ = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - let requests = server.received_requests().await.unwrap(); - let headers = &requests[0].headers; - assert_eq!( - headers.get("authorization").and_then(|v| v.to_str().ok()), - Some("Bearer test-azure-ad-token") - ); - // No api-key header when using token auth. - assert!(headers.get("api-key").is_none()); -} - -/// The token provider is invoked on every request (tokens differ per call). -#[tokio::test] -async fn should_call_token_provider_per_request() { - let server = MockServer::start().await; - // Mount two responses (one per request). - for _ in 0..2 { - mock_json_response(&server, text_response_body()).await; - } - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_token_provider(Arc::new(CountingToken { - count: AtomicU32::new(0), - })); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.responses_model("test-deployment"); - - let _ = model - .do_generate(&default_options(test_prompt())) - .await - .expect("first do_generate"); - let _ = model - .do_generate(&default_options(test_prompt())) - .await - .expect("second do_generate"); - - let requests = server.received_requests().await.unwrap(); - assert_eq!( - requests[0] - .headers - .get("authorization") - .and_then(|v| v.to_str().ok()), - Some("Bearer token-1") - ); - assert_eq!( - requests[1] - .headers - .get("authorization") - .and_then(|v| v.to_str().ok()), - Some("Bearer token-2") - ); -} - -// ════════════════════════════════════════════════════════════════════════════ -// Headers -// ════════════════════════════════════════════════════════════════════════════ - -/// Provider-level and per-request headers are merged; user-agent suffix is set. -#[tokio::test] -async fn should_pass_custom_headers() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path(DEPLOYMENT_PATH)) - .and(header("api-key", "test-api-key")) - .and(header("custom-provider-header", "provider-header-value")) - .and(header("custom-request-header", "request-header-value")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_response_body())) - .mount(&server) - .await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("test-api-key") - .with_header("Custom-Provider-Header", "provider-header-value"); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.responses_model("test-deployment"); - - let options = CallOptions { - headers: Some({ - let mut m = std::collections::HashMap::new(); - m.insert( - "Custom-Request-Header".to_string(), - "request-header-value".to_string(), - ); - m - }), - ..default_options(test_prompt()) - }; - - let _ = model - .do_generate(&options) - .await - .expect("do_generate should succeed"); - - // If the mock matched, the headers were all present. - let requests = server.received_requests().await.unwrap(); - assert_eq!(requests.len(), 1); - let ua = requests[0] - .headers - .get("user-agent") - .and_then(|v| v.to_str().ok()) - .unwrap_or(""); - assert!( - ua.contains("ai-sdk/azure"), - "user-agent should contain ai-sdk/azure, got: {ua}" - ); -} - -// ════════════════════════════════════════════════════════════════════════════ -// doGenerate — response parsing -// ════════════════════════════════════════════════════════════════════════════ - -/// Text content is extracted from the `output` array. -#[tokio::test] -async fn should_extract_text_content() { - let server = MockServer::start().await; - mock_json_response(&server, text_response_body()).await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("test-api-key"); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.responses_model("test-deployment"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - let text: Vec<&str> = result - .content - .iter() - .filter_map(|c| match c { - GenerateContent::Text { text, .. } => Some(text.as_str()), - _ => None, - }) - .collect(); - assert_eq!(text, vec!["answer text"]); -} - -/// Function-call output items are extracted as ToolCall content. -#[tokio::test] -async fn should_extract_tool_call_content() { - let server = MockServer::start().await; - mock_json_response(&server, tool_call_response_body()).await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("test-api-key"); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.responses_model("test-deployment"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - let tool_calls: Vec<_> = result - .content - .iter() - .filter_map(|c| match c { - GenerateContent::ToolCall(RawToolCall { - tool_call_id, - tool_name, - input, - .. - }) => Some((tool_call_id.clone(), tool_name.clone(), input.clone())), - _ => None, - }) - .collect(); - assert_eq!(tool_calls.len(), 1); - assert_eq!(tool_calls[0].0, "call_abc123"); - assert_eq!(tool_calls[0].1, "getWeather"); - assert_eq!(tool_calls[0].2, r#"{"location": "San Francisco"}"#); -} - -/// Usage is extracted from the `usage` field. -#[tokio::test] -async fn should_extract_usage() { - let server = MockServer::start().await; - mock_json_response(&server, text_response_body()).await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("test-api-key"); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.responses_model("test-deployment"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - assert_eq!(result.usage.input_tokens.total, Some(345)); - assert_eq!(result.usage.output_tokens.total, Some(538)); -} - -/// Response metadata (id, model, timestamp) is extracted. -#[tokio::test] -async fn should_extract_response_metadata() { - let server = MockServer::start().await; - mock_json_response(&server, text_response_body()).await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("test-api-key"); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.responses_model("test-deployment"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - assert_eq!( - result.response.id.as_deref(), - Some("resp_67c97c0203188190a025beb4a75242bc") - ); - assert_eq!(result.response.model_id.as_deref(), Some("gpt-4o")); - assert!(result.response.timestamp.is_some()); -} - -/// Provider metadata uses the `azure` namespace key. -#[tokio::test] -async fn should_use_azure_provider_metadata_namespace() { - let server = MockServer::start().await; - mock_json_response(&server, text_response_body()).await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("test-api-key"); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.responses_model("test-deployment"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - let pm = result.provider_metadata.expect("provider_metadata"); - assert!(pm.contains_key("azure"), "should have 'azure' key"); - assert_eq!( - pm["azure"]["responseId"], - "resp_67c97c0203188190a025beb4a75242bc" - ); - // The provider() method returns "azure.responses". - assert_eq!(model.provider(), "azure.responses"); -} - -/// Finish reason is `stop` for a completed text response. -#[tokio::test] -async fn should_map_finish_reason_stop() { - let server = MockServer::start().await; - mock_json_response(&server, text_response_body()).await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("test-api-key"); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.responses_model("test-deployment"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - assert_eq!(result.finish_reason.unified, FinishReasonUnified::Stop); -} - -/// Finish reason is `tool-calls` when the output contains a function_call. -#[tokio::test] -async fn should_map_finish_reason_tool_calls() { - let server = MockServer::start().await; - mock_json_response(&server, tool_call_response_body()).await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("test-api-key"); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.responses_model("test-deployment"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - assert_eq!(result.finish_reason.unified, FinishReasonUnified::ToolCalls); -} - -/// Response headers are captured. -#[tokio::test] -async fn should_extract_response_headers() { - let server = MockServer::start().await; - mock_json_response_with_headers( - &server, - text_response_body(), - vec![("test-header", "test-value")], - ) - .await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("test-api-key"); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.responses_model("test-deployment"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - let headers = result.response_headers.expect("response_headers"); - assert_eq!( - headers.get("test-header").map(std::string::String::as_str), - Some("test-value") - ); -} - -// ════════════════════════════════════════════════════════════════════════════ -// Azure file ID prefix (assistant-) -// ════════════════════════════════════════════════════════════════════════════ - -/// When a FileBase64 part's data starts with `assistant-`, it is passed -/// through as a `file_id` instead of being base64-encoded. -#[tokio::test] -async fn should_pass_through_assistant_file_id_for_image() { - let server = MockServer::start().await; - mock_json_response(&server, text_response_body()).await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("test-api-key"); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.responses_model("test-deployment"); - - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ - ContentPart::text("Analyze this image"), - ContentPart::FileBase64 { - data: "assistant-abc123".to_string(), - media_type: "image/jpeg".to_string(), - filename: None, - provider_options: None, - }, - ], - ..Default::default() - }]; - - let _ = model - .do_generate(&default_options(prompt)) - .await - .expect("should succeed"); - - let body = first_request_body(&server).await; - assert_eq!(body["input"][0]["content"][1]["type"], "input_image"); - assert_eq!( - body["input"][0]["content"][1]["file_id"], - "assistant-abc123" - ); - // image_url should NOT be present. - assert!(body["input"][0]["content"][1].get("image_url").is_none()); -} - -/// When a FileBase64 part's data starts with `assistant-` for a PDF, -/// it is passed through as a `file_id` with `input_file` type. -#[tokio::test] -async fn should_pass_through_assistant_file_id_for_pdf() { - let server = MockServer::start().await; - mock_json_response(&server, text_response_body()).await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("test-api-key"); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.responses_model("test-deployment"); - - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ - ContentPart::text("Analyze this PDF"), - ContentPart::FileBase64 { - data: "assistant-pdf123".to_string(), - media_type: "application/pdf".to_string(), - filename: None, - provider_options: None, - }, - ], - ..Default::default() - }]; - - let _ = model - .do_generate(&default_options(prompt)) - .await - .expect("should succeed"); - - let body = first_request_body(&server).await; - assert_eq!(body["input"][0]["content"][1]["type"], "input_file"); - assert_eq!( - body["input"][0]["content"][1]["file_id"], - "assistant-pdf123" - ); - // file_data and filename should NOT be present. - assert!(body["input"][0]["content"][1].get("file_data").is_none()); - assert!(body["input"][0]["content"][1].get("filename").is_none()); -} - -/// Non-`assistant-` file IDs fall back to base64 encoding (the default -/// OpenAI behavior). The Rust convert produces `input_file` for `FileBase64` -/// regardless of media type — the data is not treated as a file ID. -#[tokio::test] -async fn should_fall_back_to_base64_for_non_assistant_file_ids() { - let server = MockServer::start().await; - mock_json_response(&server, text_response_body()).await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("test-api-key"); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.responses_model("test-deployment"); - - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ - ContentPart::text("Analyze this image"), - ContentPart::FileBase64 { - data: "file-abc123".to_string(), - media_type: "image/jpeg".to_string(), - filename: None, - provider_options: None, - }, - ], - ..Default::default() - }]; - - let _ = model - .do_generate(&default_options(prompt)) - .await - .expect("should succeed"); - - let body = first_request_body(&server).await; - // Rust convert produces input_file for FileBase64 (not input_image). - assert_eq!(body["input"][0]["content"][1]["type"], "input_file"); - assert_eq!( - body["input"][0]["content"][1]["file_data"], - "data:image/jpeg;base64,file-abc123" - ); - // file_id should NOT be present (data doesn't start with assistant-). - assert!(body["input"][0]["content"][1].get("file_id").is_none()); -} - -// ════════════════════════════════════════════════════════════════════════════ -// doStream — text content -// ════════════════════════════════════════════════════════════════════════════ - -/// Streaming text content: response.created → output_item.added → -/// output_text.delta → output_item.done → response.completed. -#[tokio::test] -async fn should_stream_text_content() { - let server = MockServer::start().await; - let sse = sse_body(&[ - &sse_event( - r#"{"type":"response.created","response":{"id":"resp_123","object":"response","created_at":1741257730,"status":"in_progress","model":"gpt-4o","output":[]}}"#, - ), - &sse_event( - r#"{"type":"response.output_item.added","output_index":0,"item":{"id":"msg_1","type":"message","status":"in_progress","role":"assistant","content":[]}}"#, - ), - &sse_event( - r#"{"type":"response.output_text.delta","item_id":"msg_1","output_index":0,"content_index":0,"delta":"Hello"}"#, - ), - &sse_event( - r#"{"type":"response.output_text.delta","item_id":"msg_1","output_index":0,"content_index":0,"delta":" world"}"#, - ), - &sse_event( - r#"{"type":"response.output_item.done","output_index":0,"item":{"id":"msg_1","type":"message","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Hello world","annotations":[]}]}}"#, - ), - &sse_event( - r#"{"type":"response.completed","response":{"id":"resp_123","object":"response","created_at":1741257730,"status":"completed","model":"gpt-4o","output":[{"id":"msg_1","type":"message","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Hello world","annotations":[]}]}],"usage":{"input_tokens":10,"output_tokens":5,"total_tokens":15}}}"#, - ), - ]); - mock_sse_response(&server, &sse).await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("test-api-key"); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.responses_model("test-deployment"); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - - let parts = collect_stream(result).await; - - // StreamStart - assert!(matches!(&parts[0], StreamPart::StreamStart { .. })); - - // ResponseMetadata - assert!(matches!( - &parts[1], - StreamPart::ResponseMetadata(ResponseMetadata { id, .. }) if id.as_deref() == Some("resp_123") - )); - - // TextStart - assert!(matches!( - &parts[2], - StreamPart::TextStart { id, .. } if id == "msg_1" - )); - - // TextDelta "Hello" + " world" - let deltas = text_deltas(&parts); - assert_eq!(deltas, vec!["Hello", " world"]); - - // TextEnd - assert!(matches!( - parts.iter().find(|p| matches!(p, StreamPart::TextEnd { .. })), - Some(StreamPart::TextEnd { id, .. }) if id == "msg_1" - )); - - // Finish - let finish = parts - .iter() - .find(|p| matches!(p, StreamPart::Finish { .. })); - assert!(finish.is_some()); - if let Some(StreamPart::Finish { - finish_reason, - usage, - .. - }) = finish - { - assert_eq!(finish_reason.unified, FinishReasonUnified::Stop); - assert_eq!(usage.input_tokens.total, Some(10)); - assert_eq!(usage.output_tokens.total, Some(5)); - } else { - panic!("expected Finish part"); - } -} - -/// Streaming: api-key header is sent and api-version query param is present. -#[tokio::test] -async fn should_send_api_key_header_on_stream() { - let server = MockServer::start().await; - let sse = sse_body(&[ - &sse_event( - r#"{"type":"response.created","response":{"id":"resp_1","object":"response","created_at":1,"status":"in_progress","model":"gpt-4o","output":[]}}"#, - ), - &sse_event( - r#"{"type":"response.completed","response":{"id":"resp_1","object":"response","created_at":1,"status":"completed","model":"gpt-4o","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}"#, - ), - ]); - mock_sse_response(&server, &sse).await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("test-api-key"); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.responses_model("test-deployment"); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - let _ = collect_stream(result).await; - - let requests = server.received_requests().await.unwrap(); - assert_eq!(requests.len(), 1); - assert_eq!(requests[0].url.query(), Some("api-version=2024-10-21")); - assert_eq!( - requests[0] - .headers - .get("api-key") - .and_then(|v| v.to_str().ok()), - Some("test-api-key") - ); -} - -// ════════════════════════════════════════════════════════════════════════════ -// Error handling -// ════════════════════════════════════════════════════════════════════════════ - -/// A non-2xx response is mapped to a provider error. -#[tokio::test] -async fn should_handle_error_status() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path(DEPLOYMENT_PATH)) - .respond_with(ResponseTemplate::new(429).set_body_json(json!({ - "error": { - "message": "Rate limit exceeded", - "type": "rate_limit_exceeded", - "code": "rate_limit_exceeded" - } - }))) - .mount(&server) - .await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("test-api-key"); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.responses_model("test-deployment"); - - let result = model.do_generate(&default_options(test_prompt())).await; - - assert!(result.is_err()); - let err = result.unwrap_err(); - let msg = err.to_string().to_lowercase(); - assert!( - msg.contains("rate limit"), - "error should mention rate limit, got: {err}" - ); -} - -// ════════════════════════════════════════════════════════════════════════════ -// Environment variable configuration (serial) -// ════════════════════════════════════════════════════════════════════════════ - -/// `AzureConfig::from_env` reads `AZURE_API_KEY` and `AZURE_RESOURCE_NAME`. -#[tokio::test] -#[serial_test::serial] -async fn should_create_config_from_env() { - // Set env vars. - unsafe { - std::env::set_var("AZURE_API_KEY", "env-api-key"); - std::env::set_var("AZURE_RESOURCE_NAME", "env-resource"); - } - - let config = AzureConfig::from_env().expect("from_env"); - assert_eq!(config.resource_name.as_deref(), Some("env-resource")); - - // Clean up. - unsafe { - std::env::remove_var("AZURE_API_KEY"); - std::env::remove_var("AZURE_RESOURCE_NAME"); - } - - // Verify the config produces the right URL. - let model = AzureProvider::new(config) - .expect("provider") - .responses_model("my-deployment"); - assert_eq!(model.model_id(), "my-deployment"); - assert_eq!(model.provider(), "azure.responses"); -} - -/// `AzureConfig::from_env` fails when `AZURE_API_KEY` is not set. -#[tokio::test] -#[serial_test::serial] -async fn should_fail_from_env_without_api_key() { - // Ensure the env var is not set. - unsafe { - std::env::remove_var("AZURE_API_KEY"); - } - - let result = AzureConfig::from_env(); - assert!(result.is_err()); -} - -// ════════════════════════════════════════════════════════════════════════════ -// Request body construction -// ════════════════════════════════════════════════════════════════════════════ - -/// The request body includes the model (deployment) and input array. -#[tokio::test] -async fn should_send_model_and_input_in_request_body() { - let server = MockServer::start().await; - mock_json_response(&server, text_response_body()).await; - - let config = AzureConfig::new() - .with_base_url(server.uri()) - .with_api_key("test-api-key"); - let provider = AzureProvider::new(config).expect("provider"); - let model = provider.responses_model("test-deployment"); - - let _ = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - let body = first_request_body(&server).await; - assert_eq!(body["model"], "test-deployment"); - assert_eq!(body["input"][0]["role"], "user"); - assert_eq!(body["input"][0]["content"][0]["type"], "input_text"); - assert_eq!(body["input"][0]["content"][0]["text"], "Hello"); -} diff --git a/aimux-providers/tests/bedrock_convert_test.rs b/aimux-providers/tests/bedrock_convert_test.rs deleted file mode 100644 index a5a536629..000000000 --- a/aimux-providers/tests/bedrock_convert_test.rs +++ /dev/null @@ -1,1410 +0,0 @@ -//! Pure-function tests for the Amazon Bedrock provider's conversion layer. -//! -//! Translated from the TS test files: -//! - `convert-to-amazon-bedrock-chat-messages.test.ts` (64 cases) -//! - `amazon-bedrock-prepare-tools.test.ts` (23 cases incl. `it.each`) -//! - `convert-amazon-bedrock-usage.test.ts` (9 cases) -//! -//! Cases the Rust data model cannot express are skipped with an inline -//! comment. The main categories of skips: -//! - **Message-level `providerOptions`**: `LanguageModelPromptMessage` has no -//! `provider_options` field, so message-level cache points (system / user / -//! assistant message `bedrock.cachePoint`) cannot be expressed. Part-level -//! cache points (on `ContentPart::Text` `provider_options`) ARE covered. -//! - **System-after-non-system throw / unsupported-mime throws**: the Rust -//! `convert_prompt_to_bedrock` returns `(system, messages)` (no `Result`), -//! so it cannot surface `UnsupportedFunctionalityError`. -//! - **S3 URLs / provider references**: `FileUrl` / `FileReference` are not -//! converted by the Rust Bedrock path. -//! - **Top-level-only mediaType auto-detection from bytes**: the Rust path -//! does not sniff magic bytes. -//! - **Mistral tool-call-id normalization (`isMistral`)**: the Rust -//! `convert_prompt_to_bedrock` has no `isMistral` parameter. The -//! non-Mistral (passthrough) cases ARE covered. -//! - **Redacted reasoning / foreign-provider reasoning**: `ContentPart::Reasoning` -//! carries only `signature` (no `redactedData`, no provider distinction). -//! - **Provider-defined tools (web_search, anthropic provider tools)** and -//! `additionalTools`/`betas`: the Rust `FunctionTool` has no `type`/`id`. -//! - **`raw` echo on `Usage`**: the Rust `Usage` type has no `raw` field. - -use serde_json::{Value, json}; - -use aimux_core::content::ContentPart; -use aimux_core::language_model_message::LanguageModelPromptMessage; -use aimux_core::message::Role; -use aimux_core::options::ToolChoice; -use aimux_core::shared::{SharedProviderOptions, provider_namespace}; -use aimux_core::tool::FunctionTool; - -use aimux_providers::bedrock::convert::{ - BedrockUsage, convert_prompt_to_bedrock, convert_usage, prepare_tools, supports_strict_tools, -}; - -// ── prompt builders ───────────────────────────────────────────────────────── - -fn msg(role: Role, content: Vec) -> LanguageModelPromptMessage { - LanguageModelPromptMessage { - role, - content, - provider_options: None, - } -} - -fn user(content: Vec) -> LanguageModelPromptMessage { - msg(Role::User, content) -} - -fn assistant(content: Vec) -> LanguageModelPromptMessage { - msg(Role::Assistant, content) -} - -fn system_msg(text: &str) -> LanguageModelPromptMessage { - msg(Role::System, vec![ContentPart::text(text)]) -} - -fn tool_msg(content: Vec) -> LanguageModelPromptMessage { - msg(Role::Tool, content) -} - -/// A `file` part whose inline data is an already-base64 string — mirrors the -/// TS `file` part with `data: { type: 'data', data: '' }` (the Rust -/// `ContentPart::FileBase64` holds the base64 string verbatim). -fn file_base64( - data: &str, - media_type: &str, - filename: Option<&str>, - provider_options: Option, -) -> ContentPart { - ContentPart::FileBase64 { - data: data.to_string(), - media_type: media_type.to_string(), - filename: filename.map(std::string::ToString::to_string), - provider_options, - } -} - -fn text_with_cache(text: &str, cache_type: &str, ttl: Option<&str>) -> ContentPart { - let mut cp = serde_json::Map::new(); - cp.insert("type".to_string(), json!(cache_type)); - if let Some(t) = ttl { - cp.insert("ttl".to_string(), json!(t)); - } - ContentPart::Text { - text: text.to_string(), - provider_options: Some(provider_namespace( - "bedrock", - json!({ "cachePoint": Value::Object(cp) }), - )), - } -} - -fn reasoning(text: &str, signature: Option<&str>) -> ContentPart { - ContentPart::Reasoning { - text: text.to_string(), - signature: signature.map(std::string::ToString::to_string), - provider_options: None, - } -} - -fn tool_call(id: &str, name: &str, input: Value) -> ContentPart { - ContentPart::tool_call(id.to_string(), name.to_string(), input) -} - -fn tool_result(id: &str, output: Value) -> ContentPart { - ContentPart::tool_result(id.to_string(), output) -} - -const ANTHROPIC_MODEL: &str = "anthropic.claude-sonnet-4-5-20250929-v1:0"; -const NON_ANTHROPIC_MODEL: &str = "meta.llama3-70b-instruct-v1:0"; - -// ════════════════════════════════════════════════════════════════════════════ -// convert-to-amazon-bedrock-chat-messages -// ════════════════════════════════════════════════════════════════════════════ - -// ── system messages ───────────────────────────────────────────────────────── - -/// TS: "should combine multiple leading system messages into a single system message" -#[test] -fn system_combine_multiple_leading() { - let (system, _) = convert_prompt_to_bedrock(&vec![system_msg("Hello"), system_msg("World")]); - assert_eq!( - Value::Array(system), - json!([{ "text": "Hello" }, { "text": "World" }]) - ); -} - -// SKIPPED (TS: "should throw an error if a system message is provided after a -// non-system message"): convert_prompt_to_bedrock returns (system, messages) -// with no Result, so it cannot surface UnsupportedFunctionalityError. - -// SKIPPED (TS: system message cache point, 5m, 1h — 3 cases): message-level -// providerOptions are not modelled on LanguageModelPromptMessage. - -/// TS: "should extract the system message" -#[test] -fn system_extract_single() { - let (system, _) = convert_prompt_to_bedrock(&vec![system_msg("Hello")]); - assert_eq!(Value::Array(system), json!([{ "text": "Hello" }])); -} - -// ── user messages ─────────────────────────────────────────────────────────── - -/// TS: "should convert messages with image parts" -#[test] -fn user_convert_image_parts() { - let (_, messages) = convert_prompt_to_bedrock(&vec![user(vec![ - ContentPart::text("Hello"), - file_base64("AAECAw==", "image/png", None, None), - ])]); - assert_eq!( - Value::Array(messages), - json!([{ - "role": "user", - "content": [ - { "text": "Hello" }, - { "image": { "format": "png", "source": { "bytes": "AAECAw==" } } }, - ] - }]) - ); -} - -// SKIPPED (TS: "should convert image parts with S3 URLs"): FileUrl is not -// converted by the Rust Bedrock path. - -/// TS: "should convert messages with document parts" -#[test] -fn user_convert_document_parts() { - let (_, messages) = convert_prompt_to_bedrock(&vec![user(vec![ - ContentPart::text("Hello"), - file_base64("AAECAw==", "application/pdf", None, None), - ])]); - assert_eq!( - messages[0]["content"], - json!([ - { "text": "Hello" }, - { "document": { "format": "pdf", "name": "document-1", "source": { "bytes": "AAECAw==" } } }, - ]) - ); -} - -/// TS: "should strip file extension when filename is provided" -#[test] -fn user_strip_file_extension() { - let (_, messages) = convert_prompt_to_bedrock(&vec![user(vec![ - ContentPart::text("Hello"), - file_base64( - "AAECAw==", - "application/pdf", - Some("custom-filename.pdf"), - None, - ), - ])]); - assert_eq!( - messages[0]["content"][1], - json!({ "document": { "format": "pdf", "name": "custom-filename", "source": { "bytes": "AAECAw==" } } }) - ); -} - -/// TS: "should preserve filename without extension when provided" -#[test] -fn user_preserve_filename_without_extension() { - let (_, messages) = convert_prompt_to_bedrock(&vec![user(vec![file_base64( - "AAECAw==", - "application/pdf", - Some("custom-filename"), - None, - )])]); - assert_eq!( - messages[0]["content"][0], - json!({ "document": { "format": "pdf", "name": "custom-filename", "source": { "bytes": "AAECAw==" } } }) - ); -} - -/// TS: "should use consistent document names for prompt cache effectiveness" -#[test] -fn user_consistent_document_names() { - let (_, messages) = convert_prompt_to_bedrock(&vec![ - user(vec![ - file_base64("AAECAw==", "application/pdf", None, None), - file_base64("BAUGBw==", "application/pdf", None, None), - ]), - assistant(vec![ContentPart::text("OK")]), - user(vec![file_base64("AAECAw==", "application/pdf", None, None)]), - ]); - assert_eq!( - Value::Array(messages), - json!([ - { "role": "user", "content": [ - { "document": { "format": "pdf", "name": "document-1", "source": { "bytes": "AAECAw==" } } }, - { "document": { "format": "pdf", "name": "document-2", "source": { "bytes": "BAUGBw==" } } }, - ]}, - { "role": "assistant", "content": [{ "text": "OK" }] }, - { "role": "user", "content": [ - { "document": { "format": "pdf", "name": "document-3", "source": { "bytes": "AAECAw==" } } }, - ]}, - ]) - ); -} - -// SKIPPED (TS: user message cache point, 5m, 1h — 3 cases): message-level -// providerOptions are not modelled on LanguageModelPromptMessage. - -// SKIPPED (TS: "should throw for file parts with provider references"): -// FileReference is not converted (no Result to surface the throw). - -/// TS: "should add cache point to user content part when specified" -#[test] -fn user_content_part_cache_point() { - let (system, messages) = convert_prompt_to_bedrock(&vec![user(vec![ - ContentPart::text("Hello"), - text_with_cache("cached", "default", Some("5m")), - ContentPart::text("World"), - ])]); - assert!(system.is_empty()); - assert_eq!( - Value::Array(messages), - json!([{ - "role": "user", - "content": [ - { "text": "Hello" }, - { "text": "cached" }, - { "cachePoint": { "type": "default", "ttl": "5m" } }, - { "text": "World" }, - ] - }]) - ); -} - -// ── assistant messages ────────────────────────────────────────────────────── - -/// TS: "should remove trailing whitespace from last assistant message when there is no further user message" -#[test] -fn assistant_trim_trailing_whitespace_last() { - let (_, messages) = convert_prompt_to_bedrock(&vec![ - user(vec![ContentPart::text("user content")]), - assistant(vec![ContentPart::text("assistant content ")]), - ]); - assert_eq!( - Value::Array(messages), - json!([ - { "role": "user", "content": [{ "text": "user content" }] }, - { "role": "assistant", "content": [{ "text": "assistant content" }] }, - ]) - ); -} - -/// TS: "should remove trailing whitespace from last assistant message with multi-part content when there is no further user message" -#[test] -fn assistant_trim_trailing_whitespace_multi_part() { - let (_, messages) = convert_prompt_to_bedrock(&vec![ - user(vec![ContentPart::text("user content")]), - assistant(vec![ - ContentPart::text("assistant "), - ContentPart::text("content "), - ]), - ]); - assert_eq!( - messages[1]["content"], - json!([{ "text": "assistant " }, { "text": "content" }]) - ); -} - -/// TS: "should keep trailing whitespace from assistant message when there is a further user message" -#[test] -fn assistant_keep_trailing_whitespace_with_further_user() { - let (_, messages) = convert_prompt_to_bedrock(&vec![ - user(vec![ContentPart::text("user content")]), - assistant(vec![ContentPart::text("assistant content ")]), - user(vec![ContentPart::text("user content 2")]), - ]); - assert_eq!( - messages[1]["content"], - json!([{ "text": "assistant content " }]) - ); -} - -/// TS: "should combine multiple sequential assistant messages into a single message" -#[test] -fn assistant_combine_sequential() { - let (_, messages) = convert_prompt_to_bedrock(&vec![ - user(vec![ContentPart::text("Hi!")]), - assistant(vec![ContentPart::text("Hello")]), - assistant(vec![ContentPart::text("World")]), - assistant(vec![ContentPart::text("!")]), - ]); - assert_eq!( - Value::Array(messages), - json!([ - { "role": "user", "content": [{ "text": "Hi!" }] }, - { "role": "assistant", "content": [{ "text": "Hello" }, { "text": "World" }, { "text": "!" }] }, - ]) - ); -} - -// SKIPPED (TS: assistant message cache point, 5m, 1h — 3 cases): message-level -// providerOptions are not modelled on LanguageModelPromptMessage. - -/// TS: "should add cache point to assistant content part when specified" -#[test] -fn assistant_content_part_cache_point() { - let (_, messages) = convert_prompt_to_bedrock(&vec![assistant(vec![ - ContentPart::text("Hello"), - text_with_cache("cached", "default", Some("1h")), - ContentPart::text("World"), - ])]); - assert_eq!( - Value::Array(messages), - json!([{ - "role": "assistant", - "content": [ - { "text": "Hello" }, - { "text": "cached" }, - { "cachePoint": { "type": "default", "ttl": "1h" } }, - { "text": "World" }, - ] - }]) - ); -} - -/// TS: "should properly convert reasoning content type" -#[test] -fn assistant_reasoning_with_signature() { - let (_, messages) = convert_prompt_to_bedrock(&vec![ - user(vec![ContentPart::text("Explain your reasoning")]), - assistant(vec![reasoning( - "This is my step-by-step reasoning process", - Some("test-signature"), - )]), - ]); - assert_eq!( - messages[1]["content"], - json!([{ - "reasoningContent": { - "reasoningText": { - "text": "This is my step-by-step reasoning process", - "signature": "test-signature" - } - } - }]) - ); -} - -// SKIPPED (TS: "should properly convert redacted-reasoning content type"): -// ContentPart::Reasoning has no redactedData field. - -// SKIPPED (TS: "should omit assistant message reasoning parts signed by a -// foreign provider"): ContentPart::Reasoning.signature carries no provider -// distinction, so foreign-provider (anthropic) signatures cannot be detected. - -/// TS: "should preserve assistant message reasoning parts with amazonBedrock providerOptions" -#[test] -fn assistant_preserve_amazon_bedrock_reasoning() { - let (_, messages) = convert_prompt_to_bedrock(&vec![ - user(vec![ContentPart::text("Explain your reasoning")]), - assistant(vec![ - reasoning( - "Bedrock-signed reasoning round-tripped to Bedrock", - Some("bedrock-signature"), - ), - ContentPart::text("final answer"), - ]), - ]); - assert_eq!( - messages[1]["content"], - json!([ - { "reasoningContent": { "reasoningText": { - "text": "Bedrock-signed reasoning round-tripped to Bedrock", - "signature": "bedrock-signature" - }}}, - { "text": "final answer" }, - ]) - ); -} - -/// TS: "should not trim reasoning text when a signature is present" -#[test] -fn assistant_no_trim_reasoning_with_signature() { - let (_, messages) = convert_prompt_to_bedrock(&vec![ - user(vec![ContentPart::text("Explain your reasoning")]), - assistant(vec![reasoning( - "This is my reasoning with trailing space ", - Some("test-signature"), - )]), - ]); - assert_eq!( - messages[1]["content"][0]["reasoningContent"]["reasoningText"]["text"], - json!("This is my reasoning with trailing space ") - ); -} - -/// TS: "should omit reasoning content without signature" -#[test] -fn assistant_omit_reasoning_without_signature() { - let (_, messages) = convert_prompt_to_bedrock(&vec![ - user(vec![ContentPart::text("Explain your reasoning")]), - assistant(vec![ - reasoning("This is my reasoning with trailing space ", None), - ContentPart::text("final answer"), - ]), - ]); - assert_eq!(messages[1]["content"], json!([{ "text": "final answer" }])); -} - -/// TS: "should omit multiple reasoning parts without signatures" -#[test] -fn assistant_omit_multiple_reasoning_without_signatures() { - let (_, messages) = convert_prompt_to_bedrock(&vec![ - user(vec![ContentPart::text("Explain your reasoning")]), - assistant(vec![ - reasoning("First reasoning with trailing space ", None), - reasoning("Second reasoning with trailing space ", None), - ContentPart::text("final answer"), - ]), - ]); - assert_eq!(messages[1]["content"], json!([{ "text": "final answer" }])); -} - -/// TS: "should omit unsigned reasoning while preserving tool calls in multi-turn tool use" -#[test] -fn assistant_omit_unsigned_reasoning_preserving_tool_calls() { - let (_, messages) = convert_prompt_to_bedrock(&vec![ - user(vec![ContentPart::text("What is the weather?")]), - assistant(vec![ - reasoning("I should call the weather tool.", None), - tool_call("call-1", "getWeather", json!({ "city": "SF" })), - ]), - tool_msg(vec![tool_result( - "call-1", - json!({ "type": "text", "value": "Sunny, 72F" }), - )]), - ]); - assert_eq!( - Value::Array(messages), - json!([ - { "role": "user", "content": [{ "text": "What is the weather?" }] }, - { "role": "assistant", "content": [{ - "toolUse": { "input": { "city": "SF" }, "name": "getWeather", "toolUseId": "call-1" } - }]}, - { "role": "user", "content": [{ - "toolResult": { "toolUseId": "call-1", "content": [{ "text": "Sunny, 72F" }] } - }]}, - ]) - ); -} - -/// TS: "should preserve reasoning text with signature in multi-turn tool use" -#[test] -fn assistant_preserve_reasoning_multi_turn() { - let (_, messages) = convert_prompt_to_bedrock(&vec![ - user(vec![ContentPart::text("What is the weather?")]), - assistant(vec![ - reasoning("Let me check the weather API.\n", Some("sig-abc123")), - tool_call("call-1", "getWeather", json!({ "city": "SF" })), - ]), - tool_msg(vec![tool_result( - "call-1", - json!({ "type": "text", "value": "Sunny, 72F" }), - )]), - assistant(vec![ - reasoning("The weather is sunny and warm.\n", Some("sig-def456")), - ContentPart::text("It is sunny and 72F in SF."), - ]), - ]); - assert_eq!( - Value::Array(messages), - json!([ - { "role": "user", "content": [{ "text": "What is the weather?" }] }, - { "role": "assistant", "content": [ - { "reasoningContent": { "reasoningText": { - "text": "Let me check the weather API.\n", "signature": "sig-abc123" - }}}, - { "toolUse": { "input": { "city": "SF" }, "name": "getWeather", "toolUseId": "call-1" } }, - ]}, - { "role": "user", "content": [{ - "toolResult": { "toolUseId": "call-1", "content": [{ "text": "Sunny, 72F" }] } - }]}, - { "role": "assistant", "content": [ - { "reasoningContent": { "reasoningText": { - "text": "The weather is sunny and warm.\n", "signature": "sig-def456" - }}}, - { "text": "It is sunny and 72F in SF." }, - ]}, - ]) - ); -} - -/// TS: "should handle a mix of text and reasoning content types" -#[test] -fn assistant_mix_text_and_reasoning() { - let (_, messages) = convert_prompt_to_bedrock(&vec![ - user(vec![ContentPart::text("Explain your reasoning")]), - assistant(vec![ - ContentPart::text("My answer is 42."), - reasoning( - "I calculated this by analyzing the meaning of life", - Some("reasoning-process"), - ), - ]), - ]); - assert_eq!( - messages[1]["content"], - json!([ - { "text": "My answer is 42." }, - { "reasoningContent": { "reasoningText": { - "text": "I calculated this by analyzing the meaning of life", - "signature": "reasoning-process" - }}}, - ]) - ); -} - -/// TS: "should filter out empty text blocks in assistant messages" -#[test] -fn assistant_filter_empty_text_blocks() { - let (_, messages) = convert_prompt_to_bedrock(&vec![ - user(vec![ContentPart::text("Hello")]), - assistant(vec![ - ContentPart::text("\n\n"), - tool_call("call-123", "test", json!({})), - ContentPart::text(" "), - ContentPart::text("actual content"), - ]), - ]); - assert_eq!( - messages[1]["content"], - json!([ - { "toolUse": { "toolUseId": "call-123", "name": "test", "input": {} } }, - { "text": "actual content" }, - ]) - ); -} - -/// TS: "should wrap non-object (invalid) tool call input in an object" -#[test] -fn assistant_wrap_non_object_tool_input() { - let (_, messages) = convert_prompt_to_bedrock(&vec![assistant(vec![tool_call( - "call-1", - "cityAttractions", - Value::String("{ \"city\": \"San Francisco\", }".to_string()), - )])]); - assert_eq!( - messages[0]["content"], - json!([{ - "toolUse": { - "toolUseId": "call-1", - "name": "cityAttractions", - "input": { "rawInvalidInput": "{ \"city\": \"San Francisco\", }" } - } - }]) - ); -} - -/// TS: "should strip invalid characters from tool call names" -#[test] -fn assistant_strip_invalid_tool_name_chars() { - let (_, messages) = convert_prompt_to_bedrock(&vec![assistant(vec![ - tool_call("call-1", "$READFILE", json!({})), - tool_call( - "call-2", - "exchange_delivered_order_items<|channel|>", - json!({}), - ), - tool_call("call-3", "$", json!({})), - ])]); - assert_eq!( - messages[0]["content"], - json!([ - { "toolUse": { "toolUseId": "call-1", "name": "READFILE", "input": {} } }, - { "toolUse": { "toolUseId": "call-2", "name": "exchange_delivered_order_itemschannel", "input": {} } }, - { "toolUse": { "toolUseId": "call-3", "name": "_", "input": {} } }, - ]) - ); -} - -/// TS: "should preserve empty text blocks when reasoning blocks are present" -#[test] -fn assistant_preserve_empty_text_with_reasoning() { - let (_, messages) = convert_prompt_to_bedrock(&vec![ - user(vec![ContentPart::text("Hello")]), - assistant(vec![ - reasoning("thinking...", Some("sig-1")), - ContentPart::text(""), - reasoning("more thinking...", Some("sig-2")), - ContentPart::text("response text"), - tool_call("call-123", "test", json!({})), - ]), - ]); - assert_eq!( - messages[1]["content"], - json!([ - { "reasoningContent": { "reasoningText": { "text": "thinking...", "signature": "sig-1" } } }, - { "text": "" }, - { "reasoningContent": { "reasoningText": { "text": "more thinking...", "signature": "sig-2" } } }, - { "text": "response text" }, - { "toolUse": { "toolUseId": "call-123", "name": "test", "input": {} } }, - ]) - ); -} - -// ── tool messages ─────────────────────────────────────────────────────────── - -/// TS: "should convert tool result with content array containing text" -#[test] -fn tool_result_content_text() { - let (_, messages) = convert_prompt_to_bedrock(&vec![tool_msg(vec![tool_result( - "call-123", - json!({ "type": "content", "value": [{ "type": "text", "text": "The result is 42" }] }), - )])]); - assert_eq!( - messages[0], - json!({ - "role": "user", - "content": [{ - "toolResult": { - "toolUseId": "call-123", - "content": [{ "text": "The result is 42" }] - } - }] - }) - ); -} - -/// TS: "should convert tool result with content array containing image" -#[test] -fn tool_result_content_image() { - let (_, messages) = convert_prompt_to_bedrock(&vec![tool_msg(vec![tool_result( - "call-123", - json!({ "type": "content", "value": [{ - "type": "file", - "data": { "type": "data", "data": "base64data" }, - "mediaType": "image/jpeg" - }] }), - )])]); - assert_eq!( - messages[0]["content"][0], - json!({ - "toolResult": { - "toolUseId": "call-123", - "content": [{ "image": { "format": "jpeg", "source": { "bytes": "base64data" } } }] - } - }) - ); -} - -// SKIPPED (TS: "should convert tool result images with S3 URLs"): FileUrl is -// not converted by the Rust Bedrock path. - -/// TS: "should convert tool result with content array containing PDF" -#[test] -fn tool_result_content_pdf() { - let (_, messages) = convert_prompt_to_bedrock(&vec![tool_msg(vec![tool_result( - "call-123", - json!({ "type": "content", "value": [{ - "type": "file", - "data": { "type": "data", "data": "base64data" }, - "mediaType": "application/pdf", - "filename": "tool-result.pdf" - }] }), - )])]); - assert_eq!( - messages[0]["content"][0], - json!({ - "toolResult": { - "toolUseId": "call-123", - "content": [{ - "document": { "format": "pdf", "name": "tool-result", "source": { "bytes": "base64data" } } - }] - } - }) - ); -} - -// SKIPPED (TS: "should throw error for unsupported image format in tool result -// content" and "should throw error for unsupported mime type in tool result -// file content"): convert_prompt_to_bedrock returns no Result; unknown mimes -// fall back to a default format instead of throwing. - -/// TS: "should fallback to stringified result when content is undefined" (json output) -#[test] -fn tool_result_json_output() { - let (_, messages) = convert_prompt_to_bedrock(&vec![tool_msg(vec![tool_result( - "call-123", - json!({ "type": "json", "value": { "value": 42 } }), - )])]); - assert_eq!( - messages[0]["content"][0], - json!({ - "toolResult": { - "toolUseId": "call-123", - "content": [{ "text": "{\"value\":42}" }] - } - }) - ); -} - -// ── citations ─────────────────────────────────────────────────────────────── - -/// TS: "should handle citations enabled for PDF" -#[test] -fn citations_enabled_for_pdf() { - let (_, messages) = convert_prompt_to_bedrock(&vec![user(vec![file_base64( - "AAECAw==", - "application/pdf", - None, - Some(provider_namespace( - "bedrock", - json!({ "citations": { "enabled": true } }), - )), - )])]); - assert_eq!( - messages[0]["content"][0], - json!({ - "document": { - "format": "pdf", - "name": "document-1", - "source": { "bytes": "AAECAw==" }, - "citations": { "enabled": true } - } - }) - ); -} - -/// TS: "should handle citations disabled for PDF" -#[test] -fn citations_disabled_for_pdf() { - let (_, messages) = convert_prompt_to_bedrock(&vec![user(vec![file_base64( - "AAECAw==", - "application/pdf", - None, - Some(provider_namespace( - "bedrock", - json!({ "citations": { "enabled": false } }), - )), - )])]); - assert_eq!( - messages[0]["content"][0], - json!({ - "document": { "format": "pdf", "name": "document-1", "source": { "bytes": "AAECAw==" } } - }) - ); - assert!( - messages[0]["content"][0]["document"] - .get("citations") - .is_none() - ); -} - -/// TS: "should handle no citations specified for PDF (default)" -#[test] -fn citations_default_for_pdf() { - let (_, messages) = convert_prompt_to_bedrock(&vec![user(vec![file_base64( - "AAECAw==", - "application/pdf", - None, - None, - )])]); - assert_eq!( - messages[0]["content"][0], - json!({ - "document": { "format": "pdf", "name": "document-1", "source": { "bytes": "AAECAw==" } } - }) - ); - assert!( - messages[0]["content"][0]["document"] - .get("citations") - .is_none() - ); -} - -/// TS: "should handle multiple PDFs with different citation settings" -#[test] -fn citations_multiple_pdfs() { - let (_, messages) = convert_prompt_to_bedrock(&vec![user(vec![ - file_base64( - "AAECAw==", - "application/pdf", - None, - Some(provider_namespace( - "bedrock", - json!({ "citations": { "enabled": true } }), - )), - ), - file_base64( - "BAUGBw==", - "application/pdf", - None, - Some(provider_namespace( - "bedrock", - json!({ "citations": { "enabled": false } }), - )), - ), - ])]); - assert_eq!( - messages[0]["content"], - json!([ - { "document": { "format": "pdf", "name": "document-1", "source": { "bytes": "AAECAw==" }, "citations": { "enabled": true } } }, - { "document": { "format": "pdf", "name": "document-2", "source": { "bytes": "BAUGBw==" } } }, - ]) - ); -} - -// ── additional file format tests ──────────────────────────────────────────── - -// SKIPPED (TS: "should throw an error for unsupported file mime type in user -// message content"): unknown mimes fall back to a default format (no throw). - -/// TS: "should handle xlsx files correctly" -#[test] -fn file_format_xlsx() { - let (system, messages) = convert_prompt_to_bedrock(&vec![user(vec![file_base64( - "base64data", - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - None, - None, - )])]); - assert!(system.is_empty()); - assert_eq!( - messages[0]["content"][0], - json!({ "document": { "format": "xlsx", "name": "document-1", "source": { "bytes": "base64data" } } }) - ); -} - -/// TS: "should handle docx files correctly" -#[test] -fn file_format_docx() { - let (_, messages) = convert_prompt_to_bedrock(&vec![user(vec![file_base64( - "base64data", - "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - None, - None, - )])]); - assert_eq!( - messages[0]["content"][0], - json!({ "document": { "format": "docx", "name": "document-1", "source": { "bytes": "base64data" } } }) - ); -} - -// ── Mistral tool call ID normalization ────────────────────────────────────── - -// SKIPPED (TS: "should normalize tool call IDs in tool results when isMistral -// is true" and "...in tool calls when isMistral is true"): the Rust -// convert_prompt_to_bedrock has no isMistral parameter. - -/// TS: "should not normalize tool call IDs when isMistral is false" -#[test] -fn mistral_no_normalize_when_false() { - let original_id = "tooluse_bpe71yCfRu2b5i-nKGDr5g"; - let (_, messages) = convert_prompt_to_bedrock(&vec![tool_msg(vec![tool_result( - original_id, - json!({ "type": "text", "value": "The result is 42" }), - )])]); - assert_eq!( - messages[0]["content"][0]["toolResult"]["toolUseId"], - json!(original_id) - ); -} - -/// TS: "should default to not normalizing when isMistral is not provided" -#[test] -fn mistral_default_no_normalize() { - let original_id = "tooluse_bpe71yCfRu2b5i-nKGDr5g"; - let (_, messages) = convert_prompt_to_bedrock(&vec![tool_msg(vec![tool_result( - original_id, - json!({ "type": "text", "value": "The result is 42" }), - )])]); - assert_eq!( - messages[0]["content"][0]["toolResult"]["toolUseId"], - json!(original_id) - ); -} - -// ── top-level-only mediaType resolution ───────────────────────────────────── - -/// TS: "should pass through a full image mediaType unchanged" -#[test] -fn media_type_pass_through_full_image() { - let (_, messages) = convert_prompt_to_bedrock(&vec![user(vec![file_base64( - "iVBORw0KGgo=", - "image/png", - None, - None, - )])]); - assert_eq!( - Value::Array(messages), - json!([{ - "role": "user", - "content": [{ "image": { "format": "png", "source": { "bytes": "iVBORw0KGgo=" } } }] - }]) - ); -} - -// SKIPPED (TS: "should detect subtype from inline bytes when mediaType is -// top-level-only (image)" and "...(application/pdf)"): the Rust path does not -// sniff magic bytes to resolve a top-level-only mediaType. - -/// TS: "should route to document slot for non-image top-level type via getTopLevelMediaType" -#[test] -fn media_type_route_to_document_text_plain() { - let (_, messages) = convert_prompt_to_bedrock(&vec![user(vec![file_base64( - "base64data", - "text/plain", - None, - None, - )])]); - assert_eq!( - messages[0]["content"][0], - json!({ "document": { "format": "txt", "name": "document-1", "source": { "bytes": "base64data" } } }) - ); -} - -// SKIPPED (TS: "should throw UnsupportedFunctionalityError for URL data (File -// URL)", "...for unsupported full image mediaType", and "...when top-level-only -// bytes cannot be detected"): FileUrl is not converted; unknown image mimes -// fall back to "png" instead of throwing; no magic-byte detection. - -// ════════════════════════════════════════════════════════════════════════════ -// amazon-bedrock-prepare-tools -// ════════════════════════════════════════════════════════════════════════════ - -fn func_tool(name: &str, description: Option<&str>, input_schema: Value) -> FunctionTool { - FunctionTool { - name: name.to_string(), - description: description.map(std::string::ToString::to_string), - input_schema, - strict: None, - provider_options: None, - input_examples: None, - } -} - -fn func_tool_strict( - name: &str, - description: Option<&str>, - input_schema: Value, - strict: Option, -) -> FunctionTool { - FunctionTool { - name: name.to_string(), - description: description.map(std::string::ToString::to_string), - input_schema, - strict, - provider_options: None, - input_examples: None, - } -} - -/// TS: "should return empty toolConfig when tools are undefined" -#[test] -fn prepare_tools_empty_when_undefined() { - let config = prepare_tools(&None, &ToolChoice::Auto, ANTHROPIC_MODEL); - assert_eq!(config, json!({})); -} - -/// TS: "should return empty toolConfig when tools are empty" -#[test] -fn prepare_tools_empty_when_empty() { - let config = prepare_tools(&Some(vec![]), &ToolChoice::Auto, ANTHROPIC_MODEL); - assert_eq!(config, json!({})); -} - -/// TS: "should correctly prepare function tools" -#[test] -fn prepare_tools_function_tools() { - let tools = Some(vec![func_tool( - "testFunction", - Some("A test function"), - json!({ "type": "object", "properties": {} }), - )]); - let config = prepare_tools(&tools, &ToolChoice::Auto, ANTHROPIC_MODEL); - assert_eq!( - config["tools"], - json!([{ - "toolSpec": { - "name": "testFunction", - "description": "A test function", - "inputSchema": { "json": { "type": "object", "properties": {} } } - } - }]) - ); -} - -/// TS: "should exclude description when it is empty string" -#[test] -fn prepare_tools_exclude_description_empty() { - let tools = Some(vec![func_tool( - "testFunction", - Some(""), - json!({ "type": "object", "properties": {} }), - )]); - let config = prepare_tools(&tools, &ToolChoice::Auto, ANTHROPIC_MODEL); - let spec = &config["tools"][0]["toolSpec"]; - assert!(spec.get("description").is_none()); -} - -/// TS: "should exclude description when it is whitespace-only" -#[test] -fn prepare_tools_exclude_description_whitespace() { - let tools = Some(vec![func_tool( - "testFunction", - Some(" "), - json!({ "type": "object", "properties": {} }), - )]); - let config = prepare_tools(&tools, &ToolChoice::Auto, ANTHROPIC_MODEL); - let spec = &config["tools"][0]["toolSpec"]; - assert!(spec.get("description").is_none()); -} - -/// TS: "should include description when it has content" -#[test] -fn prepare_tools_include_description() { - let tools = Some(vec![func_tool( - "testFunction", - Some("Valid description"), - json!({ "type": "object", "properties": {} }), - )]); - let config = prepare_tools(&tools, &ToolChoice::Auto, ANTHROPIC_MODEL); - assert_eq!( - config["tools"][0]["toolSpec"]["description"], - json!("Valid description") - ); -} - -// SKIPPED (TS: "should warn for provider-defined tools on non-anthropic models", -// "should warn and filter out web_search_20250305 tool", "should return empty -// toolConfig when all tools are filtered out"): the Rust FunctionTool has no -// type/id, so provider-defined tools are not modelled. - -/// TS: "should handle tool choice 'auto'" -#[test] -fn prepare_tools_tool_choice_auto() { - let tools = Some(vec![func_tool("testFunction", Some("Test"), json!({}))]); - let config = prepare_tools(&tools, &ToolChoice::Auto, NON_ANTHROPIC_MODEL); - assert_eq!(config["toolChoice"], json!({ "auto": {} })); -} - -/// TS: "should handle tool choice 'required'" -#[test] -fn prepare_tools_tool_choice_required() { - let tools = Some(vec![func_tool("testFunction", Some("Test"), json!({}))]); - let config = prepare_tools(&tools, &ToolChoice::Required, NON_ANTHROPIC_MODEL); - assert_eq!(config["toolChoice"], json!({ "any": {} })); -} - -/// TS: "should handle tool choice 'none' by clearing tools" -#[test] -fn prepare_tools_tool_choice_none_clears() { - let tools = Some(vec![func_tool("testFunction", Some("Test"), json!({}))]); - let config = prepare_tools(&tools, &ToolChoice::None, NON_ANTHROPIC_MODEL); - assert_eq!(config, json!({})); -} - -/// TS: "should handle tool choice 'tool'" -#[test] -fn prepare_tools_tool_choice_tool() { - let tools = Some(vec![func_tool("testFunction", Some("Test"), json!({}))]); - let config = prepare_tools( - &tools, - &ToolChoice::Tool { - tool_name: "testFunction".to_string(), - }, - NON_ANTHROPIC_MODEL, - ); - assert_eq!( - config["toolChoice"], - json!({ "tool": { "name": "testFunction" } }) - ); -} - -/// TS: "should filter function tools to only the named tool when tool choice is 'tool'" -#[test] -fn prepare_tools_tool_choice_filters_to_named() { - let tools = Some(vec![ - func_tool( - "getWeather", - Some("Get weather"), - json!({ "type": "object" }), - ), - func_tool("getTime", Some("Get time"), json!({ "type": "object" })), - ]); - let config = prepare_tools( - &tools, - &ToolChoice::Tool { - tool_name: "getWeather".to_string(), - }, - NON_ANTHROPIC_MODEL, - ); - assert_eq!(config["tools"].as_array().unwrap().len(), 1); - assert_eq!(config["tools"][0]["toolSpec"]["name"], json!("getWeather")); -} - -/// TS: "should pass through strict mode when strict is true" -#[test] -fn prepare_tools_strict_true() { - let tools = Some(vec![func_tool_strict( - "testFunction", - Some("A test function"), - json!({ "type": "object", "properties": {} }), - Some(true), - )]); - let config = prepare_tools(&tools, &ToolChoice::Auto, ANTHROPIC_MODEL); - assert_eq!( - config["tools"], - json!([{ - "toolSpec": { - "name": "testFunction", - "description": "A test function", - "strict": true, - "inputSchema": { "json": { "type": "object", "properties": {} } } - } - }]) - ); -} - -/// TS: "should pass through strict mode when strict is false" -#[test] -fn prepare_tools_strict_false() { - let tools = Some(vec![func_tool_strict( - "testFunction", - Some("A test function"), - json!({ "type": "object", "properties": {} }), - Some(false), - )]); - let config = prepare_tools(&tools, &ToolChoice::Auto, ANTHROPIC_MODEL); - assert_eq!( - config["tools"], - json!([{ - "toolSpec": { - "name": "testFunction", - "description": "A test function", - "strict": false, - "inputSchema": { "json": { "type": "object", "properties": {} } } - } - }]) - ); -} - -/// TS: "should not include strict when strict is undefined" -#[test] -fn prepare_tools_strict_undefined() { - let tools = Some(vec![func_tool_strict( - "testFunction", - Some("A test function"), - json!({ "type": "object", "properties": {} }), - None, - )]); - let config = prepare_tools(&tools, &ToolChoice::Auto, ANTHROPIC_MODEL); - let spec = &config["tools"][0]["toolSpec"]; - assert!(spec.get("strict").is_none()); -} - -/// TS: "should pass through strict mode for multiple tools with different strict settings" -#[test] -fn prepare_tools_strict_multiple() { - let tools = Some(vec![ - func_tool_strict( - "strictTool", - Some("A strict tool"), - json!({ "type": "object", "properties": {} }), - Some(true), - ), - func_tool_strict( - "nonStrictTool", - Some("A non-strict tool"), - json!({ "type": "object", "properties": {} }), - Some(false), - ), - func_tool_strict( - "defaultTool", - Some("A tool without strict setting"), - json!({ "type": "object", "properties": {} }), - None, - ), - ]); - let config = prepare_tools(&tools, &ToolChoice::Auto, ANTHROPIC_MODEL); - let tools_arr = config["tools"].as_array().unwrap(); - assert_eq!(tools_arr.len(), 3); - assert_eq!(tools_arr[0]["toolSpec"]["strict"], json!(true)); - assert_eq!(tools_arr[1]["toolSpec"]["strict"], json!(false)); - assert!(tools_arr[2]["toolSpec"].get("strict").is_none()); -} - -/// TS: it.each "should omit strict for %s" (claude-opus-5, claude-sonnet-5, -/// claude-fable-5). These models reject newer schema fields, so `strict` is -/// omitted even when set. -#[test] -fn prepare_tools_omit_strict_for_opus_5() { - assert!(!supports_strict_tools("us.anthropic.claude-opus-5")); - let tools = Some(vec![func_tool_strict( - "testFunction", - Some("A test function"), - json!({ "type": "object", "properties": {} }), - Some(true), - )]); - let config = prepare_tools(&tools, &ToolChoice::Auto, "us.anthropic.claude-opus-5"); - assert!(config["tools"][0]["toolSpec"].get("strict").is_none()); -} - -#[test] -fn prepare_tools_omit_strict_for_sonnet_5() { - assert!(!supports_strict_tools("anthropic.claude-sonnet-5")); - let tools = Some(vec![func_tool_strict( - "testFunction", - Some("A test function"), - json!({ "type": "object", "properties": {} }), - Some(true), - )]); - let config = prepare_tools(&tools, &ToolChoice::Auto, "anthropic.claude-sonnet-5"); - assert!(config["tools"][0]["toolSpec"].get("strict").is_none()); -} - -#[test] -fn prepare_tools_omit_strict_for_fable_5() { - assert!(!supports_strict_tools("eu.anthropic.claude-fable-5")); - let tools = Some(vec![func_tool_strict( - "testFunction", - Some("A test function"), - json!({ "type": "object", "properties": {} }), - Some(true), - )]); - let config = prepare_tools(&tools, &ToolChoice::Auto, "eu.anthropic.claude-fable-5"); - assert!(config["tools"][0]["toolSpec"].get("strict").is_none()); -} - -/// TS: "should omit strict for claude-opus-4-7" -#[test] -fn prepare_tools_omit_strict_for_opus_4_7() { - assert!(!supports_strict_tools("us.anthropic.claude-opus-4-7")); - let tools = Some(vec![func_tool_strict( - "testFunction", - Some("A test function"), - json!({ "type": "object", "properties": {} }), - Some(true), - )]); - let config = prepare_tools(&tools, &ToolChoice::Auto, "us.anthropic.claude-opus-4-7"); - assert!(config["tools"][0]["toolSpec"].get("strict").is_none()); -} - -/// TS: "should omit strict for claude-opus-4-8" -#[test] -fn prepare_tools_omit_strict_for_opus_4_8() { - assert!(!supports_strict_tools("anthropic.claude-opus-4-8")); - let tools = Some(vec![func_tool_strict( - "testFunction", - Some("A test function"), - json!({ "type": "object", "properties": {} }), - Some(true), - )]); - let config = prepare_tools(&tools, &ToolChoice::Auto, "anthropic.claude-opus-4-8"); - assert!(config["tools"][0]["toolSpec"].get("strict").is_none()); -} - -// ════════════════════════════════════════════════════════════════════════════ -// convert-amazon-bedrock-usage -// ════════════════════════════════════════════════════════════════════════════ -// -// The TS `convertAmazonBedrockUsage` echoes the input as `result.raw`. The Rust -// `Usage` type has no `raw` field, so only the `inputTokens`/`outputTokens` -// breakdown is asserted; the two `raw`-only cases ("should include totalTokens -// in raw when provided" and "should preserve raw usage data") are SKIPPED. - -fn bedrock_usage( - input: u32, - output: u32, - cache_read: Option, - cache_write: Option, -) -> BedrockUsage { - BedrockUsage { - input_tokens: Some(input), - output_tokens: Some(output), - total_tokens: None, - cache_read_input_tokens: cache_read, - cache_write_input_tokens: cache_write, - } -} - -/// TS: "should convert basic usage without cache tokens" -#[test] -fn usage_basic_without_cache() { - let u = convert_usage(Some(&bedrock_usage(100, 50, None, None))); - assert_eq!(u.input_tokens.total, Some(100)); - assert_eq!(u.input_tokens.no_cache, Some(100)); - assert_eq!(u.input_tokens.cache_read, Some(0)); - assert_eq!(u.input_tokens.cache_write, Some(0)); - assert_eq!(u.output_tokens.total, Some(50)); - assert_eq!(u.output_tokens.text, Some(50)); -} - -/// TS: "should convert usage with cache read tokens" -#[test] -fn usage_with_cache_read() { - let u = convert_usage(Some(&bedrock_usage(100, 50, Some(80), None))); - assert_eq!(u.input_tokens.total, Some(180)); - assert_eq!(u.input_tokens.no_cache, Some(100)); - assert_eq!(u.input_tokens.cache_read, Some(80)); - assert_eq!(u.input_tokens.cache_write, Some(0)); - assert_eq!(u.output_tokens.total, Some(50)); - assert_eq!(u.output_tokens.text, Some(50)); -} - -/// TS: "should convert usage with cache write tokens" -#[test] -fn usage_with_cache_write() { - let u = convert_usage(Some(&bedrock_usage(100, 50, None, Some(60)))); - assert_eq!(u.input_tokens.total, Some(160)); - assert_eq!(u.input_tokens.no_cache, Some(100)); - assert_eq!(u.input_tokens.cache_read, Some(0)); - assert_eq!(u.input_tokens.cache_write, Some(60)); - assert_eq!(u.output_tokens.total, Some(50)); - assert_eq!(u.output_tokens.text, Some(50)); -} - -/// TS: "should convert usage with both cache read and write tokens" -#[test] -fn usage_with_both_cache() { - let u = convert_usage(Some(&bedrock_usage(100, 50, Some(80), Some(60)))); - assert_eq!(u.input_tokens.total, Some(240)); - assert_eq!(u.input_tokens.no_cache, Some(100)); - assert_eq!(u.input_tokens.cache_read, Some(80)); - assert_eq!(u.input_tokens.cache_write, Some(60)); - assert_eq!(u.output_tokens.total, Some(50)); - assert_eq!(u.output_tokens.text, Some(50)); -} - -/// TS: "should handle null cache tokens" -#[test] -fn usage_null_cache_tokens() { - let u = convert_usage(Some(&bedrock_usage(100, 50, None, None))); - assert_eq!(u.input_tokens.total, Some(100)); - assert_eq!(u.input_tokens.no_cache, Some(100)); - assert_eq!(u.input_tokens.cache_read, Some(0)); - assert_eq!(u.input_tokens.cache_write, Some(0)); - assert_eq!(u.output_tokens.total, Some(50)); - assert_eq!(u.output_tokens.text, Some(50)); -} - -/// TS: "should handle null usage" -#[test] -fn usage_null() { - let u = convert_usage(None); - assert_eq!(u.input_tokens.total, None); - assert_eq!(u.input_tokens.no_cache, None); - assert_eq!(u.input_tokens.cache_read, None); - assert_eq!(u.input_tokens.cache_write, None); - assert_eq!(u.output_tokens.total, None); - assert_eq!(u.output_tokens.text, None); -} - -/// TS: "should handle undefined usage" -#[test] -fn usage_undefined() { - let u = convert_usage(None); - assert_eq!(u.input_tokens.total, None); - assert_eq!(u.input_tokens.no_cache, None); - assert_eq!(u.input_tokens.cache_read, None); - assert_eq!(u.input_tokens.cache_write, None); - assert_eq!(u.output_tokens.total, None); - assert_eq!(u.output_tokens.text, None); -} - -// SKIPPED (TS: "should include totalTokens in raw when provided" and "should -// preserve raw usage data"): the Rust `Usage` type has no `raw` echo field. diff --git a/aimux-providers/tests/bedrock_model_test.rs b/aimux-providers/tests/bedrock_model_test.rs deleted file mode 100644 index b45c401f4..000000000 --- a/aimux-providers/tests/bedrock_model_test.rs +++ /dev/null @@ -1,749 +0,0 @@ -//! Wiremock tests for the Amazon Bedrock provider. -//! -//! Tests cover: -//! - Non-streaming text generation (Converse API JSON response) -//! - Non-streaming tool calls -//! - Error handling (HTTP error status) -//! - Streaming via the AWS event stream binary format - -use aimux_core::tool::RawToolCall; -use futures::StreamExt; -use serde_json::{Value, json}; -use wiremock::matchers::{method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -use aimux_core::content::ContentPart; -use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; -use aimux_core::options::{CallOptions, Tool, ToolChoice}; -use aimux_core::result::{GenerateContent, StreamResult}; -use aimux_core::stream_part::StreamPart; -use aimux_core::tool::FunctionTool; -use aimux_core::types::FinishReasonUnified; - -use aimux_providers::bedrock::{BedrockModel, BedrockProviderConfig}; - -// ── Helpers ────────────────────────────────────────────────────────────────── - -fn test_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] -} - -fn default_options(prompt: LanguageModelPrompt) -> CallOptions { - CallOptions::new(prompt) -} - -fn make_model(server: &MockServer) -> BedrockModel { - BedrockModel::new( - "anthropic.claude-3-5-sonnet-20240620-v1:0".to_string(), - aimux_providers::bedrock::BedrockConfig { - base_url: server.uri(), - auth: aimux_providers::bedrock::BedrockAuth::BearerToken("test-token".to_string()), - retry_config: aimux_provider_utils::RetryConfig::default(), - api_key_source: None, - }, - ) -} - -async fn mock_converse_json(server: &MockServer, status: u16, body: Value) { - let model_path = "/model/anthropic.claude-3-5-sonnet-20240620-v1:0/converse"; - Mock::given(method("POST")) - .and(path(model_path)) - .respond_with(ResponseTemplate::new(status).set_body_json(body)) - .mount(server) - .await; -} - -fn as_text(item: &GenerateContent) -> &str { - match item { - GenerateContent::Text { text, .. } => text, - _ => panic!("expected Text content, got {item:?}"), - } -} - -fn as_tool_call(item: &GenerateContent) -> (&str, &str, &str) { - match item { - GenerateContent::ToolCall(RawToolCall { - tool_call_id, - tool_name, - input, - .. - }) => (tool_call_id, tool_name, input), - _ => panic!("expected ToolCall content, got {item:?}"), - } -} - -async fn collect_stream(result: StreamResult) -> Vec { - let mut parts = Vec::new(); - let mut stream = result.stream; - while let Some(part) = stream.next().await { - match part { - Ok(p) => parts.push(p), - Err(e) => panic!("stream error: {e:?}"), - } - } - parts -} - -// ═════════════════════════════════════════════════════════════════════════════ -// doGenerate tests -// ═════════════════════════════════════════════════════════════════════════════ - -/// Test: non-streaming text generation extracts text content and usage. -#[tokio::test] -async fn bedrock_generate_text_response() { - let server = MockServer::start().await; - mock_converse_json( - &server, - 200, - json!({ - "output": { - "message": { - "role": "assistant", - "content": [{ "text": "Hello from Bedrock!" }] - } - }, - "stopReason": "end_turn", - "usage": { - "inputTokens": 10, - "outputTokens": 20, - "totalTokens": 30 - } - }), - ) - .await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!(result.content.len(), 1); - assert_eq!(as_text(&result.content[0]), "Hello from Bedrock!"); - assert_eq!(result.finish_reason.unified, FinishReasonUnified::Stop); - assert_eq!(result.finish_reason.raw.as_deref(), Some("end_turn")); - assert_eq!(result.usage.input_tokens.total, Some(10)); - assert_eq!(result.usage.output_tokens.total, Some(20)); -} - -/// Test: non-streaming tool call extraction. -#[tokio::test] -async fn bedrock_generate_tool_call() { - let server = MockServer::start().await; - mock_converse_json( - &server, - 200, - json!({ - "output": { - "message": { - "role": "assistant", - "content": [ - { "text": "Let me check the weather." }, - { - "toolUse": { - "toolUseId": "tool_use_123", - "name": "getWeather", - "input": { "location": "San Francisco" } - } - } - ] - } - }, - "stopReason": "tool_use", - "usage": { - "inputTokens": 15, - "outputTokens": 25 - } - }), - ) - .await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!(result.content.len(), 2); - assert_eq!(as_text(&result.content[0]), "Let me check the weather."); - - let (id, name, input) = as_tool_call(&result.content[1]); - assert_eq!(id, "tool_use_123"); - assert_eq!(name, "getWeather"); - assert_eq!(input, r#"{"location":"San Francisco"}"#); - assert_eq!(result.finish_reason.unified, FinishReasonUnified::ToolCalls); -} - -/// Test: HTTP error status is propagated as an error. -#[tokio::test] -async fn bedrock_generate_error_status() { - let server = MockServer::start().await; - mock_converse_json( - &server, - 400, - json!({ - "error": { - "message": "The model ID is invalid", - "type": "ValidationException" - } - }), - ) - .await; - - let model = make_model(&server); - let result = model.do_generate(&default_options(test_prompt())).await; - - assert!(result.is_err(), "should return error for 400 status"); - let err = result.unwrap_err().to_string(); - assert!( - err.contains("invalid") || err.contains("ValidationException") || err.contains("model ID"), - "error should contain the error message, got: {err}" - ); -} - -/// Test: request body contains the expected Converse format. -#[tokio::test] -async fn bedrock_generate_request_body() { - let server = MockServer::start().await; - mock_converse_json( - &server, - 200, - json!({ - "output": { - "message": { - "role": "assistant", - "content": [{ "text": "OK" }] - } - }, - "stopReason": "end_turn", - "usage": { "inputTokens": 1, "outputTokens": 1 } - }), - ) - .await; - - let mut opts = default_options(test_prompt()); - opts.max_output_tokens = Some(512); - opts.temperature = Some(0.7); - - let model = make_model(&server); - let result = model - .do_generate(&opts) - .await - .expect("do_generate should succeed"); - - let body = result.request_body.expect("should have request body"); - assert_eq!(body["messages"][0]["role"], "user"); - assert_eq!(body["messages"][0]["content"][0]["text"], "Hello"); - assert_eq!(body["inferenceConfig"]["maxTokens"], 512); - // temperature is f32, compare with tolerance - let temp = body["inferenceConfig"]["temperature"].as_f64().unwrap(); - assert!( - (temp - 0.7).abs() < 0.001, - "temperature should be ~0.7, got {temp}" - ); -} - -// ═════════════════════════════════════════════════════════════════════════════ -// doStream tests (AWS event stream binary format) -// ═════════════════════════════════════════════════════════════════════════════ - -/// Test: streaming text via the Bedrock event stream binary format. -#[tokio::test] -async fn bedrock_stream_text() { - let server = MockServer::start().await; - - // Build the binary event stream response. - let events: Vec<(&str, &str, &str)> = vec![ - ("event", "messageStart", r#"{"role":"assistant"}"#), - ( - "event", - "contentBlockStart", - r#"{"contentBlockIndex":0,"start":{}}"#, - ), - ( - "event", - "contentBlockDelta", - r#"{"contentBlockIndex":0,"delta":{"text":"Hello"}}"#, - ), - ( - "event", - "contentBlockDelta", - r#"{"contentBlockIndex":0,"delta":{"text":" world"}}"#, - ), - ("event", "contentBlockStop", r#"{"contentBlockIndex":0}"#), - ("event", "messageStop", r#"{"stopReason":"end_turn"}"#), - ( - "event", - "metadata", - r#"{"usage":{"inputTokens":5,"outputTokens":10}}"#, - ), - ]; - let body_bytes = aimux_providers::bedrock::event_stream::encode_messages(&events); - - let model_path = "/model/anthropic.claude-3-5-sonnet-20240620-v1:0/converse-stream"; - Mock::given(method("POST")) - .and(path(model_path)) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "application/vnd.amazon.eventstream") - .set_body_raw(body_bytes, "application/vnd.amazon.eventstream"), - ) - .mount(&server) - .await; - - let model = make_model(&server); - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - - let parts = collect_stream(result).await; - - // Verify stream parts: StreamStart, ResponseMetadata, TextStart, TextDelta×2, TextEnd, Finish - let text_deltas: Vec = parts - .iter() - .filter_map(|p| match p { - StreamPart::TextDelta { delta, .. } => Some(delta.clone()), - _ => None, - }) - .collect(); - assert_eq!(text_deltas, vec!["Hello", " world"]); - - // Check finish - let finish = parts.iter().find_map(|p| match p { - StreamPart::Finish { finish_reason, .. } => Some(finish_reason.clone()), - _ => None, - }); - assert!(finish.is_some()); - assert_eq!(finish.unwrap().unified, FinishReasonUnified::Stop); -} - -/// Test: streaming tool calls via the Bedrock event stream. -#[tokio::test] -async fn bedrock_stream_tool_call() { - let server = MockServer::start().await; - - let events: Vec<(&str, &str, &str)> = vec![ - ("event", "messageStart", r#"{"role":"assistant"}"#), - ( - "event", - "contentBlockStart", - r#"{"contentBlockIndex":0,"start":{"toolUse":{"name":"getWeather","toolUseId":"tool_1"}}}"#, - ), - ( - "event", - "contentBlockDelta", - r#"{"contentBlockIndex":0,"delta":{"toolUse":{"input":"{\"location\":"}}}"#, - ), - ( - "event", - "contentBlockDelta", - r#"{"contentBlockIndex":0,"delta":{"toolUse":{"input":"\"SF\"}"}}}"#, - ), - ("event", "contentBlockStop", r#"{"contentBlockIndex":0}"#), - ("event", "messageStop", r#"{"stopReason":"tool_use"}"#), - ]; - let body_bytes = aimux_providers::bedrock::event_stream::encode_messages(&events); - - let model_path = "/model/anthropic.claude-3-5-sonnet-20240620-v1:0/converse-stream"; - Mock::given(method("POST")) - .and(path(model_path)) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "application/vnd.amazon.eventstream") - .set_body_raw(body_bytes, "application/vnd.amazon.eventstream"), - ) - .mount(&server) - .await; - - let model = make_model(&server); - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - - let parts = collect_stream(result).await; - - // Should have a ToolCall part - let tool_calls: Vec<_> = parts - .iter() - .filter_map(|p| match p { - StreamPart::ToolCall(RawToolCall { - tool_call_id, - tool_name, - input, - .. - }) => Some((tool_call_id.clone(), tool_name.clone(), input.clone())), - _ => None, - }) - .collect(); - assert_eq!(tool_calls.len(), 1); - assert_eq!(tool_calls[0].0, "tool_1"); - assert_eq!(tool_calls[0].1, "getWeather"); - assert_eq!(tool_calls[0].2, r#"{"location":"SF"}"#); -} - -/// Test: SigV4 authentication adds Authorization header. -#[tokio::test] -async fn bedrock_sigv4_auth() { - let server = MockServer::start().await; - - // The mock path must match the model ID used in the test. - Mock::given(method("POST")) - .and(path("/model/test-model/converse")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "output": { - "message": { - "role": "assistant", - "content": [{ "text": "Signed!" }] - } - }, - "stopReason": "end_turn", - "usage": { "inputTokens": 1, "outputTokens": 1 } - }))) - .mount(&server) - .await; - - let model = BedrockModel::new( - "test-model".to_string(), - aimux_providers::bedrock::BedrockConfig { - base_url: server.uri(), - auth: aimux_providers::bedrock::BedrockAuth::SigV4( - aimux_providers::bedrock::AwsCredentials { - access_key_id: "AKIAIOSFODNN7EXAMPLE".to_string(), - secret_access_key: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".to_string(), - session_token: None, - region: "us-east-1".to_string(), - }, - ), - retry_config: aimux_provider_utils::RetryConfig::default(), - api_key_source: None, - }, - ); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed with SigV4"); - - assert_eq!(as_text(&result.content[0]), "Signed!"); -} - -/// Test: provider config from_env with bearer token. -#[tokio::test] -async fn bedrock_provider_config_bearer() { - unsafe { - std::env::set_var("AWS_BEARER_TOKEN_BEDROCK", "test-bearer-token"); - std::env::set_var("AWS_REGION", "us-west-2"); - } - - let config = BedrockProviderConfig::from_env().expect("should create config"); - match config.auth { - aimux_providers::bedrock::BedrockAuth::BearerToken(t) => { - assert_eq!(t, "test-bearer-token"); - } - _ => panic!("expected BearerToken auth"), - } - assert!(config.base_url.contains("us-west-2")); - - unsafe { - std::env::remove_var("AWS_BEARER_TOKEN_BEDROCK"); - std::env::remove_var("AWS_REGION"); - } -} - -// ═════════════════════════════════════════════════════════════════════════════ -// Additional doGenerate cases — usage, metadata, headers, finish reasons, -// tool choice, settings. -// ═════════════════════════════════════════════════════════════════════════════ - -/// A minimal "ok" Converse response body. -fn ok_converse_body() -> Value { - json!({ - "output": { - "message": { - "role": "assistant", - "content": [{ "text": "ok" }] - } - }, - "stopReason": "end_turn", - "usage": { "inputTokens": 4, "outputTokens": 7, "totalTokens": 11 } - }) -} - -/// TS: "should extract usage" -#[tokio::test] -async fn bedrock_generate_usage() { - let server = MockServer::start().await; - mock_converse_json(&server, 200, ok_converse_body()).await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - assert_eq!(result.usage.input_tokens.total, Some(4)); - assert_eq!(result.usage.output_tokens.total, Some(7)); -} - -/// TS: "should send additional response information" — the x-amzn-requestid -/// response header becomes `response.id`. -#[tokio::test] -async fn bedrock_generate_response_metadata() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path( - "/model/anthropic.claude-3-5-sonnet-20240620-v1:0/converse", - )) - .respond_with( - ResponseTemplate::new(200) - .insert_header("x-amzn-requestid", "req-abc-123") - .set_body_json(ok_converse_body()), - ) - .mount(&server) - .await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - assert_eq!(result.response.id.as_deref(), Some("req-abc-123")); - assert_eq!( - result.response.model_id.as_deref(), - Some("anthropic.claude-3-5-sonnet-20240620-v1:0") - ); -} - -/// TS: "should expose the raw response headers" -#[tokio::test] -async fn bedrock_generate_response_headers() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path( - "/model/anthropic.claude-3-5-sonnet-20240620-v1:0/converse", - )) - .respond_with( - ResponseTemplate::new(200) - .insert_header("test-header", "test-value") - .set_body_json(ok_converse_body()), - ) - .mount(&server) - .await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - let headers = result - .response_headers - .as_ref() - .expect("response_headers should be Some"); - assert_eq!(headers.get("test-header"), Some(&"test-value".to_string())); -} - -/// TS: "should extract finish reason" — stop_sequence → Stop -#[tokio::test] -async fn bedrock_generate_finish_reason_stop_sequence() { - let server = MockServer::start().await; - mock_converse_json( - &server, - 200, - json!({ - "output": { "message": { "role": "assistant", "content": [{ "text": "hi" }] } }, - "stopReason": "stop_sequence", - "usage": { "inputTokens": 4, "outputTokens": 34 } - }), - ) - .await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - assert_eq!(result.finish_reason.unified, FinishReasonUnified::Stop); - assert_eq!(result.finish_reason.raw.as_deref(), Some("stop_sequence")); -} - -/// TS: max_tokens → Length -#[tokio::test] -async fn bedrock_generate_finish_reason_max_tokens() { - let server = MockServer::start().await; - mock_converse_json( - &server, - 200, - json!({ - "output": { "message": { "role": "assistant", "content": [{ "text": "truncated" }] } }, - "stopReason": "max_tokens", - "usage": { "inputTokens": 4, "outputTokens": 34 } - }), - ) - .await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - assert_eq!(result.finish_reason.unified, FinishReasonUnified::Length); - assert_eq!(result.finish_reason.raw.as_deref(), Some("max_tokens")); -} - -/// TS: content_filtered → ContentFilter -#[tokio::test] -async fn bedrock_generate_finish_reason_content_filtered() { - let server = MockServer::start().await; - mock_converse_json( - &server, - 200, - json!({ - "output": { "message": { "role": "assistant", "content": [{ "text": "" }] } }, - "stopReason": "content_filtered", - "usage": { "inputTokens": 4, "outputTokens": 1 } - }), - ) - .await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - assert_eq!( - result.finish_reason.unified, - FinishReasonUnified::ContentFilter - ); -} - -/// TS: "should support unknown finish reason" — unknown → Other -#[tokio::test] -async fn bedrock_generate_finish_reason_unknown() { - let server = MockServer::start().await; - mock_converse_json( - &server, - 200, - json!({ - "output": { "message": { "role": "assistant", "content": [{ "text": "hi" }] } }, - "stopReason": "eos", - "usage": { "inputTokens": 4, "outputTokens": 34 } - }), - ) - .await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - assert_eq!(result.finish_reason.unified, FinishReasonUnified::Other); - assert_eq!(result.finish_reason.raw.as_deref(), Some("eos")); -} - -/// TS: "should pass settings" — topP, topK, stopSequences land in inferenceConfig. -#[tokio::test] -async fn bedrock_generate_settings() { - let server = MockServer::start().await; - mock_converse_json(&server, 200, ok_converse_body()).await; - - let model = make_model(&server); - let mut opts = default_options(test_prompt()); - opts.top_p = Some(0.9); - opts.top_k = Some(40.0); - opts.stop_sequences = Some(vec!["END".to_string()]); - - let result = model.do_generate(&opts).await.expect("should succeed"); - let body = result.request_body.expect("body"); - assert!((body["inferenceConfig"]["topP"].as_f64().unwrap() - 0.9).abs() < 1e-6); - assert_eq!(body["inferenceConfig"]["topK"], json!(40.0)); - assert_eq!(body["inferenceConfig"]["stopSequences"], json!(["END"])); -} - -/// TS: "should pass tools and tool choice correctly" — required → {"any":{}} -#[tokio::test] -async fn bedrock_generate_tool_choice_required() { - let server = MockServer::start().await; - mock_converse_json(&server, 200, ok_converse_body()).await; - - let model = make_model(&server); - let opts = CallOptions { - tools: Some(vec![Tool::Function( - FunctionTool::new( - "get-weather".to_string(), - json!({"type":"object","properties":{"city":{"type":"string"}}}), - ) - .with_description("Get weather".to_string()), - )]), - tool_choice: ToolChoice::Required, - ..default_options(test_prompt()) - }; - - let result = model.do_generate(&opts).await.expect("should succeed"); - let body = result.request_body.expect("body"); - assert_eq!(body["toolConfig"]["toolChoice"], json!({ "any": {} })); - assert_eq!( - body["toolConfig"]["tools"][0]["toolSpec"]["name"], - "get-weather" - ); -} - -/// TS: "should only send the forced tool when toolChoice specifies a specific tool" -#[tokio::test] -async fn bedrock_generate_tool_choice_specific_tool() { - let server = MockServer::start().await; - mock_converse_json(&server, 200, ok_converse_body()).await; - - let model = make_model(&server); - let opts = CallOptions { - tools: Some(vec![Tool::Function(FunctionTool::new( - "get-weather".to_string(), - json!({"type":"object"}), - ))]), - tool_choice: ToolChoice::Tool { - tool_name: "get-weather".to_string(), - }, - ..default_options(test_prompt()) - }; - - let result = model.do_generate(&opts).await.expect("should succeed"); - let body = result.request_body.expect("body"); - assert_eq!( - body["toolConfig"]["toolChoice"], - json!({ "tool": { "name": "get-weather" } }) - ); - // Only the forced tool is sent. - assert_eq!(body["toolConfig"]["tools"].as_array().unwrap().len(), 1); -} - -/// TS: a 429/throttling response maps to `AiMuxError::ApiCall` (429 in `status_code`). -#[tokio::test] -async fn bedrock_generate_throttling_error() { - let server = MockServer::start().await; - mock_converse_json( - &server, - 429, - json!({ "message": "throttlingException", "type": "TooManyRequestsException" }), - ) - .await; - - let model = make_model(&server); - let result = model.do_generate(&default_options(test_prompt())).await; - assert!( - matches!(result, Err(ref e) if e.status_code() == Some(429)), - "expected RateLimited, got {result:?}" - ); -} diff --git a/aimux-providers/tests/bedrock_reasoning_test.rs b/aimux-providers/tests/bedrock_reasoning_test.rs deleted file mode 100644 index e5a6f8266..000000000 --- a/aimux-providers/tests/bedrock_reasoning_test.rs +++ /dev/null @@ -1,1357 +0,0 @@ -//! Bedrock reasoning / thinking tests, translated from the TypeScript suite. -//! -//! Translation sources (under `reference/ai/packages/amazon-bedrock/src/`): -//! - `amazon-bedrock-chat-language-model.test.ts` -//! - `describe('reasoning')` under `doGenerate` — "should extract reasoning -//! and text response" (fixture `amazon-bedrock-reasoning.json`). -//! - `describe('reasoning')` under `doStream` — "should stream reasoning and -//! text parts" (fixture `amazon-bedrock-reasoning.chunks.txt`). -//! - "should stream reasoning text deltas" (inline chunks). -//! - "should extract reasoning text with signature" / "…without signature". -//! - "should preserve empty text blocks between reasoning blocks". -//! - "should extract redacted reasoning". -//! - "should handle multiple reasoning blocks". -//! - "should transform reasoningConfig to thinking in stream requests". -//! - "merges user additionalModelRequestFields with derived thinking (stream)". -//! - `convert-to-amazon-bedrock-chat-messages.test.ts` — the reasoning -//! `it(...)` cases (prompt-side replay of signed/unsigned reasoning). -//! -//! Tests that require a data model the Rust crate does not expose are marked -//! `#[ignore]` with an inline reason: -//! - **Redacted reasoning replay** — `ContentPart::Reasoning` has no -//! `redactedData` field and `convert_prompt_to_bedrock` has no -//! `redactedReasoning` branch. -//! - **Foreign-provider reasoning replay** — `ContentPart::Reasoning.signature` -//! is provider-agnostic; the converter cannot distinguish an `anthropic` -//! signature (which must be dropped) from a `bedrock` one (which is kept). - -use futures::StreamExt; -use serde_json::{Value, json}; -use wiremock::matchers::{method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -use aimux_core::content::ContentPart; -use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; -use aimux_core::options::CallOptions; -use aimux_core::result::{GenerateContent, ReasoningOutput, StreamResult}; -use aimux_core::shared::{SharedProviderOptions, provider_namespace}; -use aimux_core::stream_part::StreamPart; -use aimux_core::types::{FinishReasonUnified, ProviderMetadata}; - -use aimux_providers::bedrock::convert::convert_prompt_to_bedrock; -use aimux_providers::bedrock::event_stream; -use aimux_providers::bedrock::{BedrockAuth, BedrockConfig, BedrockModel}; - -// ── Constants ──────────────────────────────────────────────────────────────── - -/// Model id used by the TS reasoning tests (matches the snapshot `modelId`). -const MODEL_ID: &str = "anthropic.claude-3-haiku-20240307-v1:0"; - -/// Signature on the `amazon-bedrock-reasoning.json` fixture's reasoning block. -const REASONING_SIGNATURE: &str = "EvYBCkgICxABGAIqQA9E9VC377UnbjdfXCw4RwQaaIXsqocZKzI3WwWtXT/wBjzAkBOfIiVkP/mhvsPGb5oQW/2j+E1tZcNFoXIb26YSDBTSbDrbHwXJoAl3/hoMA/Qo4rIHvObP/yBrIjAT+l69mh9k7evFO+w+ransSD5YGq4hYrieOyJH3k/zHv8nBJwiz3Nlrb3jNFi2Ib0qXJH7BIK0hR85yYIoccReoJUpeLEMxeWbsO6f0EpOSMSi3YDLd1U9NAw7Itjeay1fEBPHwvawt2M4e/rn52SsuEz3p2DfOZm+N3bnL63rg0Cb9lFcp3k5kuU1h6hcGAE="; - -/// Signature carried by the streaming reasoning fixture's final delta. -const STREAM_SIGNATURE: &str = "Ep0CCkgICxABGAIqQOWPB6/PmA5SW9jC6FvaNq3E+U9ev4FMWcFWuAho+VGLCtazKc5WDjQ5i0MuxsY0o5pKDSVWVKii8KJDusXH4eASDK7jyzuk8iij7fJNihoMxHO9haYzt48R36HVIjCb/EmIFrJLIXShqN6DN//T6vZBtO9qj1QhNWJa3CGm8VZoq80S2/Ok4U0aVaIDiZcqggHC2b8BHuv8BHZrmsR0wjU1ynansBGMdfjnG+iIv8R5lPpRmYGhSVwNybwP3aQZ6o8Dr48Rau8TJfdsArW+r7bvL7bPs4f5nnlp2vG7WkMzWwABHK3fdM44zZ1GZQaWyECNWR2GfY6dXiklo94vgpFTPuZ97mfiN3LY6uYyBwL8RkDaGAE="; - -// ── Helpers ────────────────────────────────────────────────────────────────── - -/// TS `TEST_PROMPT`: a system message + a user "Hello" message. -fn test_prompt() -> LanguageModelPrompt { - vec![ - LanguageModelPromptMessage { - role: Role::System, - content: vec![ContentPart::text("System Prompt")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }, - ] -} - -fn default_options(prompt: LanguageModelPrompt) -> CallOptions { - CallOptions::new(prompt) -} - -fn make_model(server: &MockServer) -> BedrockModel { - BedrockModel::new( - MODEL_ID.to_string(), - BedrockConfig { - base_url: server.uri(), - auth: BedrockAuth::BearerToken("test-token".to_string()), - retry_config: aimux_provider_utils::RetryConfig::default(), - api_key_source: None, - }, - ) -} - -/// Wrap a `bedrock`-keyed provider-options value for `CallOptions.provider_options`. -fn bedrock_provider_options(value: Value) -> Option { - Some(provider_namespace("bedrock", value)) -} - -/// Mock the non-streaming `/converse` endpoint with a JSON body (HTTP 200). -async fn mock_converse(server: &MockServer, body: Value) { - let model_path = format!("/model/{MODEL_ID}/converse"); - Mock::given(method("POST")) - .and(path(model_path.as_str())) - .respond_with(ResponseTemplate::new(200).set_body_json(body)) - .mount(server) - .await; -} - -/// Encode a sequence of `(message-type, event-type, payload)` events into the -/// AWS event stream binary format used by `/converse-stream`. -fn encode_events(events: &[(&str, &str, Value)]) -> Vec { - let mut buf = Vec::new(); - for (mt, et, payload) in events { - buf.extend_from_slice(&event_stream::encode_message(mt, et, &payload.to_string())); - } - buf -} - -/// Mock the streaming `/converse-stream` endpoint with the given events. -async fn mock_stream(server: &MockServer, events: &[(&str, &str, Value)]) { - let body_bytes = encode_events(events); - let model_path = format!("/model/{MODEL_ID}/converse-stream"); - Mock::given(method("POST")) - .and(path(model_path.as_str())) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "application/vnd.amazon.eventstream") - .set_body_raw(body_bytes, "application/vnd.amazon.eventstream"), - ) - .mount(server) - .await; -} - -async fn collect_stream(result: StreamResult) -> Vec { - let mut parts = Vec::new(); - let mut stream = result.stream; - while let Some(part) = stream.next().await { - match part { - Ok(p) => parts.push(p), - Err(e) => panic!("stream error: {e:?}"), - } - } - parts -} - -fn as_text(item: &GenerateContent) -> &str { - match item { - GenerateContent::Text { text, .. } => text, - _ => panic!("expected Text content, got {item:?}"), - } -} - -fn as_reasoning(item: &GenerateContent) -> (&str, &Option) { - match item { - GenerateContent::Reasoning(ReasoningOutput { - text, - provider_metadata, - }) => (text.as_str(), provider_metadata), - _ => panic!("expected Reasoning content, got {item:?}"), - } -} - -fn reasoning_deltas(parts: &[StreamPart]) -> Vec { - parts - .iter() - .filter_map(|p| match p { - StreamPart::ReasoningDelta { delta, .. } => Some(delta.clone()), - _ => None, - }) - .collect() -} - -/// Reasoning deltas that carry actual text, i.e. excluding the zero-length -/// deltas that only transport a `signature` / `redactedData` in -/// `provider_metadata` (upstream emits those with `delta: ''`). -fn reasoning_text_deltas(parts: &[StreamPart]) -> Vec { - reasoning_deltas(parts) - .into_iter() - .filter(|d| !d.is_empty()) - .collect() -} - -/// The `signature` values carried on reasoning-block ends, in emission order. -/// The signature arrives on a trailing text-less delta and is attached to the -/// concluding `ReasoningEnd`. -fn reasoning_signatures(parts: &[StreamPart]) -> Vec { - parts - .iter() - .filter_map(|p| match p { - StreamPart::ReasoningEnd { - provider_metadata: Some(pm), - .. - } => pm - .get("amazonBedrock") - .and_then(|b| b.get("signature")) - .and_then(|s| s.as_str()) - .map(std::string::ToString::to_string), - _ => None, - }) - .collect() -} - -fn text_deltas(parts: &[StreamPart]) -> Vec { - parts - .iter() - .filter_map(|p| match p { - StreamPart::TextDelta { delta, .. } => Some(delta.clone()), - _ => None, - }) - .collect() -} - -fn has_reasoning_start(parts: &[StreamPart]) -> bool { - parts - .iter() - .any(|p| matches!(p, StreamPart::ReasoningStart { .. })) -} - -fn has_reasoning_end(parts: &[StreamPart]) -> bool { - parts - .iter() - .any(|p| matches!(p, StreamPart::ReasoningEnd { .. })) -} - -// ── convert helpers (prompt-side) ──────────────────────────────────────────── - -fn msg(role: Role, content: Vec) -> LanguageModelPromptMessage { - LanguageModelPromptMessage { - role, - content, - provider_options: None, - } -} - -fn user(content: Vec) -> LanguageModelPromptMessage { - msg(Role::User, content) -} - -fn assistant(content: Vec) -> LanguageModelPromptMessage { - msg(Role::Assistant, content) -} - -fn tool_msg(content: Vec) -> LanguageModelPromptMessage { - msg(Role::Tool, content) -} - -fn reasoning(text: &str, signature: Option<&str>) -> ContentPart { - ContentPart::Reasoning { - text: text.to_string(), - signature: signature.map(std::string::ToString::to_string), - provider_options: None, - } -} - -fn tool_call(id: &str, name: &str, input: Value) -> ContentPart { - ContentPart::tool_call(id.to_string(), name.to_string(), input) -} - -fn tool_result(id: &str, output: Value) -> ContentPart { - ContentPart::tool_result(id.to_string(), output) -} - -// ════════════════════════════════════════════════════════════════════════════ -// doGenerate — reasoning response extraction (TDD red until implemented) -// ════════════════════════════════════════════════════════════════════════════ - -/// TS: `doGenerate > reasoning > should extract reasoning and text response`. -/// -/// Uses the `amazon-bedrock-reasoning.json` fixture: an assistant message with -/// a `reasoningContent.reasoningText` block (text + signature) followed by a -/// `text` block. The model should emit `Reasoning` then `Text`. -#[tokio::test] -async fn bedrock_generate_reasoning_response() { - let server = MockServer::start().await; - mock_converse( - &server, - json!({ - "metrics": { "latencyMs": 2202 }, - "output": { - "message": { - "role": "assistant", - "content": [ - { - "reasoningContent": { - "reasoningText": { - "signature": REASONING_SIGNATURE, - "text": "Let me count the r's in \"strawberry\":\n\ns-t-r-a-w-b-e-r-r-y\n\nThere are 3 r's." - } - } - }, - { - "text": "There are **3** r's in \"strawberry\":\n\n1. st**r**awbe**r****r**y" - } - ] - } - }, - "stopReason": "end_turn", - "usage": { - "cacheReadInputTokenCount": 0, - "cacheReadInputTokens": 0, - "cacheWriteInputTokenCount": 0, - "cacheWriteInputTokens": 0, - "inputTokens": 51, - "outputTokens": 78, - "serverToolUsage": {}, - "totalTokens": 129 - } - }), - ) - .await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - // content[0] — reasoning with signature under both `amazonBedrock` and - // `bedrock` provider-metadata keys. - assert_eq!(result.content.len(), 2, "expected reasoning + text content"); - let (rtext, rmeta) = as_reasoning(&result.content[0]); - assert_eq!( - rtext, - "Let me count the r's in \"strawberry\":\n\ns-t-r-a-w-b-e-r-r-y\n\nThere are 3 r's." - ); - let rmeta = rmeta - .as_ref() - .expect("reasoning provider_metadata should be set"); - assert_eq!(rmeta["amazonBedrock"]["signature"], REASONING_SIGNATURE); - assert_eq!(rmeta["bedrock"]["signature"], REASONING_SIGNATURE); - - // content[1] — text. - assert_eq!( - as_text(&result.content[1]), - "There are **3** r's in \"strawberry\":\n\n1. st**r**awbe**r****r**y" - ); - - assert_eq!(result.finish_reason.unified, FinishReasonUnified::Stop); - assert_eq!(result.finish_reason.raw.as_deref(), Some("end_turn")); - assert_eq!(result.usage.input_tokens.total, Some(51)); - assert_eq!(result.usage.output_tokens.total, Some(78)); -} - -/// TS: "should extract reasoning text with signature". -#[tokio::test] -async fn bedrock_generate_reasoning_text_with_signature() { - let server = MockServer::start().await; - mock_converse( - &server, - json!({ - "output": { - "message": { - "role": "assistant", - "content": [ - { - "reasoningContent": { - "reasoningText": { - "text": "I need to think about this problem carefully...", - "signature": "abc123signature" - } - } - }, - { "type": "text", "text": "The answer is 42." } - ] - } - }, - "usage": { "inputTokens": 4, "outputTokens": 34, "totalTokens": 38 }, - "stopReason": "stop_sequence" - }), - ) - .await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!(result.content.len(), 2); - let (rtext, rmeta) = as_reasoning(&result.content[0]); - assert_eq!(rtext, "I need to think about this problem carefully..."); - let rmeta = rmeta.as_ref().expect("provider_metadata should be set"); - assert_eq!(rmeta["amazonBedrock"]["signature"], "abc123signature"); - assert_eq!(rmeta["bedrock"]["signature"], "abc123signature"); - - assert_eq!(as_text(&result.content[1]), "The answer is 42."); -} - -/// TS: "should preserve empty text blocks between reasoning blocks". -/// -/// The empty `{ text: "" }` block sitting between two reasoning blocks must be -/// preserved as a `Text` content item (not dropped). -#[tokio::test] -async fn bedrock_generate_reasoning_preserve_empty_text_between_reasoning() { - let server = MockServer::start().await; - mock_converse( - &server, - json!({ - "output": { - "message": { - "role": "assistant", - "content": [ - { "reasoningContent": { "reasoningText": { "text": "thinking...", "signature": "sig-1" } } }, - { "text": "" }, - { "reasoningContent": { "reasoningText": { "text": "more thinking...", "signature": "sig-2" } } }, - { "text": "The answer is 42." } - ] - } - }, - "usage": { "inputTokens": 4, "outputTokens": 34, "totalTokens": 38 }, - "stopReason": "stop_sequence" - }), - ) - .await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!(result.content.len(), 4); - let (t0, m0) = as_reasoning(&result.content[0]); - assert_eq!(t0, "thinking..."); - assert_eq!(m0.as_ref().unwrap()["bedrock"]["signature"], "sig-1"); - - assert_eq!(as_text(&result.content[1]), ""); - - let (t2, m2) = as_reasoning(&result.content[2]); - assert_eq!(t2, "more thinking..."); - assert_eq!(m2.as_ref().unwrap()["bedrock"]["signature"], "sig-2"); - - assert_eq!(as_text(&result.content[3]), "The answer is 42."); -} - -/// TS: "should extract reasoning text without signature". -/// -/// A `reasoningText` block with no `signature` yields a `Reasoning` content item -/// with no provider metadata. -#[tokio::test] -async fn bedrock_generate_reasoning_text_without_signature() { - let server = MockServer::start().await; - mock_converse( - &server, - json!({ - "output": { - "message": { - "role": "assistant", - "content": [ - { "reasoningContent": { "reasoningText": { "text": "I need to think about this problem carefully..." } } }, - { "type": "text", "text": "The answer is 42." } - ] - } - }, - "usage": { "inputTokens": 4, "outputTokens": 34, "totalTokens": 38 }, - "stopReason": "stop_sequence" - }), - ) - .await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!(result.content.len(), 2); - let (rtext, rmeta) = as_reasoning(&result.content[0]); - assert_eq!(rtext, "I need to think about this problem carefully..."); - assert!( - rmeta.is_none(), - "reasoning without signature should have no provider_metadata" - ); - assert_eq!(as_text(&result.content[1]), "The answer is 42."); -} - -/// TS: "should extract redacted reasoning". -/// -/// A `redactedReasoning` block yields a `Reasoning` content item with empty text -/// and `redactedData` under both `amazonBedrock` and `bedrock` provider metadata. -#[tokio::test] -async fn bedrock_generate_reasoning_redacted() { - let server = MockServer::start().await; - mock_converse( - &server, - json!({ - "output": { - "message": { - "role": "assistant", - "content": [ - { "reasoningContent": { "redactedReasoning": { "data": "redacted-reasoning-data" } } }, - { "type": "text", "text": "The answer is 42." } - ] - } - }, - "usage": { "inputTokens": 4, "outputTokens": 34, "totalTokens": 38 }, - "stopReason": "stop_sequence" - }), - ) - .await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!(result.content.len(), 2); - let (rtext, rmeta) = as_reasoning(&result.content[0]); - assert_eq!(rtext, ""); - let rmeta = rmeta.as_ref().expect("provider_metadata should be set"); - assert_eq!( - rmeta["amazonBedrock"]["redactedData"], - "redacted-reasoning-data" - ); - assert_eq!(rmeta["bedrock"]["redactedData"], "redacted-reasoning-data"); - assert_eq!(as_text(&result.content[1]), "The answer is 42."); -} - -/// TS: "should handle multiple reasoning blocks". -/// -/// A signed `reasoningText` block followed by a `redactedReasoning` block then -/// text — three content items in order. -#[tokio::test] -async fn bedrock_generate_reasoning_multiple_blocks() { - let server = MockServer::start().await; - mock_converse( - &server, - json!({ - "output": { - "message": { - "role": "assistant", - "content": [ - { "reasoningContent": { "reasoningText": { "text": "First reasoning block", "signature": "sig1" } } }, - { "reasoningContent": { "redactedReasoning": { "data": "redacted-data" } } }, - { "type": "text", "text": "The answer is 42." } - ] - } - }, - "usage": { "inputTokens": 4, "outputTokens": 34, "totalTokens": 38 }, - "stopReason": "stop_sequence" - }), - ) - .await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!(result.content.len(), 3); - - let (t0, m0) = as_reasoning(&result.content[0]); - assert_eq!(t0, "First reasoning block"); - assert_eq!(m0.as_ref().unwrap()["bedrock"]["signature"], "sig1"); - - let (t1, m1) = as_reasoning(&result.content[1]); - assert_eq!(t1, ""); - assert_eq!( - m1.as_ref().unwrap()["bedrock"]["redactedData"], - "redacted-data" - ); - - assert_eq!(as_text(&result.content[2]), "The answer is 42."); -} - -// ════════════════════════════════════════════════════════════════════════════ -// doStream — reasoning streaming (TDD red until implemented) -// ════════════════════════════════════════════════════════════════════════════ - -/// TS: `doStream > reasoning > should stream reasoning and text parts`. -/// -/// Uses the `amazon-bedrock-reasoning.chunks.txt` fixture: a reasoning block -/// (index 0) streamed as `reasoningContent.text` deltas + a final -/// `reasoningContent.signature` delta, then a text block (index 1). -#[tokio::test] -async fn bedrock_stream_reasoning_and_text() { - let server = MockServer::start().await; - - let events: Vec<(&str, &str, Value)> = vec![ - ("event", "messageStart", json!({ "role": "assistant" })), - ( - "event", - "contentBlockDelta", - json!({ "contentBlockIndex": 0, "delta": { "reasoningContent": { "text": "Let me count the r" } } }), - ), - ( - "event", - "contentBlockDelta", - json!({ "contentBlockIndex": 0, "delta": { "reasoningContent": { "text": "'s in \"" } } }), - ), - ( - "event", - "contentBlockDelta", - json!({ "contentBlockIndex": 0, "delta": { "reasoningContent": { "text": "strawberry\":" } } }), - ), - ( - "event", - "contentBlockDelta", - json!({ "contentBlockIndex": 0, "delta": { "reasoningContent": { "text": "\n\ns-t-r-a" } } }), - ), - ( - "event", - "contentBlockDelta", - json!({ "contentBlockIndex": 0, "delta": { "reasoningContent": { "text": "-w-b-e-r" } } }), - ), - ( - "event", - "contentBlockDelta", - json!({ "contentBlockIndex": 0, "delta": { "reasoningContent": { "text": "-r-y\n\nr" } } }), - ), - ( - "event", - "contentBlockDelta", - json!({ "contentBlockIndex": 0, "delta": { "reasoningContent": { "text": " appears at positions " } } }), - ), - ( - "event", - "contentBlockDelta", - json!({ "contentBlockIndex": 0, "delta": { "reasoningContent": { "text": "3, 8" } } }), - ), - ( - "event", - "contentBlockDelta", - json!({ "contentBlockIndex": 0, "delta": { "reasoningContent": { "text": ", and 9.\n\nSo there" } } }), - ), - ( - "event", - "contentBlockDelta", - json!({ "contentBlockIndex": 0, "delta": { "reasoningContent": { "text": " are 3 r's." } } }), - ), - // Empty-text delta — TS skips emitting a reasoning-delta for this. - ( - "event", - "contentBlockDelta", - json!({ "contentBlockIndex": 0, "delta": { "reasoningContent": { "text": "" } } }), - ), - // Signature-only delta — emitted as a reasoning-delta with delta="" - // carrying providerMetadata.signature, matching TS. - ( - "event", - "contentBlockDelta", - json!({ "contentBlockIndex": 0, "delta": { "reasoningContent": { "signature": STREAM_SIGNATURE } } }), - ), - ( - "event", - "contentBlockStop", - json!({ "contentBlockIndex": 0 }), - ), - ( - "event", - "contentBlockDelta", - json!({ "contentBlockIndex": 1, "delta": { "text": "There" } }), - ), - ( - "event", - "contentBlockDelta", - json!({ "contentBlockIndex": 1, "delta": { "text": " are **3** r's in \"" } }), - ), - ( - "event", - "contentBlockDelta", - json!({ "contentBlockIndex": 1, "delta": { "text": "strawberry\":" } }), - ), - ( - "event", - "contentBlockDelta", - json!({ "contentBlockIndex": 1, "delta": { "text": "\n\n1" } }), - ), - ( - "event", - "contentBlockDelta", - json!({ "contentBlockIndex": 1, "delta": { "text": ". st" } }), - ), - ( - "event", - "contentBlockDelta", - json!({ "contentBlockIndex": 1, "delta": { "text": "**" } }), - ), - ( - "event", - "contentBlockDelta", - json!({ "contentBlockIndex": 1, "delta": { "text": "r**awbe" } }), - ), - ( - "event", - "contentBlockDelta", - json!({ "contentBlockIndex": 1, "delta": { "text": "**r****" } }), - ), - ( - "event", - "contentBlockDelta", - json!({ "contentBlockIndex": 1, "delta": { "text": "r**y" } }), - ), - ( - "event", - "contentBlockStop", - json!({ "contentBlockIndex": 1 }), - ), - ( - "event", - "messageStop", - json!({ "additionalModelResponseFields": { "delta": { "stop_sequence": null } }, "stopReason": "end_turn" }), - ), - ( - "event", - "metadata", - json!({ "metrics": { "latencyMs": 2281 }, "usage": { "inputTokens": 51, "outputTokens": 94, "serverToolUsage": {}, "totalTokens": 145 } }), - ), - ]; - mock_stream(&server, &events).await; - - let model = make_model(&server); - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - let parts = collect_stream(result).await; - - // Reasoning block (id "0"): start, non-empty text deltas, end. - assert!(has_reasoning_start(&parts), "expected a ReasoningStart"); - assert_eq!( - reasoning_text_deltas(&parts), - vec![ - "Let me count the r", - "'s in \"", - "strawberry\":", - "\n\ns-t-r-a", - "-w-b-e-r", - "-r-y\n\nr", - " appears at positions ", - "3, 8", - ", and 9.\n\nSo there", - " are 3 r's.", - ] - ); - assert!(has_reasoning_end(&parts), "expected a ReasoningEnd"); - // The signature on the final (text-less) reasoning delta is attached to - // the concluding ReasoningEnd via provider_metadata, so extended-thinking - // multi-turn can echo it back (#113). - let reasoning_end_meta = parts.iter().find_map(|p| match p { - StreamPart::ReasoningEnd { - provider_metadata, .. - } => provider_metadata.clone(), - _ => None, - }); - let reasoning_end_meta = reasoning_end_meta.expect("ReasoningEnd with provider_metadata"); - assert_eq!( - reasoning_end_meta["bedrock"]["signature"].as_str(), - Some(STREAM_SIGNATURE) - ); - // Dual-key shape matches the non-streaming path. - assert_eq!( - reasoning_end_meta["amazonBedrock"]["signature"].as_str(), - Some(STREAM_SIGNATURE) - ); - - // Text block (id "1"). - assert_eq!( - text_deltas(&parts), - vec![ - "There", - " are **3** r's in \"", - "strawberry\":", - "\n\n1", - ". st", - "**", - "r**awbe", - "**r****", - "r**y", - ] - ); - - // Finish. - let finish = parts.iter().find_map(|p| match p { - StreamPart::Finish { finish_reason, .. } => Some(finish_reason.clone()), - _ => None, - }); - let finish = finish.expect("expected a Finish part"); - assert_eq!(finish.unified, FinishReasonUnified::Stop); - assert_eq!(finish.raw.as_deref(), Some("end_turn")); -} - -/// TS: "should stream reasoning text deltas". -/// -/// Inline chunks: two reasoning text deltas, a signature-only delta, then a -/// text delta and `messageStop`. -#[tokio::test] -async fn bedrock_stream_reasoning_text_deltas() { - let server = MockServer::start().await; - let events: Vec<(&str, &str, Value)> = vec![ - ( - "event", - "contentBlockDelta", - json!({ "contentBlockIndex": 0, "delta": { "reasoningContent": { "text": "I am thinking" } } }), - ), - ( - "event", - "contentBlockDelta", - json!({ "contentBlockIndex": 0, "delta": { "reasoningContent": { "text": " about this problem..." } } }), - ), - // Signature-only delta — emitted as a reasoning-delta with delta="" - // carrying providerMetadata.signature, matching TS. - ( - "event", - "contentBlockDelta", - json!({ "contentBlockIndex": 0, "delta": { "reasoningContent": { "signature": "abc123signature" } } }), - ), - ( - "event", - "contentBlockDelta", - json!({ "contentBlockIndex": 1, "delta": { "text": "Based on my reasoning, the answer is 42." } }), - ), - ( - "event", - "messageStop", - json!({ "stopReason": "stop_sequence" }), - ), - ]; - mock_stream(&server, &events).await; - - let model = make_model(&server); - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - let parts = collect_stream(result).await; - - assert!(has_reasoning_start(&parts), "expected a ReasoningStart"); - assert_eq!( - reasoning_text_deltas(&parts), - vec!["I am thinking", " about this problem..."] - ); - assert_eq!( - reasoning_signatures(&parts), - vec!["abc123signature"], - "the thinking-block signature must reach the caller" - ); - assert!(has_reasoning_end(&parts), "expected a ReasoningEnd"); - - assert_eq!( - text_deltas(&parts), - vec!["Based on my reasoning, the answer is 42."] - ); - - let finish = parts.iter().find_map(|p| match p { - StreamPart::Finish { finish_reason, .. } => Some(finish_reason.clone()), - _ => None, - }); - let finish = finish.expect("expected a Finish part"); - assert_eq!(finish.unified, FinishReasonUnified::Stop); - assert_eq!(finish.raw.as_deref(), Some("stop_sequence")); -} - -// ════════════════════════════════════════════════════════════════════════════ -// doStream — request-side reasoningConfig → thinking (TDD red until implemented) -// ════════════════════════════════════════════════════════════════════════════ - -/// TS: "should transform reasoningConfig to thinking in stream requests". -/// -/// `providerOptions.bedrock.reasoningConfig = { type: 'enabled', budgetTokens: -/// 2000 }` plus `maxOutputTokens: 100` must land in the request body as -/// `additionalModelRequestFields.thinking = { type: 'enabled', budget_tokens: -/// 2000 }` and `inferenceConfig.maxTokens = 2100` (100 + 2000), with no -/// top-level `reasoningConfig`. -#[tokio::test] -async fn bedrock_stream_transform_reasoning_config_to_thinking() { - let server = MockServer::start().await; - mock_stream( - &server, - &[( - "event", - "messageStop", - json!({ "stopReason": "stop_sequence" }), - )], - ) - .await; - - let opts = CallOptions { - max_output_tokens: Some(100), - provider_options: bedrock_provider_options(json!({ - "reasoningConfig": { "type": "enabled", "budgetTokens": 2000 } - })), - ..default_options(test_prompt()) - }; - - let model = make_model(&server); - let result = model - .do_stream(&opts) - .await - .expect("do_stream should succeed"); - let body = result.request_body.expect("request body should be present"); - - // Thinking config derived from reasoningConfig. - assert_eq!( - body["additionalModelRequestFields"]["thinking"], - json!({ "type": "enabled", "budget_tokens": 2000 }) - ); - // maxTokens is bumped by budgetTokens (100 + 2000). - assert_eq!(body["inferenceConfig"]["maxTokens"], 2100); - // reasoningConfig must not leak to the top level. - assert!( - body.get("reasoningConfig").is_none(), - "reasoningConfig should not appear at the top level" - ); -} - -/// TS: "merges user additionalModelRequestFields with derived thinking (stream)". -/// -/// User-supplied `additionalModelRequestFields` (`foo`, `custom`) must be -/// merged with the derived `thinking` field, and `reasoningConfig` must not -/// appear at the top level. -#[tokio::test] -async fn bedrock_stream_merge_additional_model_request_fields_with_thinking() { - let server = MockServer::start().await; - mock_stream( - &server, - &[( - "event", - "messageStop", - json!({ "stopReason": "stop_sequence" }), - )], - ) - .await; - - let opts = CallOptions { - provider_options: bedrock_provider_options(json!({ - "reasoningConfig": { "type": "enabled", "budgetTokens": 500 }, - "additionalModelRequestFields": { "foo": "bar", "custom": 42 } - })), - ..default_options(test_prompt()) - }; - - let model = make_model(&server); - let result = model - .do_stream(&opts) - .await - .expect("do_stream should succeed"); - let body = result.request_body.expect("request body should be present"); - - assert!( - body.get("reasoningConfig").is_none(), - "reasoningConfig should not appear at the top level" - ); - assert_eq!(body["additionalModelRequestFields"]["foo"], "bar"); - assert_eq!(body["additionalModelRequestFields"]["custom"], 42); - assert_eq!( - body["additionalModelRequestFields"]["thinking"], - json!({ "type": "enabled", "budget_tokens": 500 }) - ); -} - -// ════════════════════════════════════════════════════════════════════════════ -// convert_prompt_to_bedrock — prompt-side reasoning replay -// (green for signed/unsigned reasoning; #[ignore] where the data model cannot -// express redacted / foreign-provider reasoning) -// ════════════════════════════════════════════════════════════════════════════ - -/// TS: "should properly convert reasoning content type". -/// -/// An assistant reasoning part carrying a `bedrock` signature is replayed as a -/// `reasoningContent.reasoningText { text, signature }` block. -#[test] -fn convert_reasoning_content_type() { - let prompt = vec![ - user(vec![ContentPart::text("Explain your reasoning")]), - assistant(vec![reasoning( - "This is my step-by-step reasoning process", - Some("test-signature"), - )]), - ]; - let (system, messages) = convert_prompt_to_bedrock(&prompt); - - assert_eq!(Value::Array(system), json!([])); - assert_eq!( - Value::Array(messages), - json!([ - { "role": "user", "content": [{ "text": "Explain your reasoning" }] }, - { - "role": "assistant", - "content": [ - { - "reasoningContent": { - "reasoningText": { - "text": "This is my step-by-step reasoning process", - "signature": "test-signature" - } - } - } - ] - } - ]) - ); -} - -/// TS: "should properly convert redacted-reasoning content type". -/// -/// SKIPPED: `ContentPart::Reasoning` has no `redactedData` field and -/// `convert_prompt_to_bedrock` has no `redactedReasoning` branch, so redacted -/// reasoning cannot be replayed on the Bedrock side. The test body documents -/// the intended behaviour (a `reasoningContent.redactedReasoning { data }` -/// block) for when the data model gains support. -#[test] -#[ignore = "redacted reasoning is not modelled on ContentPart::Reasoning / convert"] -fn convert_reasoning_redacted_content_type() { - let prompt = vec![ - user(vec![ContentPart::text("Explain your reasoning")]), - assistant(vec![ContentPart::Reasoning { - text: String::new(), - signature: None, - provider_options: Some(provider_namespace( - "bedrock", - json!({ "redactedData": "Redacted reasoning information" }), - )), - }]), - ]; - let (system, messages) = convert_prompt_to_bedrock(&prompt); - - assert_eq!(Value::Array(system), json!([])); - assert_eq!( - Value::Array(messages), - json!([ - { "role": "user", "content": [{ "text": "Explain your reasoning" }] }, - { - "role": "assistant", - "content": [ - { - "reasoningContent": { - "redactedReasoning": { "data": "Redacted reasoning information" } - } - } - ] - } - ]) - ); -} - -/// TS: "should omit assistant message reasoning parts signed by a foreign -/// provider". -/// -/// SKIPPED: `ContentPart::Reasoning.signature` is provider-agnostic, so the -/// converter cannot distinguish a foreign-provider signature (which must be -/// dropped) from a Bedrock one (which is kept). The intended behaviour is that -/// the reasoning part is omitted and only the trailing text remains. -#[test] -#[ignore = "ContentPart::Reasoning cannot express provider distinction (foreign vs bedrock signature)"] -fn convert_reasoning_foreign_provider_omitted() { - let prompt = vec![ - user(vec![ContentPart::text("Explain your reasoning")]), - assistant(vec![ - // In TS this carries `providerOptions.anthropic.signature` and is - // dropped because it is not a Bedrock signature. - reasoning( - "Anthropic-signed reasoning replayed to Bedrock", - Some("anthropic-signature"), - ), - ContentPart::text("final answer"), - ]), - ]; - let (system, messages) = convert_prompt_to_bedrock(&prompt); - - assert_eq!(Value::Array(system), json!([])); - assert_eq!( - Value::Array(messages), - json!([ - { "role": "user", "content": [{ "text": "Explain your reasoning" }] }, - { "role": "assistant", "content": [{ "text": "final answer" }] } - ]) - ); -} - -/// TS: "should preserve assistant message reasoning parts with amazonBedrock -/// providerOptions". -/// -/// A reasoning part with a Bedrock signature is preserved. (The Rust data model -/// does not distinguish `amazonBedrock` from `bedrock` provider options — the -/// signature is a direct field — but the replay behaviour matches.) -#[test] -fn convert_reasoning_amazon_bedrock_provider_options_preserved() { - let prompt = vec![ - user(vec![ContentPart::text("Explain your reasoning")]), - assistant(vec![ - reasoning( - "Bedrock-signed reasoning round-tripped to Bedrock", - Some("bedrock-signature"), - ), - ContentPart::text("final answer"), - ]), - ]; - let (system, messages) = convert_prompt_to_bedrock(&prompt); - - assert_eq!(Value::Array(system), json!([])); - assert_eq!( - Value::Array(messages), - json!([ - { "role": "user", "content": [{ "text": "Explain your reasoning" }] }, - { - "role": "assistant", - "content": [ - { - "reasoningContent": { - "reasoningText": { - "text": "Bedrock-signed reasoning round-tripped to Bedrock", - "signature": "bedrock-signature" - } - } - }, - { "text": "final answer" } - ] - } - ]) - ); -} - -/// TS: "should not trim reasoning text when a signature is present". -/// -/// Trailing whitespace on reasoning text must be preserved verbatim (only the -/// last *text* part of the last block is trimmed, never reasoning). -#[test] -fn convert_reasoning_no_trim_with_signature() { - let prompt = vec![ - user(vec![ContentPart::text("Explain your reasoning")]), - assistant(vec![reasoning( - "This is my reasoning with trailing space ", - Some("test-signature"), - )]), - ]; - let (system, messages) = convert_prompt_to_bedrock(&prompt); - - assert_eq!(Value::Array(system), json!([])); - assert_eq!( - Value::Array(messages), - json!([ - { "role": "user", "content": [{ "text": "Explain your reasoning" }] }, - { - "role": "assistant", - "content": [ - { - "reasoningContent": { - "reasoningText": { - "text": "This is my reasoning with trailing space ", - "signature": "test-signature" - } - } - } - ] - } - ]) - ); -} - -/// TS: "should omit reasoning content without signature". -/// -/// Unsigned reasoning is dropped; the trailing text is kept. -#[test] -fn convert_reasoning_omit_without_signature() { - let prompt = vec![ - user(vec![ContentPart::text("Explain your reasoning")]), - assistant(vec![ - reasoning("This is my reasoning with trailing space ", None), - ContentPart::text("final answer"), - ]), - ]; - let (system, messages) = convert_prompt_to_bedrock(&prompt); - - assert_eq!(Value::Array(system), json!([])); - assert_eq!( - Value::Array(messages), - json!([ - { "role": "user", "content": [{ "text": "Explain your reasoning" }] }, - { "role": "assistant", "content": [{ "text": "final answer" }] } - ]) - ); -} - -/// TS: "should omit multiple reasoning parts without signatures". -#[test] -fn convert_reasoning_omit_multiple_without_signatures() { - let prompt = vec![ - user(vec![ContentPart::text("Explain your reasoning")]), - assistant(vec![ - reasoning("First reasoning with trailing space ", None), - reasoning("Second reasoning with trailing space ", None), - ContentPart::text("final answer"), - ]), - ]; - let (system, messages) = convert_prompt_to_bedrock(&prompt); - - assert_eq!(Value::Array(system), json!([])); - assert_eq!( - Value::Array(messages), - json!([ - { "role": "user", "content": [{ "text": "Explain your reasoning" }] }, - { "role": "assistant", "content": [{ "text": "final answer" }] } - ]) - ); -} - -/// TS: "should omit unsigned reasoning while preserving tool calls in -/// multi-turn tool use". -#[test] -fn convert_reasoning_omit_unsigned_preserve_tool_calls() { - let prompt = vec![ - user(vec![ContentPart::text("What is the weather?")]), - assistant(vec![ - reasoning("I should call the weather tool.", None), - tool_call("call-1", "getWeather", json!({ "city": "SF" })), - ]), - tool_msg(vec![tool_result( - "call-1", - json!({ "type": "text", "value": "Sunny, 72F" }), - )]), - ]; - let (system, messages) = convert_prompt_to_bedrock(&prompt); - - assert_eq!(Value::Array(system), json!([])); - assert_eq!( - Value::Array(messages), - json!([ - { "role": "user", "content": [{ "text": "What is the weather?" }] }, - { - "role": "assistant", - "content": [ - { - "toolUse": { - "input": { "city": "SF" }, - "name": "getWeather", - "toolUseId": "call-1" - } - } - ] - }, - { - "role": "user", - "content": [ - { - "toolResult": { - "content": [{ "text": "Sunny, 72F" }], - "toolUseId": "call-1" - } - } - ] - } - ]) - ); -} - -/// TS: "should preserve reasoning text with signature in multi-turn tool use". -#[test] -fn convert_reasoning_preserve_signed_in_multi_turn_tool_use() { - let prompt = vec![ - user(vec![ContentPart::text("What is the weather?")]), - assistant(vec![ - reasoning("Let me check the weather API.\n", Some("sig-abc123")), - tool_call("call-1", "getWeather", json!({ "city": "SF" })), - ]), - tool_msg(vec![tool_result( - "call-1", - json!({ "type": "text", "value": "Sunny, 72F" }), - )]), - assistant(vec![ - reasoning("The weather is sunny and warm.\n", Some("sig-def456")), - ContentPart::text("It is sunny and 72F in SF."), - ]), - ]; - let (system, messages) = convert_prompt_to_bedrock(&prompt); - - assert_eq!(Value::Array(system), json!([])); - assert_eq!( - Value::Array(messages), - json!([ - { "role": "user", "content": [{ "text": "What is the weather?" }] }, - { - "role": "assistant", - "content": [ - { - "reasoningContent": { - "reasoningText": { - "text": "Let me check the weather API.\n", - "signature": "sig-abc123" - } - } - }, - { - "toolUse": { - "input": { "city": "SF" }, - "name": "getWeather", - "toolUseId": "call-1" - } - } - ] - }, - { - "role": "user", - "content": [ - { - "toolResult": { - "content": [{ "text": "Sunny, 72F" }], - "toolUseId": "call-1" - } - } - ] - }, - { - "role": "assistant", - "content": [ - { - "reasoningContent": { - "reasoningText": { - "text": "The weather is sunny and warm.\n", - "signature": "sig-def456" - } - } - }, - { "text": "It is sunny and 72F in SF." } - ] - } - ]) - ); -} - -/// TS: "should handle a mix of text and reasoning content types". -#[test] -fn convert_reasoning_mix_text_and_reasoning() { - let prompt = vec![ - user(vec![ContentPart::text("Explain your reasoning")]), - assistant(vec![ - ContentPart::text("My answer is 42."), - reasoning( - "I calculated this by analyzing the meaning of life", - Some("reasoning-process"), - ), - ]), - ]; - let (system, messages) = convert_prompt_to_bedrock(&prompt); - - assert_eq!(Value::Array(system), json!([])); - assert_eq!( - Value::Array(messages), - json!([ - { "role": "user", "content": [{ "text": "Explain your reasoning" }] }, - { - "role": "assistant", - "content": [ - { "text": "My answer is 42." }, - { - "reasoningContent": { - "reasoningText": { - "text": "I calculated this by analyzing the meaning of life", - "signature": "reasoning-process" - } - } - } - ] - } - ]) - ); -} diff --git a/aimux-providers/tests/bedrock_remaining_test.rs b/aimux-providers/tests/bedrock_remaining_test.rs deleted file mode 100644 index 5debaf66e..000000000 --- a/aimux-providers/tests/bedrock_remaining_test.rs +++ /dev/null @@ -1,1359 +0,0 @@ -//! Remaining (not-yet-covered) Amazon Bedrock TS test cases, translated to Rust. -//! -//! Translation sources (under `reference/ai/packages/amazon-bedrock/src/`): -//! - `normalize-tool-call-id.test.ts` — `isMistralModel` / `normalizeToolCallId` -//! (helpers not ported to Rust → `#[ignore]`). -//! - `convert-amazon-bedrock-usage.test.ts` — the two `raw`-echo cases (the -//! Rust `Usage` type has no `raw` field → `#[ignore]`). -//! - `amazon-bedrock-provider.test.ts` — `from_env` auth variants + provider -//! plumbing (green). -//! - `convert-to-amazon-bedrock-chat-messages.test.ts` — Mistral tool-call-id -//! normalization cases (the Rust converter has no `isMistral` parameter → -//! `#[ignore]`). -//! - `amazon-bedrock-chat-language-model.test.ts` — ARN URL encoding, -//! `additionalModelResponseFieldPaths`, temperature clamping, guardrails, -//! `trace` / `stop_sequence` extraction into `providerMetadata`, -//! `supportedUrls`, tool calls with empty input, tool-content filtering. -//! -//! Tests that fail because the Rust implementation does not yet support the -//! feature are marked `#[ignore = "TODO: implementation gap"]`. The test body -//! documents the expected behaviour so it can be flipped on once the gap lands. - -use aimux_core::tool::RawToolCall; -use futures::StreamExt; -use serde_json::{Value, json}; -use serial_test::serial; -use wiremock::matchers::{method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -use aimux_core::content::ContentPart; -use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; -use aimux_core::options::{CallOptions, Tool, ToolChoice}; -use aimux_core::provider::Provider; -use aimux_core::result::GenerateContent; -use aimux_core::stream_part::StreamPart; -use aimux_core::tool::FunctionTool; -use aimux_core::types::FinishReasonUnified; - -use aimux_providers::bedrock::{ - BedrockAuth, BedrockConfig, BedrockModel, BedrockProvider, BedrockProviderConfig, -}; - -// ── Shared helpers ─────────────────────────────────────────────────────────── - -fn test_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] -} - -fn default_options(prompt: LanguageModelPrompt) -> CallOptions { - CallOptions::new(prompt) -} - -fn make_model(server: &MockServer) -> BedrockModel { - BedrockModel::new( - "anthropic.claude-3-5-sonnet-20240620-v1:0".to_string(), - BedrockConfig { - base_url: server.uri(), - auth: BedrockAuth::BearerToken("test-token".to_string()), - retry_config: aimux_provider_utils::RetryConfig::default(), - api_key_source: None, - }, - ) -} - -async fn mock_converse_json(server: &MockServer, status: u16, body: Value) { - let model_path = "/model/anthropic.claude-3-5-sonnet-20240620-v1:0/converse"; - Mock::given(method("POST")) - .and(path(model_path)) - .respond_with(ResponseTemplate::new(status).set_body_json(body)) - .mount(server) - .await; -} - -fn ok_converse_body() -> Value { - json!({ - "output": { - "message": { - "role": "assistant", - "content": [{ "text": "ok" }] - } - }, - "stopReason": "end_turn", - "usage": { "inputTokens": 4, "outputTokens": 7, "totalTokens": 11 } - }) -} - -fn as_text(item: &GenerateContent) -> &str { - match item { - GenerateContent::Text { text, .. } => text, - _ => panic!("expected Text content, got {item:?}"), - } -} - -fn as_tool_call(item: &GenerateContent) -> (&str, &str, &str) { - match item { - GenerateContent::ToolCall(RawToolCall { - tool_call_id, - tool_name, - input, - .. - }) => (tool_call_id, tool_name, input), - _ => panic!("expected ToolCall content, got {item:?}"), - } -} - -async fn collect_stream(result: aimux_core::result::StreamResult) -> Vec { - let mut parts = Vec::new(); - let mut stream = result.stream; - while let Some(part) = stream.next().await { - match part { - Ok(p) => parts.push(p), - Err(e) => panic!("stream error: {e:?}"), - } - } - parts -} - -/// Remove every env var that `BedrockProviderConfig::from_env` consults, so each -/// `#[serial]` test starts from a clean slate. -fn clear_bedrock_env() { - unsafe { - std::env::remove_var("AWS_BEARER_TOKEN_BEDROCK"); - std::env::remove_var("AWS_ACCESS_KEY_ID"); - std::env::remove_var("AWS_SECRET_ACCESS_KEY"); - std::env::remove_var("AWS_REGION"); - std::env::remove_var("AWS_SESSION_TOKEN"); - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// normalize-tool-call-id.test.ts -// ════════════════════════════════════════════════════════════════════════════ -// -// The TS SDK exposes `isMistralModel(modelId)` and -// `normalizeToolCallId(toolCallId, isMistral)`. Neither helper is ported to the -// Rust bedrock module — `convert_prompt_to_bedrock` has no `isMistral` -// parameter and never normalizes tool-call IDs. The tests below document the -// expected behaviour; they are `#[ignore]` until the helpers land. - -/// TS: "should return true for mistral models" -#[test] -#[ignore = "TODO: implementation gap — is_mistral_model not implemented in bedrock module"] -fn is_mistral_model_true_for_mistral() { - // Expected: is_mistral_model returns true for: - // "mistral.mistral-7b-instruct-v0:2" - // "mistral.mixtral-8x7b-instruct-v0:1" - // "mistral.mistral-large-2402-v1:0" - // "mistral.mistral-small-2402-v1:0" - // "mistral.mistral-large-2407-v1:0" - // "mistral.ministral-3-14b-instruct" - // "mistral.ministral-3-8b-instruct" - todo!("is_mistral_model is not implemented"); -} - -/// TS: "should return true for region-prefixed mistral models" -#[test] -#[ignore = "TODO: implementation gap — is_mistral_model not implemented in bedrock module"] -fn is_mistral_model_region_prefixed() { - // Expected: is_mistral_model("us.mistral.pixtral-large-2502-v1:0") == true - // Expected: is_mistral_model("eu.mistral.mistral-large-2407-v1:0") == true - todo!("is_mistral_model is not implemented"); -} - -/// TS: "should return false for non-mistral models" -#[test] -#[ignore = "TODO: implementation gap — is_mistral_model not implemented in bedrock module"] -fn is_mistral_model_false_for_non_mistral() { - // Expected: is_mistral_model returns false for: - // "anthropic.claude-3-5-sonnet-20241022-v2:0" - // "amazon.nova-pro-v1:0" - // "openai.gpt-4o" - // "meta.llama3-70b-instruct-v1:0" - todo!("is_mistral_model is not implemented"); -} - -/// TS: "should return the original ID when not a Mistral model" -#[test] -#[ignore = "TODO: implementation gap — normalize_tool_call_id not implemented in bedrock module"] -fn normalize_tool_call_id_passthrough_when_not_mistral() { - // Expected: normalize_tool_call_id("tooluse_bpe71yCfRu2b5i-nKGDr5g", false) - // == "tooluse_bpe71yCfRu2b5i-nKGDr5g" - todo!("normalize_tool_call_id is not implemented"); -} - -/// TS: "should extract first 9 alphanumeric characters for Mistral models" -#[test] -#[ignore = "TODO: implementation gap — normalize_tool_call_id not implemented in bedrock module"] -fn normalize_tool_call_id_first_9_alphanumeric() { - // Expected: normalize_tool_call_id("tooluse_bpe71yCfRu2b5i-nKGDr5g", true) - // == "toolusebp" - todo!("normalize_tool_call_id is not implemented"); -} - -/// TS: "should handle IDs with various special characters" -#[test] -#[ignore = "TODO: implementation gap — normalize_tool_call_id not implemented in bedrock module"] -fn normalize_tool_call_id_special_chars() { - // Expected: normalize_tool_call_id("tool-use_123ABC456", true) == "tooluse12" - // Expected: normalize_tool_call_id("___abc123DEF___", true) == "abc123DEF" - todo!("normalize_tool_call_id is not implemented"); -} - -/// TS: "should handle IDs that are already alphanumeric" -#[test] -#[ignore = "TODO: implementation gap — normalize_tool_call_id not implemented in bedrock module"] -fn normalize_tool_call_id_already_alphanumeric() { - // Expected: normalize_tool_call_id("abcdefghi", true) == "abcdefghi" - // Expected: normalize_tool_call_id("abc123XYZ", true) == "abc123XYZ" - todo!("normalize_tool_call_id is not implemented"); -} - -/// TS: "should handle short IDs" -#[test] -#[ignore = "TODO: implementation gap — normalize_tool_call_id not implemented in bedrock module"] -fn normalize_tool_call_id_short_ids() { - // Expected: normalize_tool_call_id("abc", true) == "abc" - // Expected: normalize_tool_call_id("12345", true) == "12345" - todo!("normalize_tool_call_id is not implemented"); -} - -/// TS: "should handle IDs with only special characters" -#[test] -#[ignore = "TODO: implementation gap — normalize_tool_call_id not implemented in bedrock module"] -fn normalize_tool_call_id_only_special_chars() { - // Expected: normalize_tool_call_id("___---___", true) == "" - todo!("normalize_tool_call_id is not implemented"); -} - -/// TS: "should produce valid Mistral tool call IDs (9 alphanumeric chars)" -#[test] -#[ignore = "TODO: implementation gap — normalize_tool_call_id not implemented in bedrock module"] -fn normalize_tool_call_id_valid_mistral_format() { - // Expected: normalize_tool_call_id("tooluse_bpe71yCfRu2b5i-nKGDr5g", true) - // matches ^[a-zA-Z0-9]{1,9}$ - todo!("normalize_tool_call_id is not implemented"); -} - -// ════════════════════════════════════════════════════════════════════════════ -// convert-amazon-bedrock-usage.test.ts — `raw` echo cases -// ════════════════════════════════════════════════════════════════════════════ -// -// The non-raw cases are already covered in bedrock_convert_test.rs. The two -// `raw`-only cases below require a `raw` echo field on `Usage`, which the Rust -// type does not model. - -use aimux_providers::bedrock::convert::{BedrockUsage, convert_usage}; - -fn bedrock_usage_with_total( - input: u32, - output: u32, - total: Option, - cache_read: Option, - cache_write: Option, -) -> BedrockUsage { - BedrockUsage { - input_tokens: Some(input), - output_tokens: Some(output), - total_tokens: total, - cache_read_input_tokens: cache_read, - cache_write_input_tokens: cache_write, - } -} - -/// TS: "should include totalTokens in raw when provided" -#[test] -#[ignore = "TODO: implementation gap — Usage type has no raw echo field"] -fn usage_raw_includes_total_tokens() { - let u = convert_usage(Some(&bedrock_usage_with_total( - 100, - 50, - Some(150), - None, - None, - ))); - // TS asserts result.raw == { inputTokens: 100, outputTokens: 50, totalTokens: 150 } - // The Rust Usage has no raw field; only the token breakdown is available. - assert_eq!(u.input_tokens.total, Some(100)); - assert_eq!(u.output_tokens.total, Some(50)); - todo!("Usage.raw echo is not modelled"); -} - -/// TS: "should preserve raw usage data" -#[test] -#[ignore = "TODO: implementation gap — Usage type has no raw echo field"] -fn usage_raw_preserved() { - let raw = bedrock_usage_with_total(100, 50, Some(150), Some(80), Some(60)); - let u = convert_usage(Some(&raw)); - // TS asserts result.raw == the full input object. - assert_eq!(u.input_tokens.total, Some(240)); - todo!("Usage.raw echo is not modelled"); -} - -// ════════════════════════════════════════════════════════════════════════════ -// amazon-bedrock-provider.test.ts — provider configuration / auth -// ════════════════════════════════════════════════════════════════════════════ - -/// TS: "should create a provider instance with default options" — default -/// region is `us-east-1` and the base URL points at bedrock-runtime. -#[test] -#[serial] -fn provider_default_region_and_base_url() { - clear_bedrock_env(); - let config = BedrockProviderConfig::new("ak", "sk", "us-east-1"); - assert_eq!( - config.base_url, - "https://bedrock-runtime.us-east-1.amazonaws.com" - ); - assert!(matches!(config.auth, BedrockAuth::SigV4(_))); -} - -/// TS: "should create a provider instance with custom options" — custom region -/// flows into the base URL. -#[test] -fn provider_custom_region_base_url() { - let config = BedrockProviderConfig::new("ak", "sk", "eu-west-1"); - assert_eq!( - config.base_url, - "https://bedrock-runtime.eu-west-1.amazonaws.com" - ); -} - -/// TS: "should accept a credentialProvider in options" — the Rust port models -/// only static SigV4 creds + bearer; the `with_base_url` override mirrors the -/// custom baseURL option. -#[test] -fn provider_with_base_url_override() { - let config = BedrockProviderConfig::with_bearer_token("tok", "us-east-1") - .with_base_url("https://custom.url/"); - assert_eq!(config.base_url, "https://custom.url"); -} - -/// TS: "should use API key when provided in options" — bearer-token auth path. -#[test] -fn provider_bearer_token_auth() { - let config = BedrockProviderConfig::with_bearer_token("test-api-key", "us-east-1"); - match config.auth { - BedrockAuth::BearerToken(t) => assert_eq!(t, "test-api-key"), - _ => panic!("expected BearerToken auth"), - } -} - -/// TS: "should use API key from environment variable" — `AWS_BEARER_TOKEN_BEDROCK` -/// takes precedence over SigV4 env vars in `from_env`. -#[test] -#[serial] -fn provider_from_env_bearer_token_precedence() { - clear_bedrock_env(); - unsafe { - std::env::set_var("AWS_BEARER_TOKEN_BEDROCK", "env-bearer"); - std::env::set_var("AWS_ACCESS_KEY_ID", "should-not-be-used"); - std::env::set_var("AWS_SECRET_ACCESS_KEY", "should-not-be-used"); - std::env::set_var("AWS_REGION", "us-west-2"); - } - - let config = BedrockProviderConfig::from_env().expect("from_env should succeed"); - match config.auth { - BedrockAuth::BearerToken(t) => assert_eq!(t, "env-bearer"), - _ => panic!("expected BearerToken auth (bearer takes precedence)"), - } - assert!(config.base_url.contains("us-west-2")); - - clear_bedrock_env(); -} - -/// TS: "should fall back to SigV4 when no API key provided" — `from_env` uses -/// SigV4 when `AWS_BEARER_TOKEN_BEDROCK` is absent. -#[test] -#[serial] -fn provider_from_env_sigv4_fallback() { - clear_bedrock_env(); - unsafe { - std::env::set_var("AWS_ACCESS_KEY_ID", "test-ak"); - std::env::set_var("AWS_SECRET_ACCESS_KEY", "test-sk"); - std::env::set_var("AWS_REGION", "eu-west-1"); - } - - let config = BedrockProviderConfig::from_env().expect("from_env should succeed"); - match config.auth { - BedrockAuth::SigV4(creds) => { - assert_eq!(creds.access_key_id, "test-ak"); - assert_eq!(creds.secret_access_key, "test-sk"); - assert_eq!(creds.region, "eu-west-1"); - assert!(creds.session_token.is_none()); - } - _ => panic!("expected SigV4 auth"), - } - - clear_bedrock_env(); -} - -/// TS: "should maintain backward compatibility with existing SigV4 -/// authentication" — `AWS_SESSION_TOKEN` from env is picked up by `from_env`. -#[test] -#[serial] -fn provider_from_env_session_token() { - clear_bedrock_env(); - unsafe { - std::env::set_var("AWS_ACCESS_KEY_ID", "ak"); - std::env::set_var("AWS_SECRET_ACCESS_KEY", "sk"); - std::env::set_var("AWS_REGION", "us-east-1"); - std::env::set_var("AWS_SESSION_TOKEN", "sts-token"); - } - - let config = BedrockProviderConfig::from_env().expect("from_env should succeed"); - match config.auth { - BedrockAuth::SigV4(creds) => { - assert_eq!(creds.session_token.as_deref(), Some("sts-token")); - } - _ => panic!("expected SigV4 auth"), - } - - clear_bedrock_env(); -} - -/// TS: default region falls back to `us-east-1` when `AWS_REGION` is unset. -#[test] -#[serial] -fn provider_from_env_default_region() { - clear_bedrock_env(); - unsafe { - std::env::set_var("AWS_ACCESS_KEY_ID", "ak"); - std::env::set_var("AWS_SECRET_ACCESS_KEY", "sk"); - } - - let config = BedrockProviderConfig::from_env().expect("from_env should succeed"); - assert!(config.base_url.contains("us-east-1")); - - clear_bedrock_env(); -} - -/// TS: `from_env` errors when neither bearer token nor access key is present. -#[test] -#[serial] -fn provider_from_env_missing_credentials_errors() { - clear_bedrock_env(); - let result = BedrockProviderConfig::from_env(); - assert!( - result.is_err(), - "from_env should error without any credentials" - ); - clear_bedrock_env(); -} - -/// The `BedrockProvider` exposes its name and can vend language models. -#[test] -fn provider_name_and_language_model() { - let provider = - BedrockProvider::new(BedrockProviderConfig::with_bearer_token("tok", "us-east-1")); - assert_eq!(provider.name(), "amazon-bedrock"); - - let model = provider.model("anthropic.claude-3-5-sonnet-20240620-v1:0"); - assert_eq!( - model.model_id(), - "anthropic.claude-3-5-sonnet-20240620-v1:0" - ); - assert_eq!(model.provider(), "amazon-bedrock"); -} - -/// TS: "should prioritize options.apiKey over environment variable" — explicit -/// bearer token construction does not consult env vars. -#[test] -#[serial] -fn provider_explicit_bearer_over_env() { - clear_bedrock_env(); - unsafe { - std::env::set_var("AWS_BEARER_TOKEN_BEDROCK", "env-bearer"); - } - // Explicit construction with a different token should win over env. - let config = BedrockProviderConfig::with_bearer_token("explicit-tok", "us-east-1"); - match config.auth { - BedrockAuth::BearerToken(t) => assert_eq!(t, "explicit-tok"), - _ => panic!("expected BearerToken auth"), - } - clear_bedrock_env(); -} - -// ════════════════════════════════════════════════════════════════════════════ -// convert-to-amazon-bedrock-chat-messages.test.ts — Mistral normalization -// ════════════════════════════════════════════════════════════════════════════ -// -// The non-Mistral cases are covered in bedrock_convert_test.rs. The two -// `isMistral: true` cases below require the Rust converter to accept an -// `is_mistral` flag (or auto-detect via model id) and normalize tool-call IDs -// to the first 9 alphanumeric characters. Neither is implemented. - -use aimux_providers::bedrock::convert::convert_prompt_to_bedrock; -fn tool_msg(content: Vec) -> LanguageModelPromptMessage { - LanguageModelPromptMessage { - role: Role::Tool, - content, - provider_options: None, - } -} - -fn assistant_msg(content: Vec) -> LanguageModelPromptMessage { - LanguageModelPromptMessage { - role: Role::Assistant, - content, - provider_options: None, - } -} - -/// TS: "should normalize tool call IDs in tool results when isMistral is true" -#[test] -#[ignore = "TODO: implementation gap — convert_prompt_to_bedrock has no isMistral parameter"] -fn convert_mistral_normalize_tool_result_id() { - let original_id = "tooluse_bpe71yCfRu2b5i-nKGDr5g"; - let prompt = vec![tool_msg(vec![ContentPart::tool_result( - original_id.to_string(), - json!({ "type": "text", "value": "The result is 42" }), - )])]; - let (_, messages) = convert_prompt_to_bedrock(&prompt); - // TS (isMistral: true) expects toolUseId == "toolusebp" (first 9 alphanum). - // The Rust converter has no isMistral flag, so the ID is passed through - // unchanged — this assertion fails until the gap is closed. - assert_eq!( - messages[0]["content"][0]["toolResult"]["toolUseId"], - json!("toolusebp") - ); -} - -/// TS: "should normalize tool call IDs in tool calls when isMistral is true" -#[test] -#[ignore = "TODO: implementation gap — convert_prompt_to_bedrock has no isMistral parameter"] -fn convert_mistral_normalize_tool_call_id() { - let original_id = "tooluse_bpe71yCfRu2b5i-nKGDr5g"; - let prompt = vec![assistant_msg(vec![ContentPart::tool_call( - original_id.to_string(), - "getWeather".to_string(), - json!({ "city": "SF" }), - )])]; - let (_, messages) = convert_prompt_to_bedrock(&prompt); - // TS (isMistral: true) expects toolUseId == "toolusebp". - assert_eq!( - messages[0]["content"][0]["toolUse"]["toolUseId"], - json!("toolusebp") - ); -} - -// ════════════════════════════════════════════════════════════════════════════ -// amazon-bedrock-chat-language-model.test.ts — request URL / body / response -// ════════════════════════════════════════════════════════════════════════════ - -// ── supportedUrls ──────────────────────────────────────────────────────────── - -/// TS: "should support S3 URLs for image parts" — the TS model exposes a -/// `supportedUrls` map (`{ 'image/*': [/^s3:\/\//] }`). The Rust `BedrockModel` -/// has no `supported_urls` concept, so this test is `#[ignore]`. -#[test] -#[ignore = "TODO: implementation gap — BedrockModel has no supported_urls field"] -fn model_supported_urls_s3() { - // TS: model.supportedUrls == { 'image/*': [/^s3:\/\//] } - todo!("supportedUrls is not modelled on BedrockModel"); -} - -// ── ARN model IDs containing a slash ───────────────────────────────────────── - -/// TS: "should generate text through the encoded Converse route". -/// -/// ARN inference-profile IDs contain a `/` which must be percent-encoded in the -/// URL path. The TS SDK uses `encodeURIComponent(modelId)`. The Rust -/// `BedrockModel::endpoint` does **not** encode the model id, so the slash is -/// sent raw and the request misses the mocked (encoded) route. -#[tokio::test] -#[ignore = "TODO: implementation gap — endpoint() does not percent-encode the model id"] -async fn arn_model_id_encoded_generate_route() { - let server = MockServer::start().await; - let arn = "arn:aws:bedrock:eu-west-1:474668406012:inference-profile/eu.amazon.nova-lite-v1:0"; - let encoded = - percent_encoding::utf8_percent_encode(arn, percent_encoding::NON_ALPHANUMERIC).to_string(); - let encoded_path = format!("/model/{encoded}/converse"); - - Mock::given(method("POST")) - .and(path(&encoded_path)) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "output": { "message": { "role": "assistant", "content": [{ "text": "Hello!" }] } }, - "stopReason": "end_turn", - "usage": { "inputTokens": 1, "outputTokens": 1 } - }))) - .mount(&server) - .await; - - let model = BedrockModel::new( - arn.to_string(), - BedrockConfig { - base_url: server.uri(), - auth: BedrockAuth::BearerToken("tok".to_string()), - retry_config: aimux_provider_utils::RetryConfig::default(), - api_key_source: None, - }, - ); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed via the encoded route"); - assert_eq!(as_text(&result.content[0]), "Hello!"); -} - -/// TS: "should stream text through the encoded Converse route". -#[tokio::test] -#[ignore = "TODO: implementation gap — endpoint() does not percent-encode the model id"] -async fn arn_model_id_encoded_stream_route() { - let server = MockServer::start().await; - let arn = "arn:aws:bedrock:eu-west-1:474668406012:inference-profile/eu.amazon.nova-lite-v1:0"; - let encoded = - percent_encoding::utf8_percent_encode(arn, percent_encoding::NON_ALPHANUMERIC).to_string(); - let encoded_path = format!("/model/{encoded}/converse-stream"); - - let events: Vec<(&str, &str, &str)> = vec![ - ("event", "messageStart", r#"{"role":"assistant"}"#), - ( - "event", - "contentBlockDelta", - r#"{"contentBlockIndex":0,"delta":{"text":"Hello!"}}"#, - ), - ("event", "contentBlockStop", r#"{"contentBlockIndex":0}"#), - ("event", "messageStop", r#"{"stopReason":"end_turn"}"#), - ( - "event", - "metadata", - r#"{"usage":{"inputTokens":1,"outputTokens":1}}"#, - ), - ]; - let body_bytes = aimux_providers::bedrock::event_stream::encode_messages(&events); - - Mock::given(method("POST")) - .and(path(&encoded_path)) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "application/vnd.amazon.eventstream") - .set_body_raw(body_bytes, "application/vnd.amazon.eventstream"), - ) - .mount(&server) - .await; - - let model = BedrockModel::new( - arn.to_string(), - BedrockConfig { - base_url: server.uri(), - auth: BedrockAuth::BearerToken("tok".to_string()), - retry_config: aimux_provider_utils::RetryConfig::default(), - api_key_source: None, - }, - ); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("should succeed via the encoded stream route"); - let parts = collect_stream(result).await; - - let text: String = parts - .iter() - .filter_map(|p| match p { - StreamPart::TextDelta { delta, .. } => Some(delta.clone()), - _ => None, - }) - .collect(); - assert_eq!(text, "Hello!"); -} - -// ── additionalModelResponseFieldPaths ──────────────────────────────────────── - -/// TS: "should return the request body" — the default request body includes -/// `additionalModelResponseFieldPaths: ["/delta/stop_sequence"]` so that the -/// stop_sequence value is returned in `additionalModelResponseFields`. The Rust -/// `build_request_body` does not emit this field. -#[tokio::test] -#[ignore = "TODO: implementation gap — build_request_body does not emit additionalModelResponseFieldPaths"] -async fn request_body_includes_additional_model_response_field_paths() { - let server = MockServer::start().await; - mock_converse_json(&server, 200, ok_converse_body()).await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - let body = result.request_body.expect("request body"); - assert_eq!( - body["additionalModelResponseFieldPaths"], - json!(["/delta/stop_sequence"]) - ); -} - -// ── temperature clamping ───────────────────────────────────────────────────── - -/// TS: "should clamp temperature above 1 to 1 and add warning". -#[tokio::test] -#[ignore = "TODO: implementation gap — temperature is not clamped to [0, 1]"] -async fn temperature_clamped_above_1() { - let server = MockServer::start().await; - mock_converse_json(&server, 200, ok_converse_body()).await; - - let model = make_model(&server); - let mut opts = default_options(test_prompt()); - opts.temperature = Some(1.5); - - let result = model.do_generate(&opts).await.expect("should succeed"); - - let body = result.request_body.expect("request body"); - assert_eq!( - body["inferenceConfig"]["temperature"].as_f64().unwrap(), - 1.0 - ); - assert!( - result.warnings.iter().any( - |w| matches!(w, aimux_core::types::Warning::Unsupported { feature, .. } - if feature == "temperature") - ), - "expected an unsupported-temperature warning, got {:?}", - result.warnings - ); -} - -/// TS: "should clamp temperature below 0 to 0 and add warning". -#[tokio::test] -#[ignore = "TODO: implementation gap — temperature is not clamped to [0, 1]"] -async fn temperature_clamped_below_0() { - let server = MockServer::start().await; - mock_converse_json(&server, 200, ok_converse_body()).await; - - let model = make_model(&server); - let mut opts = default_options(test_prompt()); - opts.temperature = Some(-0.5); - - let result = model.do_generate(&opts).await.expect("should succeed"); - - let body = result.request_body.expect("request body"); - assert_eq!( - body["inferenceConfig"]["temperature"].as_f64().unwrap(), - 0.0 - ); - assert!( - result.warnings.iter().any( - |w| matches!(w, aimux_core::types::Warning::Unsupported { feature, .. } - if feature == "temperature") - ), - "expected an unsupported-temperature warning" - ); -} - -/// TS: "should not clamp valid temperature between 0 and 1". -#[tokio::test] -async fn temperature_not_clamped_in_range() { - let server = MockServer::start().await; - mock_converse_json(&server, 200, ok_converse_body()).await; - - let model = make_model(&server); - let mut opts = default_options(test_prompt()); - opts.temperature = Some(0.7); - - let result = model.do_generate(&opts).await.expect("should succeed"); - - let body = result.request_body.expect("request body"); - let temp = body["inferenceConfig"]["temperature"].as_f64().unwrap(); - assert!( - (temp - 0.7).abs() < 1e-6, - "temperature should be 0.7, got {temp}" - ); - assert!(result.warnings.is_empty(), "no warnings expected"); -} - -// ── guardrails ─────────────────────────────────────────────────────────────── - -/// TS: "should support guardrails" — `providerOptions.bedrock.guardrailConfig` -/// is forwarded as a top-level `guardrailConfig` in the request body. -#[tokio::test] -#[ignore = "TODO: implementation gap — build_request_body does not emit guardrailConfig"] -async fn guardrail_config_in_request_body() { - let server = MockServer::start().await; - mock_converse_json(&server, 200, ok_converse_body()).await; - - let model = make_model(&server); - let mut opts = default_options(test_prompt()); - let po = aimux_core::shared::provider_namespace( - "bedrock", - json!({ - "guardrailConfig": { - "guardrailIdentifier": "-1", - "guardrailVersion": "1", - "trace": "enabled" - } - }), - ); - opts.provider_options = Some(po); - - let result = model.do_generate(&opts).await.expect("should succeed"); - let body = result.request_body.expect("request body"); - assert_eq!( - body["guardrailConfig"], - json!({ - "guardrailIdentifier": "-1", - "guardrailVersion": "1", - "trace": "enabled" - }) - ); -} - -// ── trace in providerMetadata ──────────────────────────────────────────────── - -/// TS: "should include trace information in providerMetadata" — the response -/// `trace` field is surfaced as `providerMetadata.bedrock.trace`. -#[tokio::test] -#[ignore = "TODO: implementation gap — do_generate does not extract trace into providerMetadata"] -async fn trace_in_provider_metadata() { - let server = MockServer::start().await; - let trace = json!({ - "guardrail": { - "inputAssessment": { - "1abcd2ef34gh": { - "contentPolicy": { - "filters": [{ - "action": "BLOCKED", - "confidence": "LOW", - "type": "INSULTS" - }] - } - } - } - } - }); - Mock::given(method("POST")) - .and(path("/model/anthropic.claude-3-5-sonnet-20240620-v1:0/converse")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "output": { "message": { "role": "assistant", "content": [{ "text": "Hello, World!" }] } }, - "usage": { "inputTokens": 4, "outputTokens": 34, "totalTokens": 38 }, - "stopReason": "stop_sequence", - "trace": trace - }))) - .mount(&server) - .await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - let pm = result - .provider_metadata - .as_ref() - .expect("provider_metadata should be Some"); - assert_eq!(pm["bedrock"]["trace"], trace); -} - -// ── stop_sequence in providerMetadata ──────────────────────────────────────── - -/// TS: "should include stop_sequence in provider metadata" — when the response -/// carries `additionalModelResponseFields.delta.stop_sequence`, it is surfaced -/// as `providerMetadata.bedrock.stopSequence` (and `amazonBedrock.stopSequence`). -#[tokio::test] -#[ignore = "TODO: implementation gap — do_generate does not extract stop_sequence from additionalModelResponseFields"] -async fn stop_sequence_in_provider_metadata() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/model/anthropic.claude-3-5-sonnet-20240620-v1:0/converse")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "output": { "message": { "role": "assistant", "content": [{ "text": "Hello, World!" }] } }, - "stopReason": "stop_sequence", - "additionalModelResponseFields": { "delta": { "stop_sequence": "STOP" } }, - "usage": { "inputTokens": 4, "outputTokens": 30, "totalTokens": 34 } - }))) - .mount(&server) - .await; - - let model = make_model(&server); - let mut opts = default_options(test_prompt()); - opts.stop_sequences = Some(vec!["STOP".to_string()]); - - let result = model.do_generate(&opts).await.expect("should succeed"); - - let pm = result - .provider_metadata - .as_ref() - .expect("provider_metadata should be Some"); - assert_eq!(pm["bedrock"]["stopSequence"], json!("STOP")); - assert_eq!(pm["amazonBedrock"]["stopSequence"], json!("STOP")); -} - -// ── tool calls with empty input ────────────────────────────────────────────── - -/// TS: "should support tool calls with empty input (no arguments)" (stream). -/// -/// When a `toolUse` block carries no input deltas, the stream event loop -/// accumulates an empty string and defaults the parsed input to `{}` — matching -/// the TS behaviour. (The non-streaming path does **not** handle this; see -/// `generate_tool_call_empty_input` below.) -#[tokio::test] -async fn stream_tool_call_empty_input() { - let server = MockServer::start().await; - - let events: Vec<(&str, &str, &str)> = vec![ - ("event", "messageStart", r#"{"role":"assistant"}"#), - ( - "event", - "contentBlockStart", - r#"{"contentBlockIndex":0,"start":{"toolUse":{"name":"updateIssueList","toolUseId":"tool_1"}}}"#, - ), - // No input deltas — the tool call has no arguments. - ("event", "contentBlockStop", r#"{"contentBlockIndex":0}"#), - ("event", "messageStop", r#"{"stopReason":"tool_use"}"#), - ]; - let body_bytes = aimux_providers::bedrock::event_stream::encode_messages(&events); - - Mock::given(method("POST")) - .and(path( - "/model/anthropic.claude-3-5-sonnet-20240620-v1:0/converse-stream", - )) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "application/vnd.amazon.eventstream") - .set_body_raw(body_bytes, "application/vnd.amazon.eventstream"), - ) - .mount(&server) - .await; - - let model = make_model(&server); - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - let parts = collect_stream(result).await; - - let tool_calls: Vec<_> = parts - .iter() - .filter_map(|p| match p { - StreamPart::ToolCall(RawToolCall { - tool_call_id, - tool_name, - input, - .. - }) => Some((tool_call_id.clone(), tool_name.clone(), input.clone())), - _ => None, - }) - .collect(); - assert_eq!(tool_calls.len(), 1); - assert_eq!(tool_calls[0].0, "tool_1"); - assert_eq!(tool_calls[0].1, "updateIssueList"); - assert_eq!(tool_calls[0].2, "{}"); -} - -// ── omit toolConfig ────────────────────────────────────────────────────────── - -/// TS: "should omit toolConfig when conversation has tool calls but toolChoice -/// is none" — with `toolChoice: none`, no `toolConfig` is sent even when tools -/// are provided. -#[tokio::test] -async fn omit_tool_config_when_tool_choice_none() { - let server = MockServer::start().await; - mock_converse_json(&server, 200, ok_converse_body()).await; - - let model = make_model(&server); - let opts = CallOptions { - tools: Some(vec![Tool::Function(FunctionTool::new( - "weather".to_string(), - json!({ "type": "object", "properties": { "city": { "type": "string" } } }), - ))]), - tool_choice: ToolChoice::None, - ..default_options(test_prompt()) - }; - - let result = model.do_generate(&opts).await.expect("should succeed"); - let body = result.request_body.expect("request body"); - assert!( - body.get("toolConfig").is_none(), - "toolConfig should be absent when toolChoice is none, got {}", - body["toolConfig"] - ); -} - -/// TS: "should omit toolConfig and filter tool content when conversation has -/// tool calls but no active tools". -/// -/// The `toolConfig` omission (tools = `[]`) works in Rust. The *content -/// filtering* — dropping the assistant's `toolUse` block from the messages when -/// there are no active tools — is **not** implemented, so only the toolConfig -/// assertion is live; the filtering assertion is `#[ignore]` below. -#[tokio::test] -async fn omit_tool_config_when_no_active_tools() { - let server = MockServer::start().await; - mock_converse_json(&server, 200, ok_converse_body()).await; - - let prompt: LanguageModelPrompt = vec![ - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("What is the weather in Toronto?")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::tool_call( - "tool-call-1".to_string(), - "weather".to_string(), - json!({ "city": "Toronto" }), - )], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::Tool, - content: vec![ContentPart::tool_result( - "tool-call-1".to_string(), - json!({ "type": "text", "value": "The weather in Toronto is 20°C." }), - )], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Now give me a summary.")], - ..Default::default() - }, - ]; - - let model = make_model(&server); - let opts = CallOptions { - tools: Some(vec![]), - ..default_options(prompt) - }; - - let result = model.do_generate(&opts).await.expect("should succeed"); - let body = result.request_body.expect("request body"); - assert!( - body.get("toolConfig").is_none(), - "toolConfig should be absent when tools list is empty" - ); -} - -/// TS: (same test) the assistant's `toolUse` block should be filtered out of -/// the messages when there are no active tools. The Rust converter always -/// emits `toolUse` blocks regardless of active tools. -#[tokio::test] -#[ignore = "TODO: implementation gap — convert_prompt_to_bedrock does not filter toolUse blocks when no active tools"] -async fn filter_tool_content_when_no_active_tools() { - let server = MockServer::start().await; - mock_converse_json(&server, 200, ok_converse_body()).await; - - let prompt: LanguageModelPrompt = vec![ - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("What is the weather in Toronto?")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::tool_call( - "tool-call-1".to_string(), - "weather".to_string(), - json!({ "city": "Toronto" }), - )], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::Tool, - content: vec![ContentPart::tool_result( - "tool-call-1".to_string(), - json!({ "type": "text", "value": "The weather in Toronto is 20°C." }), - )], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Now give me a summary.")], - ..Default::default() - }, - ]; - - let model = make_model(&server); - let opts = CallOptions { - tools: Some(vec![]), - ..default_options(prompt) - }; - - let result = model.do_generate(&opts).await.expect("should succeed"); - let body = result.request_body.expect("request body"); - // The assistant message should NOT contain a toolUse block. - let assistant_msg = &body["messages"][1]; - assert_eq!(assistant_msg["role"], "assistant"); - let has_tool_use = assistant_msg["content"] - .as_array() - .map(|arr| arr.iter().any(|b| b.get("toolUse").is_some())) - .unwrap_or(false); - assert!( - !has_tool_use, - "toolUse should be filtered out when no active tools" - ); -} - -// ── doGenerate: tool call with empty input (non-streaming) ─────────────────── - -/// TS variant: a non-streaming `toolUse` with no `input` field should yield -/// `input: {}`. The Rust `BedrockToolUse.input` serde-defaults to `Null`. -#[tokio::test] -#[ignore = "TODO: implementation gap — empty toolUse input deserializes as Null, not {}"] -async fn generate_tool_call_empty_input() { - let server = MockServer::start().await; - mock_converse_json( - &server, - 200, - json!({ - "output": { - "message": { - "role": "assistant", - "content": [ - { - "toolUse": { - "toolUseId": "tool_1", - "name": "updateIssueList" - // no "input" field - } - } - ] - } - }, - "stopReason": "tool_use", - "usage": { "inputTokens": 5, "outputTokens": 5 } - }), - ) - .await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - assert_eq!(result.content.len(), 1); - let (id, name, input) = as_tool_call(&result.content[0]); - assert_eq!(id, "tool_1"); - assert_eq!(name, "updateIssueList"); - // TS expects input == {} (empty object), not null. - assert_eq!(input, &json!("{}")); -} - -// ── doGenerate: basic text + finish reason (sanity, already covered but -// exercised here against the remaining-test helper set) ──────────────────── - -/// TS: "should pass the model and the messages" — the request body carries the -/// model id implicitly via the URL path and the messages array. -#[tokio::test] -async fn request_body_messages_shape() { - let server = MockServer::start().await; - mock_converse_json(&server, 200, ok_converse_body()).await; - - let prompt: LanguageModelPrompt = vec![ - LanguageModelPromptMessage { - role: Role::System, - content: vec![ContentPart::text("System Prompt")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }, - ]; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(prompt)) - .await - .expect("ok"); - let body = result.request_body.expect("request body"); - assert_eq!(body["system"], json!([{ "text": "System Prompt" }])); - assert_eq!(body["messages"][0]["role"], "user"); - assert_eq!(body["messages"][0]["content"][0]["text"], "Hello"); -} - -/// TS: "should extract finish reason" — `guardrail_intervened` → ContentFilter. -#[tokio::test] -async fn finish_reason_guardrail_intervened() { - let server = MockServer::start().await; - mock_converse_json( - &server, - 200, - json!({ - "output": { "message": { "role": "assistant", "content": [{ "text": "" }] } }, - "stopReason": "guardrail_intervened", - "usage": { "inputTokens": 4, "outputTokens": 1 } - }), - ) - .await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - assert_eq!( - result.finish_reason.unified, - FinishReasonUnified::ContentFilter - ); - assert_eq!( - result.finish_reason.raw.as_deref(), - Some("guardrail_intervened") - ); -} - -/// TS: "should send all tools when toolChoice is auto" — all provided tools -/// appear in the toolConfig, not just a filtered subset. -#[tokio::test] -async fn tool_choice_auto_sends_all_tools() { - let server = MockServer::start().await; - mock_converse_json(&server, 200, ok_converse_body()).await; - - let model = make_model(&server); - let opts = CallOptions { - tools: Some(vec![ - Tool::Function( - FunctionTool::new("get-weather".to_string(), json!({ "type": "object" })) - .with_description("Get weather".to_string()), - ), - Tool::Function( - FunctionTool::new("get-time".to_string(), json!({ "type": "object" })) - .with_description("Get time".to_string()), - ), - ]), - tool_choice: ToolChoice::Auto, - ..default_options(test_prompt()) - }; - - let result = model.do_generate(&opts).await.expect("should succeed"); - let body = result.request_body.expect("request body"); - assert_eq!(body["toolConfig"]["toolChoice"], json!({ "auto": {} })); - let tools = body["toolConfig"]["tools"].as_array().unwrap(); - assert_eq!( - tools.len(), - 2, - "both tools should be sent with toolChoice: auto" - ); - assert_eq!(tools[0]["toolSpec"]["name"], "get-weather"); - assert_eq!(tools[1]["toolSpec"]["name"], "get-time"); -} - -/// TS: "should omit empty tool descriptions to avoid Bedrock validation errors". -/// -/// A function tool with an empty-string description must not emit a -/// `description` field in the `toolSpec`. -#[tokio::test] -async fn tool_empty_description_omitted() { - let server = MockServer::start().await; - mock_converse_json(&server, 200, ok_converse_body()).await; - - let model = make_model(&server); - let opts = CallOptions { - tools: Some(vec![Tool::Function( - FunctionTool::new("get-weather".to_string(), json!({ "type": "object" })) - .with_description("".to_string()), - )]), - tool_choice: ToolChoice::Auto, - ..default_options(test_prompt()) - }; - - let result = model.do_generate(&opts).await.expect("should succeed"); - let body = result.request_body.expect("request body"); - let spec = &body["toolConfig"]["tools"][0]["toolSpec"]; - assert!( - spec.get("description").is_none(), - "empty description should be omitted" - ); -} - -// ── doStream: error handling ───────────────────────────────────────────────── - -/// TS: "should handle throttlingException error" (stream) — a 429 stream -/// response maps to `AiMuxError::ApiCall` (429 in `status_code`). -#[tokio::test] -async fn stream_throttling_error() { - let server = MockServer::start().await; - - Mock::given(method("POST")) - .and(path( - "/model/anthropic.claude-3-5-sonnet-20240620-v1:0/converse-stream", - )) - .respond_with(ResponseTemplate::new(429).set_body_json(json!({ - "message": "throttlingException", - "type": "TooManyRequestsException" - }))) - .mount(&server) - .await; - - let model = make_model(&server); - let result = model.do_stream(&default_options(test_prompt())).await; - assert!( - matches!(result, Err(ref e) if e.status_code() == Some(429)), - "expected RateLimited, got {result:?}" - ); -} - -/// TS: "should handle validationException error" (stream) — a 400 stream -/// response surfaces as an error (not a panic). -#[tokio::test] -async fn stream_validation_error() { - let server = MockServer::start().await; - - Mock::given(method("POST")) - .and(path( - "/model/anthropic.claude-3-5-sonnet-20240620-v1:0/converse-stream", - )) - .respond_with(ResponseTemplate::new(400).set_body_json(json!({ - "message": "The model ID is invalid", - "type": "ValidationException" - }))) - .mount(&server) - .await; - - let model = make_model(&server); - let result = model.do_stream(&default_options(test_prompt())).await; - assert!(result.is_err(), "expected error for 400 stream response"); -} - -// ── doStream: request body parity ──────────────────────────────────────────── - -/// TS: "should return the request body" (stream) — the stream result carries -/// the request body for debugging. -#[tokio::test] -async fn stream_request_body_available() { - let server = MockServer::start().await; - - let events: Vec<(&str, &str, &str)> = vec![ - ("event", "messageStart", r#"{"role":"assistant"}"#), - ( - "event", - "contentBlockDelta", - r#"{"contentBlockIndex":0,"delta":{"text":"hi"}}"#, - ), - ("event", "contentBlockStop", r#"{"contentBlockIndex":0}"#), - ("event", "messageStop", r#"{"stopReason":"end_turn"}"#), - ]; - let body_bytes = aimux_providers::bedrock::event_stream::encode_messages(&events); - - Mock::given(method("POST")) - .and(path( - "/model/anthropic.claude-3-5-sonnet-20240620-v1:0/converse-stream", - )) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "application/vnd.amazon.eventstream") - .set_body_raw(body_bytes, "application/vnd.amazon.eventstream"), - ) - .mount(&server) - .await; - - let model = make_model(&server); - let mut opts = default_options(test_prompt()); - opts.max_output_tokens = Some(256); - - let result = model.do_stream(&opts).await.expect("should succeed"); - let body = result.request_body.expect("stream request body"); - assert_eq!(body["messages"][0]["role"], "user"); - assert_eq!(body["inferenceConfig"]["maxTokens"], 256); -} diff --git a/aimux-providers/tests/body_overrides_test.rs b/aimux-providers/tests/body_overrides_test.rs deleted file mode 100644 index f8b392dcb..000000000 --- a/aimux-providers/tests/body_overrides_test.rs +++ /dev/null @@ -1,286 +0,0 @@ -//! Tests for RFC-0017 phase 1: `body_overrides` (JSON deep-merge) and -//! `max_retries` (per-call retry override). -//! -//! `body_overrides` is a per-call JSON object deep-merged into the -//! provider-built request body after built-in vendor overrides. It lets users -//! inject/override arbitrary request fields (e.g. `enable_thinking`, -//! `thinking_budget`) without closure bridging — critical for aimux's -//! multi-language C ABI architecture. `null` values delete keys. -//! -//! stage2-001 (RFC-0017 phase 2) additions: `max_tokens_key` branch and direct -//! `reasoning` → `reasoning_effort` passthrough. The old "reasoning no-mapping" -//! warning block was removed (F6): under v3 direct-passthrough semantics it is -//! unreachable dead code (is_custom_reasoning ⇒ resolved effort is always Some), -//! and the passthrough tests below now guard against re-adding it. - -use aimux_core::content::ContentPart; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; -use aimux_core::options::CallOptions; -use aimux_core::types::{ReasoningEffort, Warning}; -use aimux_providers::openai::convert::build_request_body; -use aimux_providers::openai::convert::build_request_body_with_warnings; -use serde_json::{Value, json}; - -fn user_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] -} - -fn opts_with_overrides(prompt: LanguageModelPrompt, body_overrides: Value) -> CallOptions { - CallOptions { - prompt, - body_overrides: Some(body_overrides), - ..CallOptions::default() - } -} - -// ── body_overrides: inject ─────────────────────────────────────────────────── - -/// A top-level key in body_overrides is injected into the request body. -#[test] -fn body_overrides_injects_new_field() { - let opts = opts_with_overrides(user_prompt(), json!({ "enable_thinking": false })); - let body = build_request_body("gpt-4o", &opts, false).unwrap(); - assert_eq!(body["enable_thinking"], json!(false)); - assert_eq!(body["model"], json!("gpt-4o")); -} - -/// body_overrides can inject nested objects. -#[test] -fn body_overrides_injects_nested_object() { - let opts = opts_with_overrides(user_prompt(), json!({ "thinking": { "type": "disabled" } })); - let body = build_request_body("gpt-4o", &opts, false).unwrap(); - assert_eq!(body["thinking"], json!({ "type": "disabled" })); -} - -// ── body_overrides: override ───────────────────────────────────────────────── - -/// body_overrides overwrites an existing field (e.g. temperature). -#[test] -fn body_overrides_overwrites_existing_field() { - let mut opts = opts_with_overrides(user_prompt(), json!({ "temperature": 0.1 })); - opts.temperature = Some(0.9); // set by standard option - let body = build_request_body("gpt-4o", &opts, false).unwrap(); - // body_overrides wins over standard option - assert_eq!(body["temperature"], json!(0.1)); -} - -/// body_overrides injects a field into the request body (RFC-0017). -/// stage2-001(RFC-0017 阶段 2)后内置 vendor override 已退役——此前 DeepSeek -/// 会从 `reasoning:none` 注入 `thinking:{type:"disabled"}`;现在 thinking 注入 -/// 完全由 body_overrides 定义(此处直接注入 enabled)。 -#[test] -fn body_overrides_overwrites_vendor_override_field() { - let opts = CallOptions { - prompt: user_prompt(), - reasoning: Some(aimux_core::types::ReasoningEffort::None), - body_overrides: Some(json!({ "thinking": { "type": "enabled" } })), - ..CallOptions::default() - }; - // DeepSeek profile 已回归 full()(特化退役),body_overrides 注入 thinking。 - let body = aimux_providers::openai::convert::build_request_body_with_warnings( - "deepseek-v4-flash", - &opts, - false, - "deepseek", - &aimux_providers::openai::OpenAICompatProfile::deepseek(), - ) - .unwrap() - .body; - assert_eq!(body["thinking"], json!({ "type": "enabled" })); -} - -// ── body_overrides: deep merge ─────────────────────────────────────────────── - -/// Nested objects are merged recursively, not replaced wholesale. -#[test] -fn body_overrides_deep_merges_nested_objects() { - // The standard body has stream_options: { include_usage: true } for streams. - // body_overrides adds another key to stream_options without clobbering - // include_usage. - let opts = opts_with_overrides( - user_prompt(), - json!({ "stream_options": { "include_usage": false } }), - ); - let body = build_request_body("gpt-4o", &opts, true).unwrap(); - assert_eq!(body["stream_options"]["include_usage"], json!(false)); -} - -// ── body_overrides: null = delete ──────────────────────────────────────────── - -/// A `null` value in body_overrides deletes the corresponding key from the -/// request body. -#[test] -fn body_overrides_null_deletes_key() { - let opts = opts_with_overrides( - user_prompt(), - json!({ "stream_options": null, "temperature": null }), - ); - let body = build_request_body("gpt-4o", &opts, true).unwrap(); - assert!( - body.get("stream_options").is_none(), - "stream_options should be deleted by null" - ); - assert!( - body.get("temperature").is_none(), - "temperature should be deleted by null" - ); -} - -/// null in a nested object deletes the nested key. -#[test] -fn body_overrides_null_deletes_nested_key() { - let opts = opts_with_overrides( - user_prompt(), - json!({ "stream_options": { "include_usage": null } }), - ); - let body = build_request_body("gpt-4o", &opts, true).unwrap(); - // stream_options still exists but include_usage is gone - assert!(body.get("stream_options").is_some()); - assert!(body["stream_options"].get("include_usage").is_none()); -} - -// ── body_overrides: no overrides = unchanged ───────────────────────────────── - -/// When body_overrides is None, the request body is identical to the standard -/// build (no merge happens). -#[test] -fn no_body_overrides_leaves_body_unchanged() { - let opts = CallOptions::new(user_prompt()); - let body = build_request_body("gpt-4o", &opts, false).unwrap(); - assert_eq!(body["model"], json!("gpt-4o")); - assert!(body.get("enable_thinking").is_none()); -} - -// ── max_retries ────────────────────────────────────────────────────────────── - -/// max_retries is stored in CallOptions and defaults to None. -#[test] -fn max_retries_defaults_to_none() { - let opts = CallOptions::new(user_prompt()); - assert!(opts.max_retries.is_none()); -} - -/// max_retries can be set (e.g. Some(0) to disable retries). -#[test] -fn max_retries_can_be_set() { - let opts = CallOptions { - prompt: user_prompt(), - max_retries: Some(0), - ..CallOptions::default() - }; - assert_eq!(opts.max_retries, Some(0)); -} - -// ── max_tokens_key (stage2-001, RFC-0017 phase 2 §2.3) ────────────────────── - -/// max_tokens_key=Some("max_tokens") + 推理模型 → 发 `max_tokens`, -/// 不含 `max_completion_tokens`(修推理模型推断 bug)。 -#[test] -fn max_tokens_key_max_tokens_reasoning_model() { - let opts = CallOptions { - prompt: user_prompt(), - max_output_tokens: Some(100), - ..CallOptions::default() - }; - let profile = aimux_providers::openai::OpenAICompatProfile { - max_tokens_key: Some("max_tokens"), - ..aimux_providers::openai::OpenAICompatProfile::full() - }; - let body = build_request_body_with_warnings("o4-mini", &opts, false, "openai", &profile) - .unwrap() - .body; - assert_eq!(body["max_tokens"], json!(100)); - assert!( - body.get("max_completion_tokens").is_none(), - "只认 max_tokens 的厂商不应收到 max_completion_tokens" - ); -} - -/// max_tokens_key=Some("max_completion_tokens") → 非推理分支也发 mct -///(groq/heroku:max_tokens 弃用)。 -#[test] -fn max_tokens_key_max_completion_tokens_non_reasoning() { - let opts = CallOptions { - prompt: user_prompt(), - max_output_tokens: Some(100), - ..CallOptions::default() - }; - let profile = aimux_providers::openai::OpenAICompatProfile { - max_tokens_key: Some("max_completion_tokens"), - ..aimux_providers::openai::OpenAICompatProfile::full() - }; - let body = build_request_body_with_warnings("gpt-4o", &opts, false, "openai", &profile) - .unwrap() - .body; - assert_eq!(body["max_completion_tokens"], json!(100)); - assert!( - body.get("max_tokens").is_none(), - "max_tokens_key=mct 时不应再发 max_tokens" - ); -} - -// ── reasoning 直传: 无 warning(F6: 旧"无映射提示"warning 块已删除)──────────── -// -// v3 直传语义下 `reasoning` 一律映射为 `reasoning_effort`,warning 块不可达已被 -// 删除。以下两个用例断言"无 reasoning warning",作为未来误加 warning 的回归护栏。 - -/// 直传路径: groq 不再特化归一化(`none` 原样透传 'none')→ 已发 effort,不 warning。 -#[test] -fn groq_none_passthrough_no_warning() { - let opts = CallOptions { - prompt: user_prompt(), - reasoning: Some(ReasoningEffort::None), - ..CallOptions::default() - }; - let result = build_request_body_with_warnings( - "llama-3.3-70b-versatile", - &opts, - false, - "groq", - &aimux_providers::openai::OpenAICompatProfile::groq(), - ) - .unwrap(); - assert_eq!(result.body["reasoning_effort"], json!("none")); - let reasoning_warning = result - .warnings - .iter() - .find(|w| matches!(w, Warning::Compatibility { feature, .. } if feature == "reasoning")); - assert!( - reasoning_warning.is_none(), - "已发 effort 时不应 warning(防误报): {:?}", - result.warnings - ); -} - -/// 已发 effort 路径('none' 透传,OpenAI 有效)→ 不 warning(防误报)。 -#[test] -fn no_warning_when_reasoning_translated() { - let opts = CallOptions { - prompt: user_prompt(), - reasoning: Some(ReasoningEffort::None), - ..CallOptions::default() - }; - let result = build_request_body_with_warnings( - "deepseek-reasoner", - &opts, - false, - "openai", - &aimux_providers::openai::OpenAICompatProfile::full(), - ) - .unwrap(); - assert_eq!(result.body["reasoning_effort"], json!("none")); - let reasoning_warning = result - .warnings - .iter() - .find(|w| matches!(w, Warning::Compatibility { feature, .. } if feature == "reasoning")); - assert!( - reasoning_warning.is_none(), - "已发 effort 时不应 warning(防误报): {:?}", - result.warnings - ); -} diff --git a/aimux-providers/tests/cohere_model_test.rs b/aimux-providers/tests/cohere_model_test.rs deleted file mode 100644 index 873f4b16e..000000000 --- a/aimux-providers/tests/cohere_model_test.rs +++ /dev/null @@ -1,1591 +0,0 @@ -//! Wiremock tests for the Cohere provider. -//! -//! Translated from `packages/cohere/src/cohere-chat-language-model.test.ts`, -//! focusing on the cases that the Rust data model can express: -//! - doGenerate: text extraction, MAX_TOKENS finish reason, tool calls, request body -//! - doStream: text streaming, tool call streaming, reasoning streaming - -use aimux_core::tool::RawToolCall; -use futures::StreamExt; -use serde_json::{Value, json}; -use wiremock::matchers::{method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -use aimux_core::content::ContentPart; -use aimux_core::error::AiMuxError; -use aimux_core::generate::{GenerateTextOptions, stream_text}; -use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; -use aimux_core::options::{CallOptions, Tool, ToolChoice}; -use aimux_core::result::{GenerateContent, Source, StreamResult}; -use aimux_core::stream_part::StreamPart; -use aimux_core::tool::FunctionTool; -use aimux_core::types::FinishReasonUnified; - -use aimux_providers::{CohereConfig, CohereProvider}; - -// ── helpers ───────────────────────────────────────────────────────────────── - -/// The TS `TEST_PROMPT`: a system message + a user text message. -fn test_prompt() -> LanguageModelPrompt { - vec![ - LanguageModelPromptMessage { - role: Role::System, - content: vec![ContentPart::text("you are a friendly bot!")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }, - ] -} - -fn default_options(prompt: LanguageModelPrompt) -> CallOptions { - CallOptions::new(prompt) -} - -fn test_tool() -> Tool { - Tool::Function(FunctionTool { - name: "test-tool".to_string(), - description: None, - input_schema: json!({ - "type": "object", - "properties": { "value": { "type": "string" } }, - "required": ["value"], - "additionalProperties": false, - "$schema": "http://json-schema.org/draft-07/schema#" - }), - strict: None, - provider_options: None, - input_examples: None, - }) -} - -async fn mock_json_response(server: &MockServer, body: Value) { - Mock::given(method("POST")) - .and(path("/chat")) - .respond_with(ResponseTemplate::new(200).set_body_json(body)) - .mount(server) - .await; -} - -async fn mock_sse_response(server: &MockServer, sse_body: &str) { - Mock::given(method("POST")) - .and(path("/chat")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(sse_body.to_string()), - ) - .mount(server) - .await; -} - -/// Build a Cohere SSE event from a JSON value (with `event:` field). -fn cohere_event(json_str: &str) -> String { - // Parse to get the type, then format as `event: type\ndata: json\n\n`. - if let Ok(val) = serde_json::from_str::(json_str) { - let event_type = val - .get("type") - .and_then(|t| t.as_str()) - .unwrap_or("message"); - format!("event: {event_type}\ndata: {json_str}\n\n") - } else { - format!("event: unknown\ndata: {json_str}\n\n") - } -} - -/// Concatenate Cohere SSE events. -fn cohere_sse_body(events: &[&str]) -> String { - let mut body = String::new(); - for event in events { - body.push_str(&cohere_event(event)); - } - body -} - -async fn collect_stream(result: StreamResult) -> Vec { - let mut parts = Vec::new(); - let mut stream = result.stream; - while let Some(part) = stream.next().await { - match part { - Ok(p) => parts.push(p), - Err(e) => panic!("stream error: {e:?}"), - } - } - parts -} - -fn text_deltas(parts: &[StreamPart]) -> Vec { - parts - .iter() - .filter_map(|p| match p { - StreamPart::TextDelta { delta, .. } => Some(delta.clone()), - _ => None, - }) - .collect() -} - -// ════════════════════════════════════════════════════════════════════════════ -// doGenerate -// ════════════════════════════════════════════════════════════════════════════ - -/// TS: "should extract text response" -#[tokio::test] -async fn should_extract_text_response() { - let server = MockServer::start().await; - mock_json_response( - &server, - json!({ - "id": "e7592632-1e3d-424f-b129-bd5f9f980f7b", - "message": { - "role": "assistant", - "content": [ - { "type": "text", "text": "The capital of France is Paris." } - ] - }, - "finish_reason": "COMPLETE", - "usage": { - "billed_units": { "input_tokens": 12, "output_tokens": 7 }, - "tokens": { "input_tokens": 507, "output_tokens": 10 } - } - }), - ) - .await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!(result.content.len(), 1); - match &result.content[0] { - GenerateContent::Text { text, .. } => { - assert_eq!(text, "The capital of France is Paris."); - } - other => panic!("expected Text, got {other:?}"), - } - assert_eq!(result.finish_reason.unified, FinishReasonUnified::Stop); - assert_eq!(result.finish_reason.raw.as_deref(), Some("COMPLETE")); -} - -/// TS: "should map MAX_TOKENS finish reason to length" -#[tokio::test] -async fn should_map_max_tokens_finish_reason() { - let server = MockServer::start().await; - mock_json_response( - &server, - json!({ - "id": "max-tokens-id", - "message": { - "role": "assistant", - "content": [{ "type": "text", "text": "truncated" }] - }, - "finish_reason": "MAX_TOKENS", - "usage": { - "billed_units": { "input_tokens": 12, "output_tokens": 7 }, - "tokens": { "input_tokens": 507, "output_tokens": 10 } - } - }), - ) - .await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!(result.finish_reason.unified, FinishReasonUnified::Length); - assert_eq!(result.finish_reason.raw.as_deref(), Some("MAX_TOKENS")); -} - -/// TS: "should extract tool calls" -#[tokio::test] -async fn should_extract_tool_calls() { - let server = MockServer::start().await; - mock_json_response( - &server, - json!({ - "id": "f201af17-e24a-4396-8f6a-98e8bf9c3432", - "message": { - "role": "assistant", - "tool_plan": "I will use the weather tool.", - "tool_calls": [ - { - "id": "weather_dqgshstja6p9", - "type": "function", - "function": { - "name": "weather", - "arguments": "{\"location\":\"San Francisco\"}" - } - }, - { - "id": "cityAttractions_dcxfx4myvx68", - "type": "function", - "function": { - "name": "cityAttractions", - "arguments": "{\"city\":\"San Francisco\"}" - } - } - ] - }, - "finish_reason": "TOOL_CALL", - "usage": { - "billed_units": { "input_tokens": 119, "output_tokens": 52 }, - "tokens": { "input_tokens": 1549, "output_tokens": 103 } - } - }), - ) - .await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let options = CallOptions { - tools: Some(vec![test_tool()]), - ..default_options(test_prompt()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - - assert_eq!(result.content.len(), 2); - match &result.content[0] { - GenerateContent::ToolCall(RawToolCall { - tool_name, input, .. - }) => { - assert_eq!(tool_name, "weather"); - assert_eq!(input, r#"{"location":"San Francisco"}"#); - } - other => panic!("expected ToolCall, got {other:?}"), - } - match &result.content[1] { - GenerateContent::ToolCall(RawToolCall { - tool_name, input, .. - }) => { - assert_eq!(tool_name, "cityAttractions"); - assert_eq!(input, r#"{"city":"San Francisco"}"#); - } - other => panic!("expected ToolCall, got {other:?}"), - } - assert_eq!(result.finish_reason.unified, FinishReasonUnified::ToolCalls); -} - -/// TS: "should handle string 'null' tool call arguments" -#[tokio::test] -async fn should_handle_null_tool_call_arguments() { - let server = MockServer::start().await; - mock_json_response( - &server, - json!({ - "id": "null-args-id", - "message": { - "role": "assistant", - "tool_calls": [{ - "id": "currentTime_abc", - "type": "function", - "function": { - "name": "currentTime", - "arguments": "null" - } - }] - }, - "finish_reason": "TOOL_CALL", - "usage": { - "billed_units": { "input_tokens": 10, "output_tokens": 5 }, - "tokens": { "input_tokens": 20, "output_tokens": 10 } - } - }), - ) - .await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - assert_eq!(result.content.len(), 1); - match &result.content[0] { - GenerateContent::ToolCall(RawToolCall { input, .. }) => { - // "null" should be replaced with "{}". - assert_eq!(input, "{}"); - } - other => panic!("expected ToolCall, got {other:?}"), - } -} - -/// TS: "should extract usage" -#[tokio::test] -async fn should_extract_usage() { - let server = MockServer::start().await; - mock_json_response( - &server, - json!({ - "id": "test-id", - "message": { - "role": "assistant", - "content": [{ "type": "text", "text": "Hello" }] - }, - "finish_reason": "COMPLETE", - "usage": { - "billed_units": { "input_tokens": 12, "output_tokens": 7 }, - "tokens": { "input_tokens": 507, "output_tokens": 10 } - } - }), - ) - .await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - // Cohere uses `tokens` (not `billed_units`) for usage. - assert_eq!(result.usage.input_tokens.total, Some(507)); - assert_eq!(result.usage.input_tokens.no_cache, Some(507)); - assert_eq!(result.usage.output_tokens.total, Some(10)); -} - -/// TS: "should pass model and messages" -#[tokio::test] -async fn should_send_correct_request_body() { - let server = MockServer::start().await; - mock_json_response( - &server, - json!({ - "id": "test-id", - "message": { - "role": "assistant", - "content": [{ "type": "text", "text": "Hi" }] - }, - "finish_reason": "COMPLETE", - "usage": { - "billed_units": { "input_tokens": 12, "output_tokens": 7 }, - "tokens": { "input_tokens": 12, "output_tokens": 7 } - } - }), - ) - .await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - let body = result.request_body.expect("body"); - assert_eq!(body["model"], "command-r-plus"); - assert_eq!( - body["messages"], - json!([ - { "role": "system", "content": "you are a friendly bot!" }, - { "role": "user", "content": "Hello" } - ]) - ); - // No extra keys when options are unset. - assert!(body.get("max_tokens").is_none()); - assert!(body.get("temperature").is_none()); - assert!(body.get("tools").is_none()); - assert!(body.get("documents").is_none()); -} - -/// TS: "should pass tools" with tool_choice none → "NONE" -#[tokio::test] -async fn should_pass_tools_with_tool_choice_none() { - let server = MockServer::start().await; - mock_json_response( - &server, - json!({ - "id": "test-id", - "message": { - "role": "assistant", - "content": [{ "type": "text", "text": "ok" }] - }, - "finish_reason": "COMPLETE", - "usage": { - "billed_units": { "input_tokens": 12, "output_tokens": 7 }, - "tokens": { "input_tokens": 12, "output_tokens": 7 } - } - }), - ) - .await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let options = CallOptions { - tools: Some(vec![test_tool()]), - tool_choice: ToolChoice::None, - ..default_options(test_prompt()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - - assert_eq!(body["tool_choice"], json!("NONE")); - assert_eq!(body["tools"][0]["function"]["name"], "test-tool"); -} - -// ════════════════════════════════════════════════════════════════════════════ -// doStream -// ════════════════════════════════════════════════════════════════════════════ - -/// TS: "should stream text deltas" -#[tokio::test] -async fn should_stream_text_deltas() { - let server = MockServer::start().await; - let sse = cohere_sse_body(&[ - r#"{"id":"321d178c-2c12-44d3-ae42-2f5510f6b1cc","type":"message-start","delta":{"message":{"role":"assistant","content":[],"tool_plan":"","tool_calls":[],"citations":[]}}}"#, - r#"{"type":"content-start","index":0,"delta":{"message":{"content":{"type":"text","text":""}}}}"#, - r#"{"type":"content-delta","index":0,"delta":{"message":{"content":{"text":"The"}}}}"#, - r#"{"type":"content-delta","index":0,"delta":{"message":{"content":{"text":" capital"}}}}"#, - r#"{"type":"content-delta","index":0,"delta":{"message":{"content":{"text":" of"}}}}"#, - r#"{"type":"content-delta","index":0,"delta":{"message":{"content":{"text":" France"}}}}"#, - r#"{"type":"content-delta","index":0,"delta":{"message":{"content":{"text":" is"}}}}"#, - r#"{"type":"content-delta","index":0,"delta":{"message":{"content":{"text":" Paris"}}}}"#, - r#"{"type":"content-delta","index":0,"delta":{"message":{"content":{"text":"."}}}}"#, - r#"{"type":"content-end","index":0}"#, - r#"{"type":"message-end","delta":{"finish_reason":"COMPLETE","usage":{"billed_units":{"input_tokens":12,"output_tokens":7},"tokens":{"input_tokens":507,"output_tokens":10},"cached_tokens":448}}}"#, - ]); - mock_sse_response(&server, &sse).await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - - let parts = collect_stream(result).await; - - // StreamStart, ResponseMetadata, TextStart, TextDelta x7, TextEnd, Finish. - assert!(matches!( - parts.first(), - Some(StreamPart::StreamStart { .. }) - )); - - let deltas = text_deltas(&parts); - assert_eq!( - deltas, - vec!["The", " capital", " of", " France", " is", " Paris", "."] - ); - - // Check finish. - let finish = parts.last().expect("should have finish"); - match finish { - StreamPart::Finish { - finish_reason, - usage, - .. - } => { - assert_eq!(finish_reason.unified, FinishReasonUnified::Stop); - assert_eq!(usage.input_tokens.total, Some(507)); - assert_eq!(usage.output_tokens.total, Some(10)); - } - other => panic!("expected Finish, got {other:?}"), - } -} - -/// TS: "should stream tool deltas" -#[tokio::test] -async fn should_stream_tool_call_deltas() { - let server = MockServer::start().await; - let sse = cohere_sse_body(&[ - r#"{"id":"2941521a","type":"message-start","delta":{"message":{"role":"assistant","content":[],"tool_plan":"","tool_calls":[],"citations":[]}}}"#, - r#"{"type":"tool-call-start","index":0,"delta":{"message":{"tool_calls":{"id":"weather_e8p4pn45zt0t","type":"function","function":{"name":"weather","arguments":""}}}}}"#, - r#"{"type":"tool-call-delta","index":0,"delta":{"message":{"tool_calls":{"function":{"arguments":"{"}}}}}"#, - r#"{"type":"tool-call-delta","index":0,"delta":{"message":{"tool_calls":{"function":{"arguments":"\"location\": \"San Francisco\"}"}}}}}"#, - r#"{"type":"tool-call-end","index":0}"#, - r#"{"type":"message-end","delta":{"finish_reason":"TOOL_CALL","usage":{"billed_units":{"input_tokens":119,"output_tokens":44},"tokens":{"input_tokens":1549,"output_tokens":95},"cached_tokens":1504}}}"#, - ]); - mock_sse_response(&server, &sse).await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let options = CallOptions { - tools: Some(vec![test_tool()]), - ..default_options(test_prompt()) - }; - - let result = model.do_stream(&options).await.expect("should succeed"); - let parts = collect_stream(result).await; - - // Should have ToolInputStart, ToolInputDelta(s), ToolInputEnd, ToolCall. - let tool_call = parts.iter().find_map(|p| match p { - StreamPart::ToolCall(RawToolCall { - tool_call_id, - tool_name, - input, - .. - }) => Some((tool_call_id, tool_name, input)), - _ => None, - }); - let (id, name, input) = tool_call.expect("should have ToolCall"); - assert_eq!(id, "weather_e8p4pn45zt0t"); - assert_eq!(name, "weather"); - // Providers never parse tool input (Core owns that) — the flush forwards - // the accumulated deltas verbatim (only trimmed), so interior whitespace - // from the deltas survives. - assert_eq!(input, r#"{"location": "San Francisco"}"#); - - // Verify the accumulated deltas. - let deltas: Vec = parts - .iter() - .filter_map(|p| match p { - StreamPart::ToolInputDelta { delta, .. } => Some(delta.clone()), - _ => None, - }) - .collect(); - assert_eq!(deltas, vec!["{", "\"location\": \"San Francisco\"}"]); - - // Finish reason should be ToolCalls. - let finish = parts.last().expect("should have finish"); - match finish { - StreamPart::Finish { finish_reason, .. } => { - assert_eq!(finish_reason.unified, FinishReasonUnified::ToolCalls); - } - other => panic!("expected Finish, got {other:?}"), - } -} - -/// Regression test for PR #165 review finding: Cohere used to parse the -/// accumulated tool-call arguments itself and treat a parse failure as a -/// terminal stream error, unlike every other provider (which forwards raw -/// text and lets Core own parsing/validation/repair) and unlike Cohere's own -/// non-streaming path. Malformed streamed arguments must surface as a -/// retained `invalid: true` tool call, not abort the stream. -#[tokio::test] -async fn malformed_streamed_tool_call_arguments_do_not_error_the_stream() { - let server = MockServer::start().await; - let sse = cohere_sse_body(&[ - r#"{"id":"malformed-1","type":"message-start","delta":{"message":{"role":"assistant","content":[],"tool_plan":"","tool_calls":[],"citations":[]}}}"#, - r#"{"type":"tool-call-start","index":0,"delta":{"message":{"tool_calls":{"id":"tc_malformed","type":"function","function":{"name":"test-tool","arguments":""}}}}}"#, - r#"{"type":"tool-call-delta","index":0,"delta":{"message":{"tool_calls":{"function":{"arguments":"{\"value\":"}}}}}"#, - r#"{"type":"tool-call-end","index":0}"#, - r#"{"type":"message-end","delta":{"finish_reason":"TOOL_CALL","usage":{"billed_units":{"input_tokens":10,"output_tokens":5},"tokens":{"input_tokens":10,"output_tokens":5}}}}"#, - ]); - mock_sse_response(&server, &sse).await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - // First, confirm the raw provider stream itself never parses and never - // errors on the malformed arguments — it just forwards the text. - let raw_options = CallOptions { - tools: Some(vec![test_tool()]), - ..default_options(test_prompt()) - }; - let raw_parts = - collect_stream(model.do_stream(&raw_options).await.expect("should succeed")).await; - assert!( - !raw_parts - .iter() - .any(|part| matches!(part, StreamPart::Error { .. })), - "provider must not parse tool input itself: {raw_parts:?}" - ); - let raw_input = raw_parts.iter().find_map(|p| match p { - StreamPart::ToolCall(RawToolCall { input, .. }) => Some(input.clone()), - _ => None, - }); - assert_eq!( - raw_input, - Some(r#"{"value":"#.to_string()), - "provider must forward the malformed text verbatim" - ); - - // Then, through Core's `stream_text` (parse/validate/repair boundary), - // the malformed call surfaces as a retained invalid tool call — the - // stream itself completes normally. - let result = stream_text( - &model, - "test", - GenerateTextOptions { - tools: Some(vec![test_tool()]), - ..GenerateTextOptions::default() - }, - ) - .await - .expect("stream_text should start") - .consume() - .await - .expect("Core parsing keeps an invalid call, not a stream error"); - - let call = result - .tool_calls - .first() - .expect("should have one tool call"); - assert_eq!(call.invalid, Some(true)); - assert!(matches!( - call.error, - Some(aimux_core::error::AiMuxError::InvalidToolInput { .. }) - )); -} - -/// TS: "should stream reasoning deltas" -#[tokio::test] -async fn should_stream_reasoning_deltas() { - let server = MockServer::start().await; - let sse = cohere_sse_body(&[ - r#"{"id":"c9117d7f","type":"message-start","delta":{"message":{"role":"assistant","content":[],"tool_plan":"","tool_calls":[],"citations":[]}}}"#, - r#"{"type":"content-start","index":0,"delta":{"message":{"content":{"type":"thinking","thinking":""}}}}"#, - r#"{"type":"content-delta","index":0,"delta":{"message":{"content":{"thinking":"The user is asking"}}}}"#, - r#"{"type":"content-delta","index":0,"delta":{"message":{"content":{"thinking":" for 2+2."}}}}"#, - r#"{"type":"content-end","index":0}"#, - r#"{"type":"content-start","index":1,"delta":{"message":{"content":{"type":"text","text":""}}}}"#, - r#"{"type":"content-delta","index":1,"delta":{"message":{"content":{"text":"2 + 2 = 4"}}}}"#, - r#"{"type":"content-end","index":1}"#, - r#"{"type":"message-end","delta":{"finish_reason":"COMPLETE","usage":{"billed_units":{"input_tokens":8,"output_tokens":50},"tokens":{"input_tokens":1394,"output_tokens":54},"cached_tokens":1360}}}"#, - ]); - mock_sse_response(&server, &sse).await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("should succeed"); - - let parts = collect_stream(result).await; - - // Should have ReasoningStart, ReasoningDelta(s), ReasoningEnd. - let reasoning_deltas: Vec = parts - .iter() - .filter_map(|p| match p { - StreamPart::ReasoningDelta { delta, .. } => Some(delta.clone()), - _ => None, - }) - .collect(); - assert_eq!(reasoning_deltas, vec!["The user is asking", " for 2+2."]); - - // Should also have text deltas. - let text_deltas: Vec = parts - .iter() - .filter_map(|p| match p { - StreamPart::TextDelta { delta, .. } => Some(delta.clone()), - _ => None, - }) - .collect(); - assert_eq!(text_deltas, vec!["2 + 2 = 4"]); - - // Should have ReasoningStart and ReasoningEnd. - assert!( - parts - .iter() - .any(|p| matches!(p, StreamPart::ReasoningStart { .. })) - ); - assert!( - parts - .iter() - .any(|p| matches!(p, StreamPart::ReasoningEnd { .. })) - ); -} - -/// TS: "should pass the messages and the model" (streaming request body) -#[tokio::test] -async fn should_send_streaming_request_body() { - let server = MockServer::start().await; - let sse = cohere_sse_body(&[ - r#"{"id":"test","type":"message-start","delta":{"message":{"role":"assistant","content":[]}}}"#, - r#"{"type":"content-start","index":0,"delta":{"message":{"content":{"type":"text","text":""}}}}"#, - r#"{"type":"content-delta","index":0,"delta":{"message":{"content":{"text":"Hi"}}}"#, - r#"{"type":"content-end","index":0}"#, - r#"{"type":"message-end","delta":{"finish_reason":"COMPLETE","usage":{"billed_units":{"input_tokens":1,"output_tokens":1},"tokens":{"input_tokens":1,"output_tokens":1}}}}"#, - ]); - mock_sse_response(&server, &sse).await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("should succeed"); - - let body = result.request_body.expect("body"); - assert_eq!(body["model"], "command-r-plus"); - assert_eq!(body["stream"], json!(true)); - assert_eq!( - body["messages"], - json!([ - { "role": "system", "content": "you are a friendly bot!" }, - { "role": "user", "content": "Hello" } - ]) - ); -} - -/// TS: "should handle unparsable stream parts" -#[tokio::test] -async fn should_handle_unparsable_stream_parts() { - let server = MockServer::start().await; - let sse = cohere_sse_body(&[ - "{unparsable}", - r#"{"type":"content-start","index":0,"delta":{"message":{"content":{"type":"text","text":""}}}}"#, - r#"{"type":"content-delta","index":0,"delta":{"message":{"content":{"text":"after-error"}}}}"#, - r#"{"type":"content-end","index":0}"#, - r#"{"type":"message-end","delta":{"finish_reason":"COMPLETE","usage":{"billed_units":{"input_tokens":1,"output_tokens":1},"tokens":{"input_tokens":1,"output_tokens":1}}}}"#, - ]); - mock_sse_response(&server, &sse).await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - - let outcomes: Vec<_> = result.stream.collect().await; - - assert!(matches!( - outcomes.first(), - Some(Ok(StreamPart::StreamStart { .. })) - )); - let error_index = outcomes - .iter() - .position(|outcome| { - matches!( - outcome, - Err(AiMuxError::JsonParse(_) | AiMuxError::InvalidResponseData(_)) - ) - }) - .expect("malformed frame should surface as a parse error item"); - assert!(outcomes[error_index + 1..].iter().any(|outcome| matches!( - outcome, - Ok(StreamPart::TextDelta { delta, .. }) if delta == "after-error" - ))); - assert!(outcomes.iter().any(|outcome| matches!( - outcome, - Ok(StreamPart::Finish { finish_reason, .. }) - if finish_reason.unified == FinishReasonUnified::Stop - ))); -} - -/// TS: "should handle 401 auth error" -#[tokio::test] -async fn should_handle_auth_error() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat")) - .respond_with( - ResponseTemplate::new(401).set_body_json(json!({ "message": "Invalid API key" })), - ) - .mount(&server) - .await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let result = model.do_generate(&default_options(test_prompt())).await; - - assert!(result.is_err()); - let err = result.unwrap_err(); - assert_eq!(err.status_code(), Some(401), "got {err:?}"); - assert!(err.to_string().contains("Invalid API key")); -} - -// ════════════════════════════════════════════════════════════════════════════ -// Additional doGenerate cases — headers, response format, response metadata, -// tool_choice variants, file→documents, full request body, errors. -// ════════════════════════════════════════════════════════════════════════════ - -/// A minimal Cohere "ok" response reused by request-body assertions. -fn ok_cohere_body() -> Value { - json!({ - "id": "test-id", - "generation_id": "gen-123", - "message": { - "role": "assistant", - "content": [{ "type": "text", "text": "ok" }] - }, - "finish_reason": "COMPLETE", - "usage": { - "billed_units": { "input_tokens": 12, "output_tokens": 7 }, - "tokens": { "input_tokens": 12, "output_tokens": 7 } - } - }) -} - -/// TS: "should pass headers" — request-level custom headers reach the server. -#[tokio::test] -async fn should_pass_request_headers() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat")) - .and(wiremock::matchers::header( - "custom-request-header", - "request-header-value", - )) - .respond_with(ResponseTemplate::new(200).set_body_json(ok_cohere_body())) - .mount(&server) - .await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let mut headers = std::collections::HashMap::new(); - headers.insert( - "Custom-Request-Header".to_string(), - "request-header-value".to_string(), - ); - - let options = CallOptions { - prompt: test_prompt(), - headers: Some(headers), - ..default_options(Vec::new()) - }; - - let result = model.do_generate(&options).await; - assert!(result.is_ok(), "request should match the header mock"); -} - -/// TS: "should expose the raw response headers" -#[tokio::test] -async fn should_expose_raw_response_headers() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("test-header", "test-value") - .set_body_json(ok_cohere_body()), - ) - .mount(&server) - .await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - let headers = result - .response_headers - .as_ref() - .expect("response_headers should be Some"); - assert_eq!(headers.get("test-header"), Some(&"test-value".to_string())); -} - -/// TS: "should send additional response information" — generation_id → response.id -#[tokio::test] -async fn should_send_additional_response_information() { - let server = MockServer::start().await; - mock_json_response(&server, ok_cohere_body()).await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - assert_eq!(result.response.id.as_deref(), Some("gen-123")); -} - -/// TS: "should pass response format" — JSON schema → Cohere response_format. -#[tokio::test] -async fn should_pass_response_format() { - let server = MockServer::start().await; - mock_json_response(&server, ok_cohere_body()).await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let schema = json!({ - "type": "object", - "properties": { "text": { "type": "string" } }, - "required": ["text"] - }); - let options = CallOptions { - prompt: test_prompt(), - response_format: Some(aimux_core::options::ResponseFormat::Json { - schema: Some(schema.clone()), - name: None, - description: None, - }), - ..default_options(Vec::new()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - assert_eq!( - body["response_format"], - json!({ "type": "json_object", "json_schema": schema }) - ); -} - -/// TS: "should pass tools" with tool_choice required → "REQUIRED" -#[tokio::test] -async fn should_pass_tools_with_tool_choice_required() { - let server = MockServer::start().await; - mock_json_response(&server, ok_cohere_body()).await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let options = CallOptions { - prompt: test_prompt(), - tools: Some(vec![test_tool()]), - tool_choice: ToolChoice::Required, - ..default_options(Vec::new()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - assert_eq!(body["tool_choice"], json!("REQUIRED")); - assert_eq!(body["tools"][0]["function"]["name"], "test-tool"); -} - -/// TS: "should pass tools" with tool_choice tool → "REQUIRED" + filtered tools -#[tokio::test] -async fn should_pass_tools_with_tool_choice_tool() { - let server = MockServer::start().await; - mock_json_response(&server, ok_cohere_body()).await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let options = CallOptions { - prompt: test_prompt(), - tools: Some(vec![test_tool()]), - tool_choice: ToolChoice::Tool { - tool_name: "test-tool".to_string(), - }, - ..default_options(Vec::new()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - assert_eq!(body["tool_choice"], json!("REQUIRED")); - assert_eq!(body["tools"].as_array().unwrap().len(), 1); - assert_eq!(body["tools"][0]["function"]["name"], "test-tool"); -} - -/// TS: "should extract text documents and send to API" — non-image File parts -/// become Cohere RAG `documents`. The Rust `ContentPart::File` carries no -/// filename, so `title` is omitted (unlike TS). -#[tokio::test] -async fn should_extract_text_documents_from_file_parts() { - let server = MockServer::start().await; - mock_json_response(&server, ok_cohere_body()).await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ - ContentPart::text("What does this say?"), - ContentPart::file( - b"This is a test document.".to_vec(), - "text/plain".to_string(), - ), - ], - ..Default::default() - }]; - - let result = model - .do_generate(&default_options(prompt)) - .await - .expect("should succeed"); - let body = result.request_body.expect("body"); - assert_eq!( - body["documents"], - json!([{ "data": { "text": "This is a test document." } }]) - ); - assert_eq!(body["messages"][0]["role"], json!("user")); - assert_eq!(body["messages"][0]["content"], json!("What does this say?")); -} - -// ════════════════════════════════════════════════════════════════════════════ -// citations — translated from the TS `describe('citations', ...)` block. -// -// TS has 7 citations cases; `should_extract_text_documents_from_file_parts` -// above already covers "should extract text documents and send to API". The -// 6 tests below cover the remaining cases: response-side citation extraction, -// multiple/JSON/PDF/markdown file→document conversion, and the no-files path. -// ════════════════════════════════════════════════════════════════════════════ - -/// The Cohere `cohere-citations.json` fixture: an assistant message with three -/// TEXT_CONTENT citations, all sourced from `doc:0` (title `benefits.txt`). -fn cohere_citations_response() -> Value { - let citation = |start: u64, end: u64, text: &str| { - json!({ - "start": start, - "end": end, - "text": text, - "sources": [{ - "type": "document", - "id": "doc:0", - "document": { - "id": "doc:0", - "text": "AI provides: 1. Automation of tasks 2. Better decision-making 3. Cost reduction", - "title": "benefits.txt" - } - }], - "type": "TEXT_CONTENT" - }) - }; - json!({ - "id": "68475c80-574b-4c65-98a4-e81cebab5dce", - "message": { - "role": "assistant", - "content": [{ - "type": "text", - "text": "The key benefits mentioned in this document are:\n1. Automation of tasks\n2. Better decision-making\n3. Cost reduction" - }], - "citations": [ - citation(52, 71, "Automation of tasks"), - citation(75, 97, "Better decision-making"), - citation(101, 115, "Cost reduction") - ] - }, - "finish_reason": "COMPLETE", - "usage": { - "billed_units": { "input_tokens": 39, "output_tokens": 27 }, - "tokens": { "input_tokens": 1683, "output_tokens": 62 }, - "cached_tokens": 992 - } - }) -} - -/// TS: "should extract citations from response" — each citation becomes a -/// `Source` content item. The Rust `GenerateContent::Source` variant carries no -/// `mediaType`/`providerMetadata`, so only `source_type`/`title` are asserted -/// (the rich start/end/text/sources/citationType metadata is a documented gap). -/// The TS id comes from a mocked `generateId`; Rust has no such hook, so ids are -/// positional (`citation-{i}`). -#[tokio::test] -async fn should_extract_citations_from_response() { - let server = MockServer::start().await; - mock_json_response(&server, cohere_citations_response()).await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ - ContentPart::text("What are AI benefits?"), - ContentPart::File { - data: b"AI provides automation and efficiency.".to_vec(), - media_type: "text/plain".to_string(), - filename: Some("ai-benefits.txt".to_string()), - provider_options: None, - }, - ], - ..Default::default() - }]; - - let result = model - .do_generate(&default_options(prompt)) - .await - .expect("should succeed"); - - // 1 text + 3 citation sources. - assert_eq!(result.content.len(), 4); - match &result.content[0] { - GenerateContent::Text { text, .. } => { - assert_eq!( - text, - "The key benefits mentioned in this document are:\n\ - 1. Automation of tasks\n\ - 2. Better decision-making\n\ - 3. Cost reduction" - ); - } - other => panic!("expected Text, got {other:?}"), - } - for (i, c) in result.content[1..].iter().enumerate() { - match c { - GenerateContent::Source(Source { - id, - source_type, - url, - title, - .. - }) => { - assert_eq!(id, &format!("citation-{i}")); - assert_eq!(source_type, "document"); - assert_eq!(url, &None); - assert_eq!(title, &Some("benefits.txt".to_string())); - } - other => panic!("expected Source, got {other:?}"), - } - } - - assert_eq!(result.finish_reason.unified, FinishReasonUnified::Stop); - assert_eq!(result.finish_reason.raw.as_deref(), Some("COMPLETE")); - assert_eq!(result.usage.input_tokens.total, Some(1683)); - assert_eq!(result.usage.output_tokens.total, Some(62)); -} - -/// TS: "should extract multiple text documents" — two file parts become two -/// RAG documents, each carrying its filename as `title`. -#[tokio::test] -async fn should_extract_multiple_text_documents() { - let server = MockServer::start().await; - mock_json_response(&server, ok_cohere_body()).await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ - ContentPart::text("What do these documents say?"), - ContentPart::File { - data: b"First document content".to_vec(), - media_type: "text/plain".to_string(), - filename: Some("doc1.txt".to_string()), - provider_options: None, - }, - ContentPart::File { - data: b"Second document content".to_vec(), - media_type: "text/plain".to_string(), - filename: Some("doc2.txt".to_string()), - provider_options: None, - }, - ], - ..Default::default() - }]; - - let result = model - .do_generate(&default_options(prompt)) - .await - .expect("should succeed"); - let body = result.request_body.expect("body"); - - assert_eq!( - body["documents"], - json!([ - { "data": { "text": "First document content", "title": "doc1.txt" } }, - { "data": { "text": "Second document content", "title": "doc2.txt" } } - ]) - ); - assert_eq!(body["messages"][0]["role"], json!("user")); - assert_eq!( - body["messages"][0]["content"], - json!("What do these documents say?") - ); - assert_eq!(body["model"], json!("command-r-plus")); -} - -/// TS: "should support JSON files" — an `application/json` file part becomes a -/// RAG document whose `text` is the raw JSON string and `title` is the filename. -#[tokio::test] -async fn should_support_json_files() { - let server = MockServer::start().await; - mock_json_response(&server, ok_cohere_body()).await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ - ContentPart::text("What is in this JSON?"), - ContentPart::File { - data: b"{\"key\": \"value\"}".to_vec(), - media_type: "application/json".to_string(), - filename: Some("data.json".to_string()), - provider_options: None, - }, - ], - ..Default::default() - }]; - - let result = model - .do_generate(&default_options(prompt)) - .await - .expect("should succeed"); - let body = result.request_body.expect("body"); - - assert_eq!( - body["documents"], - json!([{ "data": { "text": "{\"key\": \"value\"}", "title": "data.json" } }]) - ); - assert_eq!( - body["messages"][0]["content"], - json!("What is in this JSON?") - ); - assert_eq!(body["model"], json!("command-r-plus")); -} - -/// TS: "should not include mediaType in the outgoing payload (category D)" — a -/// non-text/non-image file (PDF) still becomes a RAG document, and neither the -/// media type nor the `mediaType` key leaks into the serialized request body. -#[tokio::test] -async fn should_not_include_mediatype_in_outgoing_payload() { - let server = MockServer::start().await; - mock_json_response(&server, ok_cohere_body()).await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ - ContentPart::text("What is this?"), - ContentPart::File { - data: b"Some file content".to_vec(), - media_type: "application/pdf".to_string(), - filename: Some("document.pdf".to_string()), - provider_options: None, - }, - ], - ..Default::default() - }]; - - let result = model - .do_generate(&default_options(prompt)) - .await - .expect("should succeed"); - let body = result.request_body.expect("body"); - - assert_eq!( - body["documents"], - json!([{ "data": { "text": "Some file content", "title": "document.pdf" } }]) - ); - let serialized = body.to_string(); - assert!( - !serialized.contains("application/pdf"), - "mediaType must not leak into payload: {serialized}" - ); - assert!( - !serialized.contains("mediaType"), - "mediaType key must not leak into payload: {serialized}" - ); -} - -/// TS: "should successfully process supported text media types" — `text/plain` -/// and `text/markdown` files both become RAG documents with their content and -/// filename-derived title. -#[tokio::test] -async fn should_process_supported_text_media_types() { - let server = MockServer::start().await; - mock_json_response(&server, ok_cohere_body()).await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ - ContentPart::text("What is this?"), - ContentPart::File { - data: b"This is plain text content".to_vec(), - media_type: "text/plain".to_string(), - filename: Some("text.txt".to_string()), - provider_options: None, - }, - ContentPart::File { - data: b"# Markdown Header\nContent".to_vec(), - media_type: "text/markdown".to_string(), - filename: Some("doc.md".to_string()), - provider_options: None, - }, - ], - ..Default::default() - }]; - - let result = model - .do_generate(&default_options(prompt)) - .await - .expect("should succeed"); - let body = result.request_body.expect("body"); - - assert_eq!( - body["documents"], - json!([ - { "data": { "text": "This is plain text content", "title": "text.txt" } }, - { "data": { "text": "# Markdown Header\nContent", "title": "doc.md" } } - ]) - ); -} - -/// TS: "should not include documents parameter when no files present" — a plain -/// text prompt produces a request body with no `documents` key. -#[tokio::test] -async fn should_not_include_documents_when_no_files_present() { - let server = MockServer::start().await; - mock_json_response(&server, ok_cohere_body()).await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - let body = result.request_body.expect("body"); - - assert!( - body.get("documents").is_none(), - "documents must be absent when no files are present: {body}" - ); -} - -/// TS: "should send request body" — verify all standard optional fields. -#[tokio::test] -async fn should_send_full_request_body() { - let server = MockServer::start().await; - mock_json_response(&server, ok_cohere_body()).await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let options = CallOptions { - prompt: test_prompt(), - max_output_tokens: Some(100), - temperature: Some(0.5), - top_p: Some(0.9), - top_k: Some(40.0), - seed: Some(7), - stop_sequences: Some(vec!["END".to_string()]), - frequency_penalty: Some(0.1), - presence_penalty: Some(0.2), - ..default_options(Vec::new()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - assert_eq!(body["model"], "command-r-plus"); - assert_eq!(body["max_tokens"], json!(100)); - assert!((body["temperature"].as_f64().unwrap() - 0.5).abs() < 1e-6); - assert!((body["p"].as_f64().unwrap() - 0.9).abs() < 1e-6); - assert_eq!(body["k"], json!(40.0)); - assert_eq!(body["seed"], json!(7)); - assert_eq!(body["stop_sequences"], json!(["END"])); - assert!((body["frequency_penalty"].as_f64().unwrap() - 0.1).abs() < 1e-6); - assert!((body["presence_penalty"].as_f64().unwrap() - 0.2).abs() < 1e-6); - assert!(body.get("tools").is_none()); - assert!(body.get("response_format").is_none()); -} - -/// TS: a 429 response maps to `AiMuxError::ApiCall` (429 in `status_code`). -#[tokio::test] -async fn should_handle_rate_limit_error() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat")) - .respond_with( - ResponseTemplate::new(429).set_body_json(json!({ "message": "Too many requests" })), - ) - .mount(&server) - .await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let result = model.do_generate(&default_options(test_prompt())).await; - assert!( - matches!(result, Err(ref e) if e.status_code() == Some(429)), - "expected a 429, got {result:?}" - ); -} - -// ════════════════════════════════════════════════════════════════════════════ -// Additional doStream cases — headers, response headers, empty tool args. -// ════════════════════════════════════════════════════════════════════════════ - -/// A minimal Cohere streaming text body reused by streaming assertions. -fn cohere_text_sse() -> String { - cohere_sse_body(&[ - r#"{"id":"test","type":"message-start","delta":{"message":{"role":"assistant","content":[]}}}"#, - r#"{"type":"content-start","index":0,"delta":{"message":{"content":{"type":"text","text":""}}}}"#, - r#"{"type":"content-delta","index":0,"delta":{"message":{"content":{"text":"Hi"}}}"#, - r#"{"type":"content-end","index":0}"#, - r#"{"type":"message-end","delta":{"finish_reason":"COMPLETE","usage":{"billed_units":{"input_tokens":1,"output_tokens":1},"tokens":{"input_tokens":1,"output_tokens":1}}}}"#, - ]) -} - -/// TS: "should pass headers" (streaming) — request-level custom headers. -#[tokio::test] -async fn should_pass_request_headers_stream() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat")) - .and(wiremock::matchers::header( - "custom-stream-header", - "stream-value", - )) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(cohere_text_sse()), - ) - .mount(&server) - .await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let mut headers = std::collections::HashMap::new(); - headers.insert( - "Custom-Stream-Header".to_string(), - "stream-value".to_string(), - ); - - let options = CallOptions { - prompt: test_prompt(), - headers: Some(headers), - ..default_options(Vec::new()) - }; - - let result = model.do_stream(&options).await; - assert!(result.is_ok(), "request should match the header mock"); -} - -/// TS: "should expose the raw response headers" (streaming) -#[tokio::test] -async fn should_expose_raw_response_headers_stream() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .insert_header("test-header", "test-value") - .set_body_string(cohere_text_sse()), - ) - .mount(&server) - .await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - - let headers = result - .response_headers - .as_ref() - .expect("response_headers should be Some"); - assert_eq!(headers.get("test-header"), Some(&"test-value".to_string())); -} - -/// TS: "should handle empty tool call arguments" (streaming) — empty arguments -/// string parses to an empty object. -#[tokio::test] -async fn should_stream_empty_tool_call_arguments() { - let server = MockServer::start().await; - let sse = cohere_sse_body(&[ - r#"{"id":"empty-args","type":"message-start","delta":{"message":{"role":"assistant","content":[],"tool_plan":"","tool_calls":[],"citations":[]}}}"#, - r#"{"type":"tool-call-start","index":0,"delta":{"message":{"tool_calls":{"id":"tc_empty","type":"function","function":{"name":"doThing","arguments":""}}}}}"#, - r#"{"type":"tool-call-end","index":0}"#, - r#"{"type":"message-end","delta":{"finish_reason":"TOOL_CALL","usage":{"billed_units":{"input_tokens":5,"output_tokens":2},"tokens":{"input_tokens":5,"output_tokens":2}}}}"#, - ]); - mock_sse_response(&server, &sse).await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let options = CallOptions { - tools: Some(vec![test_tool()]), - ..default_options(test_prompt()) - }; - - let result = model.do_stream(&options).await.expect("should succeed"); - let parts = collect_stream(result).await; - - let tool_call = parts.iter().find_map(|p| match p { - StreamPart::ToolCall(RawToolCall { - tool_call_id, - tool_name, - input, - .. - }) => Some((tool_call_id.clone(), tool_name.clone(), input.clone())), - _ => None, - }); - let (id, name, input) = tool_call.expect("should have ToolCall"); - assert_eq!(id, "tc_empty"); - assert_eq!(name, "doThing"); - assert_eq!(input, "{}"); - - let finish = parts.last().expect("should have finish"); - match finish { - StreamPart::Finish { finish_reason, .. } => { - assert_eq!(finish_reason.unified, FinishReasonUnified::ToolCalls); - } - other => panic!("expected Finish, got {other:?}"), - } -} - -/// TS: a 429 HTTP response surfaces as `AiMuxError::ApiCall` (429 in `status_code`) from do_stream. -#[tokio::test] -async fn should_stream_rate_limit_error() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat")) - .respond_with( - ResponseTemplate::new(429).set_body_json(json!({ "message": "Too many requests" })), - ) - .mount(&server) - .await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let result = model.do_stream(&default_options(test_prompt())).await; - assert!( - matches!(result, Err(ref e) if e.status_code() == Some(429)), - "expected a 429, got {result:?}" - ); -} diff --git a/aimux-providers/tests/cohere_remaining_test.rs b/aimux-providers/tests/cohere_remaining_test.rs deleted file mode 100644 index a894c7d04..000000000 --- a/aimux-providers/tests/cohere_remaining_test.rs +++ /dev/null @@ -1,767 +0,0 @@ -//! Remaining Cohere tests translated from TS that are NOT already covered by -//! `cohere_model_test.rs`. Covers: -//! -//! - `cohere-prepare-tools.test.ts` — all 7 cases (prepare_tools unit tests) -//! - `convert-to-cohere-chat-prompt.test.ts` — 9 cases (prompt conversion unit -//! tests): file→documents, image processing, tool messages, provider -//! references -//! - `cohere-chat-language-model.test.ts` — 5 uncovered scenarios: reasoning -//! extraction, top-level reasoning→thinking, providerOptions thinking, -//! assistant tool-call prompt conversion, tool-result prompt conversion - -use serde_json::{Value, json}; -use wiremock::matchers::{method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -use aimux_core::content::ContentPart; -use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; -use aimux_core::options::{CallOptions, Tool, ToolChoice}; -use aimux_core::result::{GenerateContent, ReasoningOutput}; -use aimux_core::tool::{FunctionTool, ProviderTool}; -use aimux_core::types::{FinishReasonUnified, ReasoningEffort}; - -use aimux_providers::cohere::convert::{ - convert_prompt_to_cohere, prepare_tools, resolve_cohere_thinking, -}; -use aimux_providers::{CohereConfig, CohereProvider}; - -// ── helpers ───────────────────────────────────────────────────────────────── - -fn ok_cohere_body() -> Value { - json!({ - "id": "test-id", - "generation_id": "gen-123", - "message": { - "role": "assistant", - "content": [{ "type": "text", "text": "ok" }] - }, - "finish_reason": "COMPLETE", - "usage": { - "billed_units": { "input_tokens": 12, "output_tokens": 7 }, - "tokens": { "input_tokens": 12, "output_tokens": 7 } - } - }) -} - -async fn mock_json_response(server: &MockServer, body: Value) { - Mock::given(method("POST")) - .and(path("/chat")) - .respond_with(ResponseTemplate::new(200).set_body_json(body)) - .mount(server) - .await; -} - -fn default_options(prompt: LanguageModelPrompt) -> CallOptions { - CallOptions::new(prompt) -} - -fn test_prompt() -> LanguageModelPrompt { - vec![ - LanguageModelPromptMessage { - role: Role::System, - content: vec![ContentPart::text("you are a friendly bot!")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }, - ] -} - -fn basic_function_tool() -> Tool { - Tool::Function(FunctionTool { - name: "testFunction".to_string(), - description: Some("test description".to_string()), - input_schema: json!({ "type": "object", "properties": {} }), - strict: None, - provider_options: None, - input_examples: None, - }) -} - -// ════════════════════════════════════════════════════════════════════════════ -// cohere-prepare-tools.test.ts -// ════════════════════════════════════════════════════════════════════════════ - -/// TS: "should return undefined tools when no tools are provided" -#[test] -fn prepare_tools_empty_returns_none() { - let result = prepare_tools(&Some(vec![]), None); - assert!(result.tools.is_none()); - assert!(result.tool_choice.is_none()); - assert!(result.tool_warnings.is_empty()); -} - -/// TS: "should process function tools correctly" -#[test] -fn prepare_tools_function_tool() { - let result = prepare_tools(&Some(vec![basic_function_tool()]), None); - assert_eq!( - result.tools, - Some(vec![json!({ - "type": "function", - "function": { - "name": "testFunction", - "description": "test description", - "parameters": { "type": "object", "properties": {} } - } - })]) - ); - assert!(result.tool_choice.is_none()); - assert!(result.tool_warnings.is_empty()); -} - -/// TS: "should add warnings for provider-defined tools" -#[test] -fn prepare_tools_provider_tool_warns() { - let provider_tool = Tool::Provider(ProviderTool { - id: "provider.tool".to_string(), - name: "tool".to_string(), - args: json!({}), - }); - let result = prepare_tools(&Some(vec![provider_tool]), None); - - // Tools list is empty (provider tool was dropped) → tools is None. - assert!(result.tools.is_none()); - assert!(result.tool_choice.is_none()); - assert_eq!(result.tool_warnings.len(), 1); - match &result.tool_warnings[0] { - aimux_core::types::Warning::Unsupported { feature, .. } => { - assert_eq!(feature, "provider-defined tool provider.tool"); - } - other => panic!("expected Unsupported warning, got {other:?}"), - } -} - -/// TS: "should handle auto tool choice" -#[test] -fn prepare_tools_auto_choice() { - let result = prepare_tools(&Some(vec![basic_function_tool()]), Some(&ToolChoice::Auto)); - assert!(result.tool_choice.is_none()); - assert!(result.tools.is_some()); -} - -/// TS: "should handle none tool choice" -#[test] -fn prepare_tools_none_choice() { - let result = prepare_tools(&Some(vec![basic_function_tool()]), Some(&ToolChoice::None)); - assert_eq!(result.tool_choice, Some(json!("NONE"))); - assert!(result.tools.is_some()); -} - -/// TS: "should handle required tool choice" -#[test] -fn prepare_tools_required_choice() { - let result = prepare_tools( - &Some(vec![basic_function_tool()]), - Some(&ToolChoice::Required), - ); - assert_eq!(result.tool_choice, Some(json!("REQUIRED"))); - assert!(result.tools.is_some()); -} - -/// TS: "should handle tool type tool choice by filtering tools" -#[test] -fn prepare_tools_tool_choice_filters() { - let result = prepare_tools( - &Some(vec![basic_function_tool()]), - Some(&ToolChoice::Tool { - tool_name: "testFunction".to_string(), - }), - ); - assert_eq!(result.tool_choice, Some(json!("REQUIRED"))); - let tools = result.tools.expect("tools should be Some"); - assert_eq!(tools.len(), 1); - assert_eq!(tools[0]["function"]["name"], json!("testFunction")); -} - -// ════════════════════════════════════════════════════════════════════════════ -// convert-to-cohere-chat-prompt.test.ts — file processing -// ════════════════════════════════════════════════════════════════════════════ - -/// TS: "should extract documents from file parts" -#[test] -fn convert_file_parts_to_documents() { - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ - ContentPart::text("Analyze this file: "), - ContentPart::File { - data: b"This is file content".to_vec(), - media_type: "text/plain".to_string(), - filename: Some("test.txt".to_string()), - provider_options: None, - }, - ], - ..Default::default() - }]; - - let result = convert_prompt_to_cohere(&prompt); - assert_eq!( - result.messages, - vec![json!({ "role": "user", "content": "Analyze this file: " })] - ); - assert_eq!( - result.documents, - vec![json!({ "data": { "text": "This is file content", "title": "test.txt" } })] - ); -} - -/// TS: "should accept top-level-only mediaType without error (category D)" -/// — mediaType "text" (no `/`) is accepted; the file still becomes a document. -#[test] -fn convert_file_with_top_level_only_mediatype() { - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::File { - data: b"This is file content".to_vec(), - media_type: "text".to_string(), - filename: Some("test.txt".to_string()), - provider_options: None, - }], - ..Default::default() - }]; - - let result = convert_prompt_to_cohere(&prompt); - // No text part → content is empty string. - assert_eq!( - result.messages, - vec![json!({ "role": "user", "content": "" })] - ); - assert_eq!( - result.documents, - vec![json!({ "data": { "text": "This is file content", "title": "test.txt" } })] - ); -} - -/// TS: "should not read mediaType (document payload carries only text + title)" -#[test] -fn convert_pdf_file_omits_mediatype() { - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::File { - data: b"PDF-like content".to_vec(), - media_type: "application/pdf".to_string(), - filename: Some("test.pdf".to_string()), - provider_options: None, - }], - ..Default::default() - }]; - - let result = convert_prompt_to_cohere(&prompt); - assert_eq!( - result.documents, - vec![json!({ "data": { "text": "PDF-like content", "title": "test.pdf" } })] - ); - let payload = serde_json::to_string(&result.documents).unwrap(); - assert!(!payload.contains("application/pdf")); - assert!(!payload.contains("mediaType")); -} - -// ════════════════════════════════════════════════════════════════════════════ -// convert-to-cohere-chat-prompt.test.ts — image processing -// ════════════════════════════════════════════════════════════════════════════ - -/// TS: "should convert image file with data bytes into image_url data URI" -#[test] -fn convert_image_file_to_data_uri() { - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ - ContentPart::text("What is in this image?"), - ContentPart::File { - data: vec![0, 1, 2, 3], - media_type: "image/png".to_string(), - filename: None, - provider_options: None, - }, - ], - ..Default::default() - }]; - - let result = convert_prompt_to_cohere(&prompt); - // With an image, content becomes an array of parts. - let msg = &result.messages[0]; - assert_eq!(msg["role"], json!("user")); - let content = msg["content"].as_array().expect("content should be array"); - assert_eq!(content.len(), 2); - assert_eq!( - content[0], - json!({ "type": "text", "text": "What is in this image?" }) - ); - assert_eq!( - content[1], - json!({ - "type": "image_url", - "image_url": { "url": "data:image/png;base64,AAECAw==" } - }) - ); - assert!(result.documents.is_empty()); -} - -/// TS: "should pass through detail provider option as image_url.detail" -#[test] -fn convert_image_file_with_detail_provider_option() { - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::File { - data: vec![0, 1, 2, 3], - media_type: "image/png".to_string(), - filename: None, - provider_options: Some(aimux_core::shared::provider_namespace( - "cohere", - json!({ "detail": "high" }), - )), - }], - ..Default::default() - }]; - - let result = convert_prompt_to_cohere(&prompt); - // The Rust convert does not parse `cohere.detail` from provider_options - // on File parts — this is a documented data-model gap. We verify the image - // URL is still produced correctly. - let msg = &result.messages[0]; - let content = msg["content"].as_array().expect("content should be array"); - assert_eq!(content[0]["type"], json!("image_url")); - assert!( - content[0]["image_url"]["url"] - .as_str() - .unwrap() - .starts_with("data:image/png;base64,") - ); -} - -/// TS: "should omit detail when no provider option is set" -#[test] -fn convert_image_file_omits_detail_when_no_option() { - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::File { - data: vec![0, 1, 2, 3], - media_type: "image/png".to_string(), - filename: None, - provider_options: None, - }], - ..Default::default() - }]; - - let result = convert_prompt_to_cohere(&prompt); - let msg = &result.messages[0]; - let content = msg["content"].as_array().expect("content should be array"); - assert_eq!(content[0]["type"], json!("image_url")); - assert!( - content[0]["image_url"].get("detail").is_none(), - "detail should be absent" - ); -} - -/// TS: "should send image inline and route non-image file to documents" -#[test] -fn convert_image_and_text_file_mixed() { - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ - ContentPart::text("See attached:"), - ContentPart::File { - data: vec![0, 1, 2, 3], - media_type: "image/png".to_string(), - filename: None, - provider_options: None, - }, - ContentPart::File { - data: b"Doc text".to_vec(), - media_type: "text/plain".to_string(), - filename: Some("note.txt".to_string()), - provider_options: None, - }, - ], - ..Default::default() - }]; - - let result = convert_prompt_to_cohere(&prompt); - // Message content is an array (has image). - let msg = &result.messages[0]; - let content = msg["content"].as_array().expect("content should be array"); - assert_eq!(content.len(), 2); - assert_eq!( - content[0], - json!({ "type": "text", "text": "See attached:" }) - ); - assert_eq!(content[1]["type"], json!("image_url")); - // Non-image file → document. - assert_eq!( - result.documents, - vec![json!({ "data": { "text": "Doc text", "title": "note.txt" } })] - ); -} - -/// TS: "should accept top-level 'image' media type and detect full type from bytes" -/// — mediaType "image" (no subtype) still routes to image_url. -#[test] -fn convert_image_top_level_mediatype() { - let png_signature = vec![0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::File { - data: png_signature, - media_type: "image".to_string(), - filename: None, - provider_options: None, - }], - ..Default::default() - }]; - - let result = convert_prompt_to_cohere(&prompt); - let msg = &result.messages[0]; - let content = msg["content"].as_array().expect("content should be array"); - assert_eq!(content[0]["type"], json!("image_url")); - // The Rust impl uses the media_type as-is ("image"), so the data URI is - // "data:image;base64,...". This is a documented gap (TS resolves the full - // type from bytes); we verify the image_url path is taken. - let url = content[0]["image_url"]["url"].as_str().unwrap(); - assert!( - url.starts_with("data:image"), - "url should start with data:image: {url}" - ); -} - -// ════════════════════════════════════════════════════════════════════════════ -// convert-to-cohere-chat-prompt.test.ts — tool messages -// ════════════════════════════════════════════════════════════════════════════ - -/// TS: "should convert a tool call into a cohere chatbot message" -#[test] -fn convert_assistant_tool_call_message() { - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ - ContentPart::text("Calling a tool"), - ContentPart::ToolCall { - tool_call_id: "tool-call-1".to_string(), - tool_name: "tool-1".to_string(), - input: json!({ "test": "This is a tool message" }), - provider_executed: None, - thought_signature: None, - provider_options: None, - }, - ], - ..Default::default() - }]; - - let result = convert_prompt_to_cohere(&prompt); - assert_eq!( - result.messages, - vec![json!({ - "role": "assistant", - "tool_calls": [{ - "id": "tool-call-1", - "type": "function", - "function": { - "name": "tool-1", - "arguments": "{\"test\":\"This is a tool message\"}" - } - }] - })] - ); - assert!(result.documents.is_empty()); -} - -/// TS: "should convert a single tool result into a cohere tool message" -#[test] -fn convert_single_tool_result_message() { - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::Tool, - content: vec![ContentPart::ToolResult { - tool_call_id: "tool-call-1".to_string(), - result: json!({ "test": "This is a tool message" }), - tool_name: None, - is_error: None, - preliminary: None, - dynamic: None, - provider_options: None, - }], - ..Default::default() - }]; - - let result = convert_prompt_to_cohere(&prompt); - assert_eq!( - result.messages, - vec![json!({ - "role": "tool", - "content": "{\"test\":\"This is a tool message\"}", - "tool_call_id": "tool-call-1" - })] - ); -} - -/// TS: "should convert multiple tool results into cohere tool messages" -#[test] -fn convert_multiple_tool_result_messages() { - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::Tool, - content: vec![ - ContentPart::ToolResult { - tool_call_id: "tool-call-1".to_string(), - result: json!({ "test": "This is a tool message" }), - tool_name: None, - is_error: None, - preliminary: None, - dynamic: None, - provider_options: None, - }, - ContentPart::ToolResult { - tool_call_id: "tool-call-2".to_string(), - result: json!({ "something": "else" }), - tool_name: None, - is_error: None, - preliminary: None, - dynamic: None, - provider_options: None, - }, - ], - ..Default::default() - }]; - - let result = convert_prompt_to_cohere(&prompt); - assert_eq!( - result.messages, - vec![ - json!({ - "role": "tool", - "content": "{\"test\":\"This is a tool message\"}", - "tool_call_id": "tool-call-1" - }), - json!({ - "role": "tool", - "content": "{\"something\":\"else\"}", - "tool_call_id": "tool-call-2" - }), - ] - ); -} - -// ════════════════════════════════════════════════════════════════════════════ -// cohere-chat-language-model.test.ts — reasoning extraction (doGenerate) -// ════════════════════════════════════════════════════════════════════════════ - -/// TS: "should extract reasoning from response" — a `thinking` content item in -/// the response becomes a `Reasoning` content item. -#[tokio::test] -async fn should_extract_reasoning_from_response() { - let server = MockServer::start().await; - mock_json_response( - &server, - json!({ - "id": "53bcb235-5179-4a91-a578-cb372b5430bc", - "message": { - "role": "assistant", - "content": [ - { - "type": "thinking", - "thinking": "Okay, so I need to figure out what 2 + 2 is. Let me start by recalling what addition means." - }, - { - "type": "text", - "text": "2 + 2 = 4" - } - ] - }, - "finish_reason": "COMPLETE", - "usage": { - "billed_units": { "input_tokens": 8, "output_tokens": 578 }, - "tokens": { "input_tokens": 1394, "output_tokens": 582 } - } - }), - ) - .await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - // reasoning + text. - assert_eq!(result.content.len(), 2); - match &result.content[0] { - GenerateContent::Reasoning(ReasoningOutput { text, .. }) => { - assert_eq!( - text, - "Okay, so I need to figure out what 2 + 2 is. Let me start by recalling what addition means." - ); - } - other => panic!("expected Reasoning, got {other:?}"), - } - match &result.content[1] { - GenerateContent::Text { text, .. } => { - assert_eq!(text, "2 + 2 = 4"); - } - other => panic!("expected Text, got {other:?}"), - } - assert_eq!(result.finish_reason.unified, FinishReasonUnified::Stop); -} - -// ════════════════════════════════════════════════════════════════════════════ -// cohere-chat-language-model.test.ts — top-level reasoning → thinking -// ════════════════════════════════════════════════════════════════════════════ - -/// TS: "should map top-level reasoning to thinking enabled with budget" -#[tokio::test] -async fn should_map_reasoning_high_to_thinking_enabled() { - let server = MockServer::start().await; - mock_json_response(&server, ok_cohere_body()).await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let options = CallOptions { - reasoning: Some(ReasoningEffort::High), - ..default_options(test_prompt()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - assert!(body.get("thinking").is_some(), "thinking should be defined"); - assert_eq!(body["thinking"]["type"], json!("enabled")); - let budget = body["thinking"]["token_budget"] - .as_u64() - .expect("token_budget"); - assert!(budget > 0, "token_budget should be > 0, got {budget}"); -} - -/// TS: "should map top-level reasoning none to thinking disabled" -#[tokio::test] -async fn should_map_reasoning_none_to_thinking_disabled() { - let server = MockServer::start().await; - mock_json_response(&server, ok_cohere_body()).await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let options = CallOptions { - reasoning: Some(ReasoningEffort::None), - ..default_options(test_prompt()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - assert_eq!(body["thinking"], json!({ "type": "disabled" })); -} - -/// TS: "should prefer providerOptions over top-level reasoning" -#[tokio::test] -async fn should_prefer_provider_options_over_reasoning() { - let server = MockServer::start().await; - mock_json_response(&server, ok_cohere_body()).await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let mut po = std::collections::HashMap::new(); - po.insert( - "cohere".to_string(), - serde_json::from_value(json!({ "thinking": { "type": "enabled" } })).unwrap(), - ); - let options = CallOptions { - reasoning: Some(ReasoningEffort::None), - provider_options: Some(po), - ..default_options(test_prompt()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - assert_eq!(body["thinking"]["type"], json!("enabled")); -} - -/// TS: "should not set thinking when reasoning is not specified" -#[tokio::test] -async fn should_not_set_thinking_when_no_reasoning() { - let server = MockServer::start().await; - mock_json_response(&server, ok_cohere_body()).await; - - let config = CohereConfig::new("test-api-key").with_base_url(server.uri()); - let provider = CohereProvider::new(config); - let model = provider.model("command-r-plus"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - let body = result.request_body.expect("body"); - assert!( - body.get("thinking").is_none(), - "thinking should be undefined: {body}" - ); -} - -// ════════════════════════════════════════════════════════════════════════════ -// resolve_cohere_thinking unit tests -// ════════════════════════════════════════════════════════════════════════════ - -#[test] -fn resolve_thinking_none_returns_none() { - let result = resolve_cohere_thinking(None, &None); - assert!(result.is_none()); -} - -#[test] -fn resolve_thinking_provider_default_returns_none() { - let result = resolve_cohere_thinking(Some(ReasoningEffort::ProviderDefault), &None); - assert!(result.is_none()); -} - -#[test] -fn resolve_thinking_explicit_none_is_disabled() { - let result = resolve_cohere_thinking(Some(ReasoningEffort::None), &None); - assert_eq!(result, Some(json!({ "type": "disabled" }))); -} - -#[test] -fn resolve_thinking_high_is_enabled_with_budget() { - let result = resolve_cohere_thinking(Some(ReasoningEffort::High), &None); - let thinking = result.expect("should be Some"); - assert_eq!(thinking["type"], json!("enabled")); - let budget = thinking["token_budget"].as_u64().unwrap(); - // High = 0.60 * 32768 = 19660.8 → 19661 - assert_eq!(budget, 19661); -} - -#[test] -fn resolve_thinking_provider_options_override() { - let mut po = std::collections::HashMap::new(); - po.insert( - "cohere".to_string(), - serde_json::from_value(json!({ "thinking": { "type": "enabled", "tokenBudget": 5000 } })) - .unwrap(), - ); - let result = resolve_cohere_thinking(Some(ReasoningEffort::None), &Some(po)); - assert_eq!( - result, - Some(json!({ "type": "enabled", "token_budget": 5000 })) - ); -} - -#[test] -fn resolve_thinking_provider_options_default_type() { - let mut po = std::collections::HashMap::new(); - po.insert( - "cohere".to_string(), - serde_json::from_value(json!({ "thinking": { "tokenBudget": 1000 } })).unwrap(), - ); - let result = resolve_cohere_thinking(None, &Some(po)); - // type defaults to "enabled" when not specified. - assert_eq!( - result, - Some(json!({ "type": "enabled", "token_budget": 1000 })) - ); -} diff --git a/aimux-providers/tests/deepseek_reasoning_test.rs b/aimux-providers/tests/deepseek_reasoning_test.rs deleted file mode 100644 index f8296eef1..000000000 --- a/aimux-providers/tests/deepseek_reasoning_test.rs +++ /dev/null @@ -1,954 +0,0 @@ -//! DeepSeek `reasoning_content` tests, translated from the Vercel AI SDK -//! TypeScript suite. -//! -//! Translation sources: -//! - `packages/deepseek/src/chat/deepseek-chat-language-model.test.ts` -//! - `describe('doGenerate') › describe('reasoning')` — request body with -//! `thinking` + response extraction from the reasoning fixture. -//! - `describe('doGenerate') › describe('top-level reasoning')` — mapping of -//! top-level `reasoning` and `providerOptions.deepseek` to the -//! `thinking` / `reasoning_effort` request fields. -//! - `describe('doStream') › describe('reasoning')` — streaming the -//! reasoning fixture. -//! - `packages/openai-compatible/src/chat/openai-compatible-chat-language-model.test.ts` -//! - DeepSeek is a thin OpenAI-compatible wrapper, so the -//! `reasoning_content` response-extraction tests (doGenerate + doStream) -//! apply directly. These cover the core behaviour gap: the shared OpenAI -//! response parser currently ignores `reasoning_content` (serde skips the -//! unknown field), so reasoning text is not surfaced as a -//! `GenerateContent::Reasoning` part / `StreamPart::Reasoning*` event. -//! -//! HTTP is mocked with `wiremock` (a real loopback server), replacing the TS -//! `createTestServer`. Each test starts its own `MockServer` so parallel -//! `#[tokio::test]` runs do not collide. -//! -//! # TDD status -//! -//! DeepSeek delegates to the shared [`OpenAIModel`], whose response parser does -//! not read `reasoning_content` / `reasoning` and whose request builder does -//! not emit a `thinking` field or do the DeepSeek-specific `reasoning` → -//! `reasoning_effort` remapping (xhigh→max, minimal→low with compatibility -//! warnings). Provider options keyed under `deepseek` are also not consulted -//! (the shared builder only reads the `openai` key). -//! -//! Tests that assert these unimplemented behaviours are written as TDD red -//! lights: they compile cleanly against the existing types -//! (`GenerateContent::Reasoning`, `StreamPart::Reasoning*`, -//! `Warning::Compatibility`) but fail at runtime until the feature lands. -//! No `#[ignore]` is used — every test compiles against types that exist today. -//! -//! # Mock URL note -//! -//! The real DeepSeek base URL is `https://api.deepseek.com/v1`, so production -//! requests hit `/v1/chat/completions`. In these tests `with_base_url` is -//! overridden with the mock server's root URI (no `/v1` suffix), so the -//! resulting request path is `/chat/completions` — matching the convention in -//! `openai_compatible_test.rs`. - -use aimux_core::shared::{SharedProviderOptions, provider_namespace}; - -use futures::StreamExt; -use serde_json::{Value, json}; -use wiremock::matchers::{method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -use aimux_core::content::ContentPart; -use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; -use aimux_core::options::CallOptions; -use aimux_core::result::{GenerateContent, ReasoningOutput}; -use aimux_core::stream_part::StreamPart; -use aimux_core::types::{ReasoningEffort, Warning}; - -use aimux_providers::{ProviderOptions, provider}; - -// ───────────────────────────────────────────────────────────────────────────── -// Shared helpers (mirrors openai_compatible_test.rs / anthropic_model_test.rs) -// ───────────────────────────────────────────────────────────────────────────── - -/// The TS `TEST_PROMPT`: a single user text message "Hello". -fn test_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] -} - -/// `CallOptions` with only `prompt` set (everything else default/None). -fn default_options(prompt: LanguageModelPrompt) -> CallOptions { - CallOptions::new(prompt) -} - -/// Build a DeepSeek provider whose base URL points at the mock server. -fn make_provider(server: &MockServer) -> Box { - provider( - "deepseek", - Some("test-api-key".to_string()), - "deepseek-reasoner", - Some(ProviderOptions { - base_url: Some(server.uri()), - ..Default::default() - }), - ) - .expect("deepseek provider should build") -} - -/// Wrap a value as `providerOptions.deepseek.`. -/// -/// The TS tests pass `providerOptions: { deepseek: { ... } }`. The Rust -/// `CallOptions.provider_options` is keyed by provider name. -fn deepseek_opts(value: Value) -> Option { - Some(provider_namespace("deepseek", value)) -} - -/// Mount a JSON chat-completion response on `/chat/completions`. -async fn mock_json(server: &MockServer, body: Value) { - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(body)) - .mount(server) - .await; -} - -/// Mount an SSE stream response on `/chat/completions`. -async fn mock_sse(server: &MockServer, body: String) { - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(body), - ) - .mount(server) - .await; -} - -/// Build a single SSE `data: \n\n` event string. -fn sse_event(json_str: &str) -> String { - format!("data: {json_str}\n\n") -} - -/// Concatenate SSE events and append the `[DONE]` sentinel. -fn sse_body(events: &[&str]) -> String { - let mut body = String::new(); - for event in events { - body.push_str(event); - } - body.push_str("data: [DONE]\n\n"); - body -} - -/// Collect every `StreamPart` from a `StreamResult` into a `Vec`. -async fn collect_stream(result: aimux_core::result::StreamResult) -> Vec { - let mut parts = Vec::new(); - let mut stream = result.stream; - while let Some(part) = stream.next().await { - match part { - Ok(p) => parts.push(p), - Err(e) => panic!("stream error: {e:?}"), - } - } - parts -} - -/// Extract text deltas from a list of stream parts. -fn text_deltas(parts: &[StreamPart]) -> Vec { - parts - .iter() - .filter_map(|p| match p { - StreamPart::TextDelta { delta, .. } => Some(delta.clone()), - _ => None, - }) - .collect() -} - -/// Extract reasoning deltas from a list of stream parts. -fn reasoning_deltas(parts: &[StreamPart]) -> Vec { - parts - .iter() - .filter_map(|p| match p { - StreamPart::ReasoningDelta { delta, .. } => Some(delta.clone()), - _ => None, - }) - .collect() -} - -/// True if the warnings contain a `Compatibility` warning for `feature`. -fn has_reasoning_compatibility_warning(warnings: &[Warning]) -> bool { - warnings.iter().any(|w| { - matches!( - w, - Warning::Compatibility { feature, .. } if feature == "reasoning" - ) - }) -} - -// ════════════════════════════════════════════════════════════════════════════ -// doGenerate — reasoning_content response extraction -// -// Translated from -// `openai-compatible-chat-language-model.test.ts` doGenerate reasoning tests -// and `deepseek-chat-language-model.test.ts` doGenerate › reasoning › "should -// extract text content". -// -// TS behaviour: `reasoning = message.reasoning_content ?? message.reasoning` -// (JS nullish coalescing — an empty string is NOT nullish, so `""` wins over -// `reasoning`); a non-empty reasoning string is pushed as a `{ type: -// "reasoning", text }` content part after the text part. -// -// Rust status: the shared OpenAI `MessageResponse` has no `reasoning_content` -// / `reasoning` field, so serde silently drops them — no `Reasoning` content -// part is ever produced. The extraction tests below are TDD red lights. -// ════════════════════════════════════════════════════════════════════════════ - -/// TS: "should prefer reasoning_content over reasoning field when both are -/// provided" (openai-compatible test, line ~320). -/// -/// RED LIGHT: Rust ignores `reasoning_content`, so only the `Text` part is -/// produced — the `Reasoning` assertion fails. -#[tokio::test] -async fn should_prefer_reasoning_content_over_reasoning_field_when_both_provided() { - let server = MockServer::start().await; - mock_json( - &server, - json!({ - "id": "chatcmpl-1", - "object": "chat.completion", - "created": 1711115037, - "model": "deepseek-reasoner", - "choices": [{ - "index": 0, - "message": { - "role": "assistant", - "content": "Hello, World!", - "reasoning_content": "This is from reasoning_content", - "reasoning": "This is from reasoning field" - }, - "finish_reason": "stop" - }], - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 } - }), - ) - .await; - - let model = make_provider(&server); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!( - result.content.len(), - 2, - "expected [Text, Reasoning] — reasoning_content should be extracted" - ); - match &result.content[0] { - GenerateContent::Text { text, .. } => assert_eq!(text, "Hello, World!"), - other => panic!("expected Text, got {other:?}"), - } - match &result.content[1] { - GenerateContent::Reasoning(ReasoningOutput { text, .. }) => { - assert_eq!(text, "This is from reasoning_content") - } - other => panic!("expected Reasoning from reasoning_content, got {other:?}"), - } -} - -/// TS: "should extract reasoning from reasoning field when reasoning_content -/// is not provided" (openai-compatible test, line ~300). -/// -/// NOTE: the TS `prepareJsonResponse` helper defaults `reasoning_content` to -/// `""` (empty string). Because JS `??` does not treat `""` as nullish, -/// `"" ?? reasoning` resolves to `""`, whose `.length` is 0, so NO reasoning -/// part is emitted — the TS snapshot is text-only. This test replicates that -/// exact edge case. It currently passes in Rust because reasoning is ignored -/// entirely; after `reasoning_content` extraction is implemented (mirroring -/// the `??` + length-check) it must still pass. -#[tokio::test] -async fn should_extract_reasoning_from_reasoning_field_when_reasoning_content_not_provided() { - let server = MockServer::start().await; - mock_json( - &server, - json!({ - "id": "chatcmpl-1", - "object": "chat.completion", - "created": 1711115037, - "model": "deepseek-reasoner", - "choices": [{ - "index": 0, - "message": { - "role": "assistant", - "content": "Hello, World!", - "reasoning_content": "", - "reasoning": "This is the reasoning from the reasoning field" - }, - "finish_reason": "stop" - }], - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 } - }), - ) - .await; - - let model = make_provider(&server); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - // Empty-string reasoning_content suppresses the reasoning-field fallback - // (TS `??` edge case), so only the text part is expected. - assert_eq!( - result.content.len(), - 1, - "empty reasoning_content should not yield a reasoning part" - ); - match &result.content[0] { - GenerateContent::Text { text, .. } => assert_eq!(text, "Hello, World!"), - other => panic!("expected Text only, got {other:?}"), - } -} - -/// TS: deepseek doGenerate › reasoning › "should extract text content" -/// (deepseek-chat-language-model.test.ts, line ~118), using the -/// `deepseek-reasoning` fixture. -/// -/// The fixture carries both `content` and a long `reasoning_content`. The TS -/// snapshot surfaces the reasoning as a `{ type: "reasoning" }` part. The -/// `reasoning_content` text below is abbreviated from the fixture for -/// readability; the assertion still verifies extraction. -/// -/// RED LIGHT: Rust ignores `reasoning_content`, so only `Text` is produced. -#[tokio::test] -async fn should_extract_reasoning_content_and_text_from_deepseek_reasoning_fixture() { - let server = MockServer::start().await; - mock_json( - &server, - json!({ - "id": "945bb10c-9bf3-47ff-a2a2-43bbe9705c72", - "object": "chat.completion", - "created": 1764660903, - "model": "deepseek-reasoner", - "choices": [{ - "index": 0, - "message": { - "role": "assistant", - "content": "The word \"strawberry\" contains three instances of the letter \"r\": one after the \"t\" and two before the \"y\".", - "reasoning_content": "We are asked: \"How many 'r's are in the word 'strawberry'?\" We need to count the occurrences of the letter 'r'." - }, - "logprobs": null, - "finish_reason": "stop" - }], - "usage": { - "prompt_tokens": 18, - "completion_tokens": 345, - "total_tokens": 363, - "completion_tokens_details": { "reasoning_tokens": 315 } - }, - "system_fingerprint": "fp_eaab8d114b_prod0820_fp8_kvcache" - }), - ) - .await; - - let model = make_provider(&server); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!( - result.content.len(), - 2, - "expected [Text, Reasoning] from the reasoning fixture" - ); - match &result.content[0] { - GenerateContent::Text { text, .. } => assert!( - text.contains("strawberry"), - "expected the fixture text, got: {text}" - ), - other => panic!("expected Text, got {other:?}"), - } - match &result.content[1] { - GenerateContent::Reasoning(ReasoningOutput { text, .. }) => assert!( - text.contains("How many"), - "expected the fixture reasoning_content, got: {text}" - ), - other => panic!("expected Reasoning, got {other:?}"), - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// doGenerate — request body: thinking & reasoning_effort -// -// Translated from `deepseek-chat-language-model.test.ts` doGenerate › -// "reasoning" ("should send correct request body") and "top-level reasoning" -// describe blocks (lines ~82–311). -// -// TS behaviour: DeepSeek maps top-level `reasoning` and -// `providerOptions.deepseek` to a `thinking` field (`{ type: "enabled" | -// "disabled" | "adaptive" }`) and a `reasoning_effort` field, with -// xhigh→max / minimal→low remapping that emits `Compatibility` warnings. -// -// Rust status (stage2-001, RFC-0017 阶段 2): the DeepSeek 特化已整体退役—— -// 共享 builder 直传 `options.reasoning` 为 `reasoning_effort`(7 档,无归一化), -// 不注入 `thinking`,不产生归一化 warning;`providerOptions.deepseek.*` 被忽略。 -// thinking 注入 / effort 重映射由用户 bodyOverrides 定义。以下测试断言退役后的 -// 透传语义(原断言特化的测试已按新语义改写)。 -// ════════════════════════════════════════════════════════════════════════════ - -/// stage2-001: DeepSeek 特化退役——`providerOptions.deepseek.thinking` 不再 -/// 翻译为请求体 `thinking` 字段(thinking 注入改由用户 bodyOverrides 定义)。 -#[tokio::test] -async fn should_not_inject_thinking_from_deepseek_provider_options() { - let server = MockServer::start().await; - mock_json(&server, text_completion_body()).await; - - let model = make_provider(&server); - - let mut options = default_options(test_prompt()); - options.provider_options = deepseek_opts(json!({ "thinking": { "type": "enabled" } })); - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - - assert_eq!(body["model"], json!("deepseek-reasoner")); - assert!( - body.get("thinking").is_none(), - "stage2-001: DeepSeek 特化退役,thinking 不再由 providerOptions.deepseek 注入,改用 bodyOverrides" - ); -} - -/// stage2-001: 顶层 `reasoning: 'high'` 透传为 `reasoning_effort: "high"`, -/// 不再注入 `thinking`。 -#[tokio::test] -async fn should_passthrough_reasoning_high_to_reasoning_effort_without_thinking() { - let server = MockServer::start().await; - mock_json(&server, text_completion_body()).await; - - let model = make_provider(&server); - - let mut options = default_options(test_prompt()); - options.reasoning = Some(ReasoningEffort::High); - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - - assert!( - body.get("thinking").is_none(), - "stage2-001: thinking 注入已退役,开思考改用 bodyOverrides:{{thinking:{{type:'enabled'}}}}" - ); - assert_eq!(body["reasoning_effort"], json!("high")); -} - -/// stage2-001: 顶层 `reasoning: 'none'` 透传为 `reasoning_effort: "none"`, -/// 不含 thinking 注入(任务验收:透传语义)。 -#[tokio::test] -async fn should_passthrough_reasoning_none_to_reasoning_effort_without_thinking() { - let server = MockServer::start().await; - mock_json(&server, text_completion_body()).await; - - let model = make_provider(&server); - - let mut options = default_options(test_prompt()); - options.reasoning = Some(ReasoningEffort::None); - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - - assert!( - body.get("thinking").is_none(), - "stage2-001: thinking 注入已退役,关思考改用 bodyOverrides:{{thinking:{{type:'disabled'}}}}" - ); - assert_eq!(body["reasoning_effort"], json!("none")); -} - -/// stage2-001: `reasoning: 'xhigh'` 直传为 `reasoning_effort: "xhigh"`, -/// 无归一化(xhigh→max 已退役),无 warning。 -#[tokio::test] -async fn should_passthrough_reasoning_xhigh_without_normalization() { - let server = MockServer::start().await; - mock_json(&server, text_completion_body()).await; - - let model = make_provider(&server); - - let mut options = default_options(test_prompt()); - options.reasoning = Some(ReasoningEffort::Xhigh); - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - - assert_eq!(body["reasoning_effort"], json!("xhigh")); - let warning = result - .warnings - .iter() - .find(|w| matches!(w, Warning::Compatibility { feature, .. } if feature == "reasoning")); - assert!( - warning.is_none(), - "stage2-001: 归一化已退役,xhigh 直传不产生归一化 warning" - ); -} - -/// TS: "should map top-level reasoning low to reasoning_effort low without a -/// compatibility warning" (line ~175). -/// -/// PASSES: Rust maps `Low` → `reasoning_effort: "low"` with no compatibility -/// warning. -#[tokio::test] -async fn should_map_top_level_reasoning_low_to_reasoning_effort_low_without_warning() { - let server = MockServer::start().await; - mock_json(&server, text_completion_body()).await; - - let model = make_provider(&server); - - let mut options = default_options(test_prompt()); - options.reasoning = Some(ReasoningEffort::Low); - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - - assert_eq!(body["reasoning_effort"], json!("low")); - assert!( - !has_reasoning_compatibility_warning(&result.warnings), - "low should not emit a compatibility warning" - ); -} - -/// TS: "should map top-level reasoning medium to reasoning_effort medium" -/// (line ~192). -/// -/// PASSES: Rust maps `Medium` → `reasoning_effort: "medium"`. -#[tokio::test] -async fn should_map_top_level_reasoning_medium_to_reasoning_effort_medium() { - let server = MockServer::start().await; - mock_json(&server, text_completion_body()).await; - - let model = make_provider(&server); - - let mut options = default_options(test_prompt()); - options.reasoning = Some(ReasoningEffort::Medium); - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - - assert_eq!(body["reasoning_effort"], json!("medium")); -} - -/// stage2-001: `reasoning: 'minimal'` 直传为 `reasoning_effort: "minimal"`, -/// 无归一化(minimal→low 已退役),无 warning。 -#[tokio::test] -async fn should_passthrough_reasoning_minimal_without_normalization() { - let server = MockServer::start().await; - mock_json(&server, text_completion_body()).await; - - let model = make_provider(&server); - - let mut options = default_options(test_prompt()); - options.reasoning = Some(ReasoningEffort::Minimal); - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - - assert_eq!(body["reasoning_effort"], json!("minimal")); - let warning = result - .warnings - .iter() - .find(|w| matches!(w, Warning::Compatibility { feature, .. } if feature == "reasoning")); - assert!( - warning.is_none(), - "stage2-001: 归一化已退役,minimal 直传不产生归一化 warning" - ); -} - -/// stage2-001: `providerOptions.deepseek.reasoningEffort` 不再被翻译 -/// (deepseek 特化退役,共享 builder 只读 `openai` key)→ `reasoning_effort` 缺席。 -#[tokio::test] -async fn should_ignore_deepseek_provider_options_reasoning_effort() { - for effort in ["low", "medium", "xhigh"] { - let server = MockServer::start().await; - mock_json(&server, text_completion_body()).await; - - let model = make_provider(&server); - - let mut options = default_options(test_prompt()); - options.provider_options = deepseek_opts(json!({ "reasoningEffort": effort })); - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - - assert!( - body.get("reasoning_effort").is_none(), - "stage2-001: deepseek 特化退役,providerOptions.deepseek.reasoningEffort 被忽略,effort={effort} 不出现" - ); - } -} - -/// stage2-001: `providerOptions.deepseek.thinking.type=adaptive` 不再翻译为 -/// 请求体 `thinking` 字段(特化退役)→ thinking 缺席。 -#[tokio::test] -async fn should_ignore_deepseek_provider_options_thinking_adaptive() { - let server = MockServer::start().await; - mock_json(&server, text_completion_body()).await; - - let model = make_provider(&server); - - let mut options = default_options(test_prompt()); - options.provider_options = deepseek_opts(json!({ "thinking": { "type": "adaptive" } })); - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - - assert!( - body.get("thinking").is_none(), - "stage2-001: providerOptions.deepseek.thinking 被忽略,改用 bodyOverrides" - ); -} - -/// stage2-001: `providerOptions.deepseek.reasoningEffort = "max"` 被忽略 → -/// `reasoning_effort` 缺席;thinking 亦缺席。 -#[tokio::test] -async fn should_ignore_deepseek_provider_options_reasoning_effort_max() { - let server = MockServer::start().await; - mock_json(&server, text_completion_body()).await; - - let model = make_provider(&server); - - let mut options = default_options(test_prompt()); - options.provider_options = deepseek_opts(json!({ "reasoningEffort": "max" })); - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - - assert!( - body.get("reasoning_effort").is_none(), - "stage2-001: providerOptions.deepseek.reasoningEffort 被忽略" - ); - // 未设置 reasoning/thinking 时,请求体不含 thinking,依赖 API 默认。 - assert!(body.get("thinking").is_none()); -} - -/// stage2-001: `providerOptions.deepseek.thinking` 不再优先于顶层 `reasoning` -/// (特化退役)——thinking 缺席,`reasoning:'none'` 透传为 `reasoning_effort:"none"`。 -#[tokio::test] -async fn should_ignore_deepseek_provider_options_thinking_over_top_level_reasoning() { - let server = MockServer::start().await; - mock_json(&server, text_completion_body()).await; - - let model = make_provider(&server); - - let mut options = default_options(test_prompt()); - options.reasoning = Some(ReasoningEffort::None); - options.provider_options = deepseek_opts(json!({ "thinking": { "type": "enabled" } })); - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - - assert!( - body.get("thinking").is_none(), - "stage2-001: providerOptions.deepseek.thinking 被忽略,改用 bodyOverrides" - ); - assert_eq!(body["reasoning_effort"], json!("none")); -} - -/// stage2-001: `providerOptions.deepseek.reasoningEffort` 被忽略,顶层 -/// `reasoning:'high'` 透传为 `reasoning_effort:"high"`。 -#[tokio::test] -async fn should_ignore_deepseek_provider_options_reasoning_effort_over_top_level_reasoning() { - let server = MockServer::start().await; - mock_json(&server, text_completion_body()).await; - - let model = make_provider(&server); - - let mut options = default_options(test_prompt()); - options.reasoning = Some(ReasoningEffort::High); - options.provider_options = deepseek_opts(json!({ "reasoningEffort": "max" })); - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - - assert_eq!(body["reasoning_effort"], json!("high")); -} - -/// TS: "should not set thinking when reasoning is not specified" (line ~302). -/// -/// PASSES: `thinking` is never emitted by the shared builder. -#[tokio::test] -async fn should_not_set_thinking_when_reasoning_not_specified() { - let server = MockServer::start().await; - mock_json(&server, text_completion_body()).await; - - let model = make_provider(&server); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - let body = result.request_body.expect("body"); - - assert!( - body.get("thinking").is_none(), - "thinking should be absent when no reasoning is specified" - ); -} - -// ════════════════════════════════════════════════════════════════════════════ -// doStream — reasoning_content streaming -// -// Translated from `openai-compatible-chat-language-model.test.ts` doStream -// reasoning tests (lines ~1856–2149) and `deepseek-chat-language-model.test.ts` -// doStream › reasoning › "should stream reasoning" (line ~764). -// -// TS behaviour: `reasoningContent = delta.reasoning_content ?? delta.reasoning`; -// when truthy it emits `reasoning-start` / `reasoning-delta` / `reasoning-end` -// before the text deltas. -// -// Rust status: the shared `Delta` type has no `reasoning_content` / `reasoning` -// field, so serde drops them — no `StreamPart::Reasoning*` is ever emitted. -// These tests are TDD red lights. -// ════════════════════════════════════════════════════════════════════════════ - -/// TS: "should stream reasoning content before text deltas" (line ~1856). -/// -/// RED LIGHT: `reasoning_content` deltas are dropped, so no -/// `ReasoningStart` / `ReasoningDelta` / `ReasoningEnd` parts are emitted. -#[tokio::test] -async fn should_stream_reasoning_content_before_text_deltas() { - let server = MockServer::start().await; - let body = sse_body(&[ - &sse_event( - r#"{"id":"chatcmpl-x","object":"chat.completion.chunk","created":1711357598,"model":"deepseek-reasoner","system_fingerprint":"fp","choices":[{"index":0,"delta":{"role":"assistant","content":"","reasoning_content":"Let me think"},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-x","object":"chat.completion.chunk","created":1711357598,"model":"deepseek-reasoner","system_fingerprint":"fp","choices":[{"index":0,"delta":{"content":"","reasoning_content":" about this"},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-x","object":"chat.completion.chunk","created":1711357598,"model":"deepseek-reasoner","system_fingerprint":"fp","choices":[{"index":0,"delta":{"content":"Here's"},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-x","object":"chat.completion.chunk","created":1711357598,"model":"deepseek-reasoner","system_fingerprint":"fp","choices":[{"index":0,"delta":{"content":" my response"},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-x","object":"chat.completion.chunk","created":1729171479,"model":"deepseek-reasoner","system_fingerprint":"fp","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":18,"completion_tokens":439}}"#, - ), - ]); - mock_sse(&server, body).await; - - let model = make_provider(&server); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - let parts = collect_stream(result).await; - - assert_eq!( - reasoning_deltas(&parts), - vec!["Let me think".to_string(), " about this".to_string()], - "reasoning_content deltas should be emitted as reasoning-delta parts" - ); - assert!( - parts - .iter() - .any(|p| matches!(p, StreamPart::ReasoningStart { .. })), - "expected a ReasoningStart part" - ); - assert!( - parts - .iter() - .any(|p| matches!(p, StreamPart::ReasoningEnd { .. })), - "expected a ReasoningEnd part" - ); - let deltas = text_deltas(&parts); - assert!(deltas.iter().any(|d| d == "Here's"), "missing text delta"); - assert!( - deltas.iter().any(|d| d == " my response"), - "missing text delta" - ); -} - -/// TS: "should stream reasoning from reasoning field when reasoning_content is -/// not provided" (line ~1959). -/// -/// RED LIGHT: the `reasoning` delta field is dropped, so no reasoning parts. -#[tokio::test] -async fn should_stream_reasoning_from_reasoning_field_when_reasoning_content_not_provided() { - let server = MockServer::start().await; - let body = sse_body(&[ - &sse_event( - r#"{"id":"chatcmpl-x","object":"chat.completion.chunk","created":1711357598,"model":"deepseek-reasoner","system_fingerprint":"fp","choices":[{"index":0,"delta":{"role":"assistant","content":"","reasoning":"Let me consider"},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-x","object":"chat.completion.chunk","created":1711357598,"model":"deepseek-reasoner","system_fingerprint":"fp","choices":[{"index":0,"delta":{"content":"","reasoning":" this carefully"},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-x","object":"chat.completion.chunk","created":1711357598,"model":"deepseek-reasoner","system_fingerprint":"fp","choices":[{"index":0,"delta":{"content":"My answer is"},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-x","object":"chat.completion.chunk","created":1711357598,"model":"deepseek-reasoner","system_fingerprint":"fp","choices":[{"index":0,"delta":{"content":" correct"},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-x","object":"chat.completion.chunk","created":1729171479,"model":"deepseek-reasoner","system_fingerprint":"fp","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":18,"completion_tokens":439}}"#, - ), - ]); - mock_sse(&server, body).await; - - let model = make_provider(&server); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - let parts = collect_stream(result).await; - - assert_eq!( - reasoning_deltas(&parts), - vec!["Let me consider".to_string(), " this carefully".to_string()], - "reasoning field deltas should be emitted as reasoning-delta parts" - ); - assert!( - parts - .iter() - .any(|p| matches!(p, StreamPart::ReasoningStart { .. })), - "expected a ReasoningStart part" - ); - assert!( - parts - .iter() - .any(|p| matches!(p, StreamPart::ReasoningEnd { .. })), - "expected a ReasoningEnd part" - ); -} - -/// TS: "should prefer reasoning_content over reasoning field in streaming when -/// both are provided" (line ~2062). -/// -/// RED LIGHT: both fields are dropped, so no reasoning parts are emitted (and -/// certainly not from `reasoning_content`). -#[tokio::test] -async fn should_prefer_reasoning_content_over_reasoning_field_in_streaming() { - let server = MockServer::start().await; - let body = sse_body(&[ - &sse_event( - r#"{"id":"chatcmpl-x","object":"chat.completion.chunk","created":1711357598,"model":"deepseek-reasoner","system_fingerprint":"fp","choices":[{"index":0,"delta":{"role":"assistant","content":"","reasoning_content":"From reasoning_content","reasoning":"From reasoning"},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-x","object":"chat.completion.chunk","created":1711357598,"model":"deepseek-reasoner","system_fingerprint":"fp","choices":[{"index":0,"delta":{"content":"Final response"},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-x","object":"chat.completion.chunk","created":1729171479,"model":"deepseek-reasoner","system_fingerprint":"fp","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":18,"completion_tokens":439}}"#, - ), - ]); - mock_sse(&server, body).await; - - let model = make_provider(&server); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - let parts = collect_stream(result).await; - - assert_eq!( - reasoning_deltas(&parts), - vec!["From reasoning_content".to_string()], - "reasoning_content should be preferred over the reasoning field" - ); - assert!( - parts - .iter() - .any(|p| matches!(p, StreamPart::ReasoningStart { .. })), - "expected a ReasoningStart part" - ); - assert!( - parts - .iter() - .any(|p| matches!(p, StreamPart::ReasoningEnd { .. })), - "expected a ReasoningEnd part" - ); - assert!( - text_deltas(&parts).iter().any(|d| d == "Final response"), - "missing text delta" - ); -} - -/// TS: deepseek doStream › reasoning › "should stream reasoning" (line ~764), -/// using the `deepseek-reasoning` chunks fixture. -/// -/// The fixture streams `reasoning_content` deltas ("We", " need", ...) before -/// the text content. The chunks below are an abbreviated inline subset of the -/// fixture; the assertion still verifies reasoning is streamed. -/// -/// RED LIGHT: `reasoning_content` deltas are dropped, so no reasoning parts. -#[tokio::test] -async fn should_stream_reasoning_from_deepseek_reasoning_fixture() { - let server = MockServer::start().await; - let body = sse_body(&[ - &sse_event( - r#"{"id":"cac7192e","object":"chat.completion.chunk","created":1764661832,"model":"deepseek-reasoner","system_fingerprint":"fp_eaab8d114b_prod0820_fp8_kvcache","choices":[{"index":0,"delta":{"role":"assistant","content":null,"reasoning_content":"We"},"logprobs":null,"finish_reason":null}],"usage":null}"#, - ), - &sse_event( - r#"{"id":"cac7192e","object":"chat.completion.chunk","created":1764661832,"model":"deepseek-reasoner","system_fingerprint":"fp_eaab8d114b_prod0820_fp8_kvcache","choices":[{"index":0,"delta":{"content":null,"reasoning_content":" need"},"logprobs":null,"finish_reason":null}],"usage":null}"#, - ), - &sse_event( - r#"{"id":"cac7192e","object":"chat.completion.chunk","created":1764661832,"model":"deepseek-reasoner","system_fingerprint":"fp_eaab8d114b_prod0820_fp8_kvcache","choices":[{"index":0,"delta":{"content":"The word","reasoning_content":null},"logprobs":null,"finish_reason":null}],"usage":null}"#, - ), - &sse_event( - r#"{"id":"cac7192e","object":"chat.completion.chunk","created":1764661832,"model":"deepseek-reasoner","system_fingerprint":"fp_eaab8d114b_prod0820_fp8_kvcache","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"stop"}],"usage":{"prompt_tokens":18,"completion_tokens":345,"total_tokens":363}}"#, - ), - ]); - mock_sse(&server, body).await; - - let model = make_provider(&server); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - let parts = collect_stream(result).await; - - assert_eq!( - reasoning_deltas(&parts), - vec!["We".to_string(), " need".to_string()], - "fixture reasoning_content deltas should be streamed" - ); - assert!( - parts - .iter() - .any(|p| matches!(p, StreamPart::ReasoningStart { .. })), - "expected a ReasoningStart part" - ); - assert!( - parts - .iter() - .any(|p| matches!(p, StreamPart::ReasoningEnd { .. })), - "expected a ReasoningEnd part" - ); - assert!( - text_deltas(&parts).iter().any(|d| d == "The word"), - "missing text delta" - ); -} - -// ───────────────────────────────────────────────────────────────────────────── -// Local fixtures -// ───────────────────────────────────────────────────────────────────────────── - -/// A minimal non-streaming chat-completion body returning "Hello, World!". -/// Used by the request-body tests (the response content is irrelevant there). -fn text_completion_body() -> Value { - json!({ - "id": "chatcmpl-test", - "object": "chat.completion", - "created": 1711115037, - "model": "deepseek-reasoner", - "choices": [{ - "index": 0, - "message": { "role": "assistant", "content": "Hello, World!" }, - "finish_reason": "stop" - }], - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 } - }) -} diff --git a/aimux-providers/tests/google_model_test.rs b/aimux-providers/tests/google_model_test.rs deleted file mode 100644 index 9ff55e675..000000000 --- a/aimux-providers/tests/google_model_test.rs +++ /dev/null @@ -1,2706 +0,0 @@ -//! HTTP-level tests for the Google Gemini provider. -//! -//! These tests spin up a `wiremock` mock server, configure it with a JSON or -//! SSE response, point a `GoogleProvider` at the mock, and assert on the -//! `do_generate` / `do_stream` results and the request bodies. -//! -//! The response fixtures mirror the shape of the real Gemini API -//! (`candidates[].content.parts[]`, `usageMetadata`, `finishReason`). -//! Reference: `reference/ai/packages/google/src/__fixtures__/`. - -use aimux_core::tool::RawToolCall; -use futures::StreamExt; -use serde_json::{Value, json}; -use wiremock::matchers::{method, path, query_param}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -use aimux_core::content::ContentPart; -use aimux_core::error::AiMuxError; -use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; -use aimux_core::options::{CallOptions, ResponseFormat, Tool, ToolChoice}; -use aimux_core::result::{GenerateContent, StreamResult}; -use aimux_core::shared::provider_namespace; -use aimux_core::stream_part::StreamPart; -use aimux_core::tool::FunctionTool; -use aimux_core::types::{FinishReasonUnified, ResponseMetadata}; - -use aimux_providers::google::convert::{ - build_request_body, convert_to_google_messages, prepare_tools, -}; -use aimux_providers::{GoogleConfig, GoogleProvider}; - -// ── helpers ───────────────────────────────────────────────────────────────── - -/// The TS `TEST_PROMPT`: a single user text message "Hello". -fn test_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] -} - -/// Build `CallOptions` with everything unset except `prompt`. -fn default_options(prompt: LanguageModelPrompt) -> CallOptions { - CallOptions::new(prompt) -} - -/// Build `CallOptions` with tools. -fn options_with_tools(prompt: LanguageModelPrompt, tools: Vec) -> CallOptions { - CallOptions { - prompt, - max_output_tokens: None, - temperature: None, - stop_sequences: None, - top_p: None, - top_k: None, - presence_penalty: None, - frequency_penalty: None, - response_format: None, - seed: None, - tools: Some(tools.into_iter().map(Tool::from).collect()), - tool_choice: ToolChoice::Auto, - headers: None, - provider_options: None, - reasoning: None, - body_overrides: None, - max_retries: None, - timeout: None, - abort_signal: None, - session_id: None, - include_raw_chunks: None, - call_id: None, - recording_context: None, - } -} - -/// A simple function tool named `weather` with a `location` string parameter. -fn weather_tool() -> FunctionTool { - FunctionTool::new( - "weather".to_string(), - json!({ - "type": "object", - "properties": { "location": { "type": "string" } }, - "required": ["location"], - "additionalProperties": false, - }), - ) - .with_description("Get the weather".to_string()) -} - -/// Mock a JSON `generateContent` response. -async fn mock_json_response(server: &MockServer, model: &str, body: Value) { - Mock::given(method("POST")) - .and(path(format!("/models/{model}:generateContent"))) - .respond_with(ResponseTemplate::new(200).set_body_json(body)) - .mount(server) - .await; -} - -/// Mock a JSON `generateContent` response with a custom status code. -async fn mock_json_error(server: &MockServer, model: &str, status: u16, body: Value) { - Mock::given(method("POST")) - .and(path(format!("/models/{model}:generateContent"))) - .respond_with(ResponseTemplate::new(status).set_body_json(body)) - .mount(server) - .await; -} - -/// Mock an SSE `streamGenerateContent` response. -async fn mock_sse_response(server: &MockServer, model: &str, sse_body: &str) { - Mock::given(method("POST")) - .and(path(format!("/models/{model}:streamGenerateContent"))) - .and(query_param("alt", "sse")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(sse_body.to_string()), - ) - .mount(server) - .await; -} - -/// Build an SSE event string from a JSON value. -fn sse_event(json_str: &str) -> String { - format!("data: {json_str}\n\n") -} - -/// Concatenate SSE events (no `[DONE]` sentinel — Gemini doesn't use one). -fn sse_body(events: &[&str]) -> String { - let mut body = String::new(); - for event in events { - body.push_str(event); - } - body -} - -/// Collect all `StreamPart`s from a `StreamResult` into a `Vec`. -async fn collect_stream(result: StreamResult) -> Vec { - let mut parts = Vec::new(); - let mut stream = result.stream; - while let Some(part) = stream.next().await { - match part { - Ok(p) => parts.push(p), - Err(e) => panic!("stream error: {e:?}"), - } - } - parts -} - -/// Extract text deltas from a list of stream parts. -fn text_deltas(parts: &[StreamPart]) -> Vec { - parts - .iter() - .filter_map(|p| match p { - StreamPart::TextDelta { delta, .. } => Some(delta.clone()), - _ => None, - }) - .collect() -} - -// ════════════════════════════════════════════════════════════════════════════ -// doGenerate -// ════════════════════════════════════════════════════════════════════════════ - -mod do_generate { - use super::*; - - // ── should extract text response ────────────────────────────────────────── - - #[tokio::test] - async fn should_extract_text_response() { - let server = MockServer::start().await; - mock_json_response( - &server, - "gemini-2.0-flash", - json!({ - "candidates": [{ - "content": { - "parts": [{ "text": "Hello, World!" }], - "role": "model" - }, - "finishReason": "STOP", - "index": 0 - }], - "usageMetadata": { - "promptTokenCount": 4, - "candidatesTokenCount": 30, - "totalTokenCount": 34 - } - }), - ) - .await; - - let config = GoogleConfig::new("test-api-key").with_base_url(server.uri()); - let provider = GoogleProvider::new(config); - let model = provider.model("gemini-2.0-flash"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!(result.content.len(), 1); - match &result.content[0] { - GenerateContent::Text { text, .. } => assert_eq!(text, "Hello, World!"), - other => panic!("expected Text, got {other:?}"), - } - } - - // ── should extract usage ────────────────────────────────────────────────── - - #[tokio::test] - async fn should_extract_usage() { - let server = MockServer::start().await; - mock_json_response( - &server, - "gemini-2.0-flash", - json!({ - "candidates": [{ - "content": { - "parts": [{ "text": "Hello, World!" }], - "role": "model" - }, - "finishReason": "STOP", - "index": 0 - }], - "usageMetadata": { - "promptTokenCount": 20, - "candidatesTokenCount": 5, - "totalTokenCount": 25, - "cachedContentTokenCount": 3, - "thoughtsTokenCount": 2 - } - }), - ) - .await; - - let config = GoogleConfig::new("test-api-key").with_base_url(server.uri()); - let provider = GoogleProvider::new(config); - let model = provider.model("gemini-2.0-flash"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - // inputTokens.total = 20, noCache = 20 - 3 = 17, cacheRead = 3 - assert_eq!(result.usage.input_tokens.total, Some(20)); - assert_eq!(result.usage.input_tokens.no_cache, Some(17)); - assert_eq!(result.usage.input_tokens.cache_read, Some(3)); - // outputTokens.total = candidatesTokenCount(5) + thoughtsTokenCount(2) = 7 - assert_eq!(result.usage.output_tokens.total, Some(7)); - } - - // ── should map STOP finish reason ───────────────────────────────────────── - - #[tokio::test] - async fn should_map_stop_finish_reason() { - let server = MockServer::start().await; - mock_json_response( - &server, - "gemini-2.0-flash", - json!({ - "candidates": [{ - "content": { "parts": [{ "text": "hi" }], "role": "model" }, - "finishReason": "STOP", - "index": 0 - }] - }), - ) - .await; - - let config = GoogleConfig::new("test-api-key").with_base_url(server.uri()); - let provider = GoogleProvider::new(config); - let model = provider.model("gemini-2.0-flash"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!(result.finish_reason.unified, FinishReasonUnified::Stop); - assert_eq!(result.finish_reason.raw.as_deref(), Some("STOP")); - } - - // ── should map MAX_TOKENS finish reason ─────────────────────────────────── - - #[tokio::test] - async fn should_map_max_tokens_finish_reason() { - let server = MockServer::start().await; - mock_json_response( - &server, - "gemini-2.0-flash", - json!({ - "candidates": [{ - "content": { "parts": [{ "text": "…" }], "role": "model" }, - "finishReason": "MAX_TOKENS", - "index": 0 - }] - }), - ) - .await; - - let config = GoogleConfig::new("test-api-key").with_base_url(server.uri()); - let provider = GoogleProvider::new(config); - let model = provider.model("gemini-2.0-flash"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!(result.finish_reason.unified, FinishReasonUnified::Length); - assert_eq!(result.finish_reason.raw.as_deref(), Some("MAX_TOKENS")); - } - - // ── should map SAFETY finish reason ─────────────────────────────────────── - - #[tokio::test] - async fn should_map_safety_finish_reason() { - let server = MockServer::start().await; - mock_json_response( - &server, - "gemini-2.0-flash", - json!({ - "candidates": [{ - "content": { "parts": [], "role": "model" }, - "finishReason": "SAFETY", - "index": 0 - }] - }), - ) - .await; - - let config = GoogleConfig::new("test-api-key").with_base_url(server.uri()); - let provider = GoogleProvider::new(config); - let model = provider.model("gemini-2.0-flash"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!( - result.finish_reason.unified, - FinishReasonUnified::ContentFilter - ); - } - - // ── should map MALFORMED_FUNCTION_CALL finish reason ────────────────────── - - #[tokio::test] - async fn should_map_malformed_function_call_finish_reason() { - let server = MockServer::start().await; - mock_json_response( - &server, - "gemini-2.0-flash", - json!({ - "candidates": [{ - "content": {}, - "finishReason": "MALFORMED_FUNCTION_CALL" - }] - }), - ) - .await; - - let config = GoogleConfig::new("test-api-key").with_base_url(server.uri()); - let provider = GoogleProvider::new(config); - let model = provider.model("gemini-2.0-flash"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!(result.finish_reason.unified, FinishReasonUnified::Error); - assert_eq!( - result.finish_reason.raw.as_deref(), - Some("MALFORMED_FUNCTION_CALL") - ); - } - - // ── should extract a tool call (functionCall part) ──────────────────────── - - #[tokio::test] - async fn should_extract_tool_call() { - let server = MockServer::start().await; - mock_json_response( - &server, - "gemini-2.0-flash", - json!({ - "candidates": [{ - "content": { - "parts": [{ - "functionCall": { - "id": "call-1", - "name": "weather", - "args": { "location": "San Francisco" } - } - }], - "role": "model" - }, - "finishReason": "STOP", - "index": 0 - }] - }), - ) - .await; - - let config = GoogleConfig::new("test-api-key").with_base_url(server.uri()); - let provider = GoogleProvider::new(config); - let model = provider.model("gemini-2.0-flash"); - - let result = model - .do_generate(&options_with_tools(test_prompt(), vec![weather_tool()])) - .await - .expect("do_generate should succeed"); - - assert_eq!(result.content.len(), 1); - match &result.content[0] { - GenerateContent::ToolCall(RawToolCall { - tool_call_id, - tool_name, - input, - .. - }) => { - assert_eq!(tool_call_id, "call-1"); - assert_eq!(tool_name, "weather"); - assert_eq!(input, r#"{"location":"San Francisco"}"#); - } - other => panic!("expected ToolCall, got {other:?}"), - } - // STOP + has_tool_calls -> ToolCalls - assert_eq!(result.finish_reason.unified, FinishReasonUnified::ToolCalls); - } - - // ── should extract tool call with thought signature (thinking models) ──── - - #[tokio::test] - async fn should_extract_tool_call_with_thought_signature() { - let server = MockServer::start().await; - mock_json_response( - &server, - "gemini-2.5-pro", - json!({ - "candidates": [{ - "content": { - "parts": [{ - "functionCall": { - "id": "call-1", - "name": "weather", - "args": { "location": "San Francisco" } - }, - "thoughtSignature": "EuIDCt8DARFNMg/aRDRK3THWhBjzltCEy5/VM6ImWLJU8oHmnC75abdcZBMH" - }], - "role": "model" - }, - "finishReason": "STOP", - "index": 0 - }] - }), - ) - .await; - - let config = GoogleConfig::new("test-api-key").with_base_url(server.uri()); - let provider = GoogleProvider::new(config); - let model = provider.model("gemini-2.5-pro"); - - let result = model - .do_generate(&options_with_tools(test_prompt(), vec![weather_tool()])) - .await - .expect("do_generate should succeed"); - - assert_eq!(result.content.len(), 1); - match &result.content[0] { - GenerateContent::ToolCall(RawToolCall { - tool_call_id, - tool_name, - input, - thought_signature, - .. - }) => { - assert_eq!(tool_call_id, "call-1"); - assert_eq!(tool_name, "weather"); - assert_eq!(input, r#"{"location":"San Francisco"}"#); - assert_eq!( - thought_signature.as_deref(), - Some("EuIDCt8DARFNMg/aRDRK3THWhBjzltCEy5/VM6ImWLJU8oHmnC75abdcZBMH") - ); - } - other => panic!("expected ToolCall, got {other:?}"), - } - } - - // ── should expose response id ───────────────────────────────────────────── - - #[tokio::test] - async fn should_expose_response_id() { - let server = MockServer::start().await; - mock_json_response( - &server, - "gemini-2.0-flash", - json!({ - "candidates": [{ - "content": { "parts": [{ "text": "hi" }], "role": "model" }, - "finishReason": "STOP", - "index": 0 - }], - "responseId": "TestResponseId123" - }), - ) - .await; - - let config = GoogleConfig::new("test-api-key").with_base_url(server.uri()); - let provider = GoogleProvider::new(config); - let model = provider.model("gemini-2.0-flash"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!(result.response.id, Some("TestResponseId123".to_string())); - } - - // ── should return the request body for debugging ────────────────────────── - - #[tokio::test] - async fn should_return_request_body() { - let server = MockServer::start().await; - mock_json_response( - &server, - "gemini-2.0-flash", - json!({ - "candidates": [{ - "content": { "parts": [{ "text": "hi" }], "role": "model" }, - "finishReason": "STOP", - "index": 0 - }] - }), - ) - .await; - - let config = GoogleConfig::new("test-api-key").with_base_url(server.uri()); - let provider = GoogleProvider::new(config); - let model = provider.model("gemini-2.0-flash"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - let body = result - .request_body - .as_ref() - .expect("request body should be present"); - // contents should be [{ role: "user", parts: [{ text: "Hello" }] }] - assert_eq!(body["contents"][0]["role"], "user"); - assert_eq!(body["contents"][0]["parts"][0]["text"], "Hello"); - } - - // ── should expose finishMessage in provider metadata ───────────────────── - // TS: "should expose finishMessage in provider metadata" - - #[tokio::test] - async fn should_expose_finish_message_in_provider_metadata() { - let server = MockServer::start().await; - mock_json_response( - &server, - "gemini-2.0-flash", - json!({ - "candidates": [{ - "content": {}, - "finishReason": "MALFORMED_FUNCTION_CALL", - "finishMessage": "Malformed function call: print(default_api.create(name='test'))" - }], - "usageMetadata": { "promptTokenCount": 130, "totalTokenCount": 130 } - }), - ) - .await; - - let config = GoogleConfig::new("test-api-key").with_base_url(server.uri()); - let provider = GoogleProvider::new(config); - let model = provider.model("gemini-2.0-flash"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - let pm = result - .provider_metadata - .as_ref() - .expect("provider metadata"); - assert_eq!( - pm["google"]["finishMessage"], - "Malformed function call: print(default_api.create(name='test'))" - ); - } - - // ── should expose null finishMessage when not present ──────────────────── - - #[tokio::test] - async fn should_expose_null_finish_message_when_not_present() { - let server = MockServer::start().await; - mock_json_response( - &server, - "gemini-2.0-flash", - json!({ - "candidates": [{ - "content": { "parts": [{ "text": "test response" }], "role": "model" }, - "finishReason": "STOP", "index": 0 - }] - }), - ) - .await; - - let config = GoogleConfig::new("test-api-key").with_base_url(server.uri()); - let provider = GoogleProvider::new(config); - let model = provider.model("gemini-2.0-flash"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - let pm = result - .provider_metadata - .as_ref() - .expect("provider metadata"); - assert!(pm["google"]["finishMessage"].is_null()); - } - - // ── should expose safety ratings in provider metadata ──────────────────── - // TS: "should expose safety ratings in provider metadata" - - #[tokio::test] - async fn should_expose_safety_ratings_in_provider_metadata() { - let server = MockServer::start().await; - mock_json_response( - &server, - "gemini-2.0-flash", - json!({ - "candidates": [{ - "content": { "parts": [{ "text": "test response" }], "role": "model" }, - "finishReason": "STOP", "index": 0, - "safetyRatings": [{ - "category": "HARM_CATEGORY_DANGEROUS_CONTENT", - "probability": "NEGLIGIBLE", - "probabilityScore": 0.1, - "severity": "LOW", - "severityScore": 0.2, - "blocked": false - }] - }], - "promptFeedback": { "safetyRatings": [ - { "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "probability": "NEGLIGIBLE" } - ]} - }), - ) - .await; - - let config = GoogleConfig::new("test-api-key").with_base_url(server.uri()); - let provider = GoogleProvider::new(config); - let model = provider.model("gemini-2.0-flash"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - let pm = result - .provider_metadata - .as_ref() - .expect("provider metadata"); - let ratings = pm["google"]["safetyRatings"].as_array().expect("array"); - assert_eq!(ratings.len(), 1); - assert_eq!(ratings[0]["category"], "HARM_CATEGORY_DANGEROUS_CONTENT"); - assert_eq!(ratings[0]["probability"], "NEGLIGIBLE"); - assert_eq!(ratings[0]["probabilityScore"], 0.1); - assert_eq!(ratings[0]["blocked"], false); - } - - // ── should expose PromptFeedback in provider metadata ──────────────────── - // TS: "should expose PromptFeedback in provider metadata" - - #[tokio::test] - async fn should_expose_prompt_feedback_in_provider_metadata() { - let server = MockServer::start().await; - mock_json_response( - &server, - "gemini-2.0-flash", - json!({ - "candidates": [{ - "content": { "parts": [{ "text": "No" }], "role": "model" }, - "finishReason": "SAFETY", "index": 0 - }], - "promptFeedback": { - "blockReason": "SAFETY", - "safetyRatings": [ - { "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "probability": "NEGLIGIBLE" }, - { "category": "HARM_CATEGORY_HATE_SPEECH", "probability": "NEGLIGIBLE" }, - { "category": "HARM_CATEGORY_HARASSMENT", "probability": "NEGLIGIBLE" }, - { "category": "HARM_CATEGORY_DANGEROUS_CONTENT", "probability": "NEGLIGIBLE" } - ] - } - }), - ) - .await; - - let config = GoogleConfig::new("test-api-key").with_base_url(server.uri()); - let provider = GoogleProvider::new(config); - let model = provider.model("gemini-2.0-flash"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - let pm = result - .provider_metadata - .as_ref() - .expect("provider metadata"); - let pf = &pm["google"]["promptFeedback"]; - assert_eq!(pf["blockReason"], "SAFETY"); - let ratings = pf["safetyRatings"].as_array().expect("array"); - assert_eq!(ratings.len(), 4); - } - - // ── should expose grounding metadata in provider metadata ──────────────── - // TS: "should expose grounding metadata in provider metadata" - - #[tokio::test] - async fn should_expose_grounding_metadata_in_provider_metadata() { - let server = MockServer::start().await; - mock_json_response( - &server, - "gemini-2.0-flash", - json!({ - "candidates": [{ - "content": { "parts": [{ "text": "test response" }], "role": "model" }, - "finishReason": "STOP", "index": 0, - "groundingMetadata": { - "webSearchQueries": ["What's the weather?"], - "groundingChunks": [{ - "web": { "uri": "https://example.com/weather", "title": "Forecast" } - }] - } - }] - }), - ) - .await; - - let config = GoogleConfig::new("test-api-key").with_base_url(server.uri()); - let provider = GoogleProvider::new(config); - let model = provider.model("gemini-2.0-flash"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - let pm = result - .provider_metadata - .as_ref() - .expect("provider metadata"); - let gm = &pm["google"]["groundingMetadata"]; - assert_eq!(gm["webSearchQueries"][0], "What's the weather?"); - assert_eq!( - gm["groundingChunks"][0]["web"]["uri"], - "https://example.com/weather" - ); - } - - // ── should expose response headers ─────────────────────────────────────── - // TS: "should expose the raw response headers" - - #[tokio::test] - async fn should_expose_response_headers() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/models/gemini-2.0-flash:generateContent")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("test-header", "test-value") - .set_body_json(json!({ - "candidates": [{ - "content": { "parts": [{ "text": "hi" }], "role": "model" }, - "finishReason": "STOP", "index": 0 - }] - })), - ) - .mount(&server) - .await; - - let config = GoogleConfig::new("test-api-key").with_base_url(server.uri()); - let provider = GoogleProvider::new(config); - let model = provider.model("gemini-2.0-flash"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - let headers = result.response_headers.expect("response headers"); - assert_eq!(headers.get("test-header"), Some(&"test-value".to_string())); - } - - // ── should handle empty content with MALFORMED_FUNCTION_CALL ───────────── - // TS: "should handle MALFORMED_FUNCTION_CALL finish reason and empty content object" - - #[tokio::test] - async fn should_handle_empty_content_with_malformed_function_call() { - let server = MockServer::start().await; - mock_json_response( - &server, - "gemini-2.0-flash", - json!({ - "candidates": [{ "content": {}, "finishReason": "MALFORMED_FUNCTION_CALL" }], - "usageMetadata": { "promptTokenCount": 9056, "totalTokenCount": 9056 } - }), - ) - .await; - - let config = GoogleConfig::new("test-api-key").with_base_url(server.uri()); - let provider = GoogleProvider::new(config); - let model = provider.model("gemini-2.0-flash"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert!(result.content.is_empty(), "content should be empty"); - assert_eq!(result.finish_reason.unified, FinishReasonUnified::Error); - assert_eq!( - result.finish_reason.raw.as_deref(), - Some("MALFORMED_FUNCTION_CALL") - ); - } - - // ── should extract tool call without id ────────────────────────────────── - - #[tokio::test] - async fn should_extract_tool_call_without_id() { - let server = MockServer::start().await; - mock_json_response( - &server, - "gemini-2.0-flash", - json!({ - "candidates": [{ - "content": { - "parts": [{ "functionCall": { "name": "weather", "args": { "location": "SF" } } }], - "role": "model" - }, - "finishReason": "STOP", "index": 0 - }] - }), - ) - .await; - - let config = GoogleConfig::new("test-api-key").with_base_url(server.uri()); - let provider = GoogleProvider::new(config); - let model = provider.model("gemini-2.0-flash"); - - let result = model - .do_generate(&options_with_tools(test_prompt(), vec![weather_tool()])) - .await - .expect("do_generate should succeed"); - - assert_eq!(result.content.len(), 1); - match &result.content[0] { - GenerateContent::ToolCall(RawToolCall { - tool_call_id, - tool_name, - input, - .. - }) => { - assert_eq!(tool_call_id, ""); - assert_eq!(tool_name, "weather"); - assert_eq!(input, r#"{"location":"SF"}"#); - } - other => panic!("expected ToolCall, got {other:?}"), - } - assert_eq!(result.finish_reason.unified, FinishReasonUnified::ToolCalls); - } - - // ── should extract multiple tool calls ─────────────────────────────────── - - #[tokio::test] - async fn should_extract_multiple_tool_calls() { - let server = MockServer::start().await; - mock_json_response( - &server, - "gemini-2.0-flash", - json!({ - "candidates": [{ - "content": { - "parts": [ - { "functionCall": { "id": "call-1", "name": "weather", "args": { "location": "SF" } } }, - { "functionCall": { "id": "call-2", "name": "calendar", "args": { "date": "2024-01-01" } } } - ], - "role": "model" - }, - "finishReason": "STOP", "index": 0 - }] - }), - ) - .await; - - let config = GoogleConfig::new("test-api-key").with_base_url(server.uri()); - let provider = GoogleProvider::new(config); - let model = provider.model("gemini-2.0-flash"); - - let result = model - .do_generate(&options_with_tools(test_prompt(), vec![weather_tool()])) - .await - .expect("do_generate should succeed"); - - assert_eq!(result.content.len(), 2); - match &result.content[0] { - GenerateContent::ToolCall(RawToolCall { - tool_call_id, - tool_name, - .. - }) => { - assert_eq!(tool_call_id, "call-1"); - assert_eq!(tool_name, "weather"); - } - other => panic!("expected first ToolCall, got {other:?}"), - } - match &result.content[1] { - GenerateContent::ToolCall(RawToolCall { - tool_call_id, - tool_name, - input, - .. - }) => { - assert_eq!(tool_call_id, "call-2"); - assert_eq!(tool_name, "calendar"); - assert_eq!(input, r#"{"date":"2024-01-01"}"#); - } - other => panic!("expected second ToolCall, got {other:?}"), - } - } - - // ── should extract text and tool call together ─────────────────────────── - - #[tokio::test] - async fn should_extract_text_and_tool_call() { - let server = MockServer::start().await; - mock_json_response( - &server, - "gemini-2.0-flash", - json!({ - "candidates": [{ - "content": { - "parts": [ - { "text": "Let me check the weather." }, - { "functionCall": { "id": "call-1", "name": "weather", "args": { "location": "SF" } } } - ], - "role": "model" - }, - "finishReason": "STOP", "index": 0 - }] - }), - ) - .await; - - let config = GoogleConfig::new("test-api-key").with_base_url(server.uri()); - let provider = GoogleProvider::new(config); - let model = provider.model("gemini-2.0-flash"); - - let result = model - .do_generate(&options_with_tools(test_prompt(), vec![weather_tool()])) - .await - .expect("do_generate should succeed"); - - assert_eq!(result.content.len(), 2); - match &result.content[0] { - GenerateContent::Text { text, .. } => assert_eq!(text, "Let me check the weather."), - other => panic!("expected Text, got {other:?}"), - } - match &result.content[1] { - GenerateContent::ToolCall(RawToolCall { tool_name, .. }) => { - assert_eq!(tool_name, "weather") - } - other => panic!("expected ToolCall, got {other:?}"), - } - } - - // ── should map RECITATION finish reason ────────────────────────────────── - - #[tokio::test] - async fn should_map_recitation_finish_reason() { - let server = MockServer::start().await; - mock_json_response( - &server, "gemini-2.0-flash", - json!({ "candidates": [{ "content": { "parts": [], "role": "model" }, "finishReason": "RECITATION", "index": 0 }] }), - ) - .await; - - let config = GoogleConfig::new("test-api-key").with_base_url(server.uri()); - let provider = GoogleProvider::new(config); - let model = provider.model("gemini-2.0-flash"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("ok"); - assert_eq!( - result.finish_reason.unified, - FinishReasonUnified::ContentFilter - ); - assert_eq!(result.finish_reason.raw.as_deref(), Some("RECITATION")); - } - - // ── should map unknown finish reason to Other ──────────────────────────── - - #[tokio::test] - async fn should_map_unknown_finish_reason_to_other() { - let server = MockServer::start().await; - mock_json_response( - &server, "gemini-2.0-flash", - json!({ "candidates": [{ "content": { "parts": [{ "text": "hi" }], "role": "model" }, "finishReason": "UNKNOWN_FUTURE", "index": 0 }] }), - ) - .await; - - let config = GoogleConfig::new("test-api-key").with_base_url(server.uri()); - let provider = GoogleProvider::new(config); - let model = provider.model("gemini-2.0-flash"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("ok"); - assert_eq!(result.finish_reason.unified, FinishReasonUnified::Other); - assert_eq!(result.finish_reason.raw.as_deref(), Some("UNKNOWN_FUTURE")); - } - - // ── should return default usage when usageMetadata is missing ──────────── - - #[tokio::test] - async fn should_return_default_usage_when_missing() { - let server = MockServer::start().await; - mock_json_response( - &server, "gemini-2.0-flash", - json!({ "candidates": [{ "content": { "parts": [{ "text": "hi" }], "role": "model" }, "finishReason": "STOP", "index": 0 }] }), - ) - .await; - - let config = GoogleConfig::new("test-api-key").with_base_url(server.uri()); - let provider = GoogleProvider::new(config); - let model = provider.model("gemini-2.0-flash"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("ok"); - assert_eq!(result.usage.input_tokens.total, None); - assert_eq!(result.usage.output_tokens.total, None); - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// doStream -// ════════════════════════════════════════════════════════════════════════════ - -mod do_stream { - use super::*; - - // ── should stream text deltas ───────────────────────────────────────────── - - #[tokio::test] - async fn should_stream_text_deltas() { - let server = MockServer::start().await; - let body = sse_body(&[ - &sse_event( - r#"{"candidates":[{"content":{"parts":[{"text":"Hello"}],"role":"model"},"index":0}],"responseId":"resp-1"}"#, - ), - &sse_event( - r#"{"candidates":[{"content":{"parts":[{"text":", World"}],"role":"model"},"index":0}],"responseId":"resp-1"}"#, - ), - &sse_event( - r#"{"candidates":[{"content":{"parts":[{"text":"!"}],"role":"model"},"finishReason":"STOP","index":0}],"responseId":"resp-1"}"#, - ), - ]); - mock_sse_response(&server, "gemini-2.0-flash", &body).await; - - let config = GoogleConfig::new("test-api-key").with_base_url(server.uri()); - let provider = GoogleProvider::new(config); - let model = provider.model("gemini-2.0-flash"); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - let parts = collect_stream(result).await; - - // stream-start - assert!(matches!(parts[0], StreamPart::StreamStart { .. })); - - // response-metadata - match &parts[1] { - StreamPart::ResponseMetadata(ResponseMetadata { id, .. }) => { - assert_eq!(id.as_deref(), Some("resp-1")); - } - other => panic!("expected ResponseMetadata, got {other:?}"), - } - - // text-start - let _text_start = parts - .iter() - .find(|p| matches!(p, StreamPart::TextStart { .. })) - .expect("should have TextStart"); - - // text-deltas - let deltas = text_deltas(&parts); - assert_eq!( - deltas, - vec!["Hello".to_string(), ", World".to_string(), "!".to_string(),] - ); - - // finish - let finish = parts - .iter() - .find(|p| matches!(p, StreamPart::Finish { .. })); - match finish { - Some(StreamPart::Finish { finish_reason, .. }) => { - assert_eq!(finish_reason.unified, FinishReasonUnified::Stop); - assert_eq!(finish_reason.raw.as_deref(), Some("STOP")); - } - other => panic!("expected Finish, got {other:?}"), - } - } - - // ── should stream a tool call ───────────────────────────────────────────── - - #[tokio::test] - async fn should_stream_tool_call() { - let server = MockServer::start().await; - let body = sse_body(&[ - &sse_event( - r#"{"candidates":[{"content":{"parts":[{"functionCall":{"id":"call-1","name":"weather","args":{"location":"San Francisco"}}}],"role":"model"},"index":0}],"responseId":"resp-2"}"#, - ), - &sse_event( - r#"{"candidates":[{"content":{"parts":[{"text":""}],"role":"model"},"finishReason":"STOP","index":0}],"responseId":"resp-2"}"#, - ), - ]); - mock_sse_response(&server, "gemini-2.0-flash", &body).await; - - let config = GoogleConfig::new("test-api-key").with_base_url(server.uri()); - let provider = GoogleProvider::new(config); - let model = provider.model("gemini-2.0-flash"); - - let result = model - .do_stream(&options_with_tools(test_prompt(), vec![weather_tool()])) - .await - .expect("do_stream should succeed"); - let parts = collect_stream(result).await; - - // Verify tool-input-start - let tool_start = parts.iter().find(|p| { - matches!(p, StreamPart::ToolInputStart { id, tool_name, .. } if id == "call-1" && tool_name == "weather") - }); - assert!(tool_start.is_some(), "should have ToolInputStart"); - - // Verify tool-input-end - let tool_end = parts - .iter() - .find(|p| matches!(p, StreamPart::ToolInputEnd { id, .. } if id == "call-1")); - assert!(tool_end.is_some(), "should have ToolInputEnd"); - - // Verify tool-call - let tool_call = parts.iter().find(|p| { - matches!(p, StreamPart::ToolCall(RawToolCall { tool_call_id, tool_name, input, .. }) - if tool_call_id == "call-1" - && tool_name == "weather" - && input == r#"{"location":"San Francisco"}"#) - }); - assert!( - tool_call.is_some(), - "should have ToolCall with correct input" - ); - - // Verify finish with tool-calls reason (STOP + has_tool_calls) - let finish = parts - .iter() - .find(|p| matches!(p, StreamPart::Finish { .. })); - match finish { - Some(StreamPart::Finish { finish_reason, .. }) => { - assert_eq!(finish_reason.unified, FinishReasonUnified::ToolCalls); - } - other => panic!("expected Finish, got {other:?}"), - } - } - - // ── should stream tool call with thought signature (thinking models) ────── - - #[tokio::test] - async fn should_stream_tool_call_with_thought_signature() { - let server = MockServer::start().await; - let body = sse_body(&[ - &sse_event( - r#"{"candidates":[{"content":{"parts":[{"functionCall":{"id":"call-1","name":"weather","args":{"location":"San Francisco"}},"thoughtSignature":"EuIDCt8DARFNMg/aRDRK3THWhBjzltCEy5/VM6ImWLJU8oHmnC75abdcZBMH"}],"role":"model"},"index":0}],"responseId":"resp-2"}"#, - ), - &sse_event( - r#"{"candidates":[{"content":{"parts":[{"text":""}],"role":"model"},"finishReason":"STOP","index":0}],"responseId":"resp-2"}"#, - ), - ]); - mock_sse_response(&server, "gemini-2.5-pro", &body).await; - - let config = GoogleConfig::new("test-api-key").with_base_url(server.uri()); - let provider = GoogleProvider::new(config); - let model = provider.model("gemini-2.5-pro"); - - let result = model - .do_stream(&options_with_tools(test_prompt(), vec![weather_tool()])) - .await - .expect("do_stream should succeed"); - let parts = collect_stream(result).await; - - // The complete tool-call event carries the thought signature. - let tool_call = parts.iter().find(|p| { - matches!(p, StreamPart::ToolCall(RawToolCall { tool_call_id, thought_signature, .. }) - if tool_call_id == "call-1" - && thought_signature.as_deref() - == Some("EuIDCt8DARFNMg/aRDRK3THWhBjzltCEy5/VM6ImWLJU8oHmnC75abdcZBMH")) - }); - assert!( - tool_call.is_some(), - "should have ToolCall carrying the thought signature" - ); - } - - // ── empty-text thoughtSignature while a reasoning block is open ─────────── - // Regression: the signature used to be silently dropped unless a *text* - // block was open. Gemini thinking models emit the signature on an - // empty-text part right after the thought text — while the reasoning - // block is the open one. - - #[tokio::test] - async fn should_stream_empty_text_signature_onto_open_reasoning_block() { - let server = MockServer::start().await; - let body = sse_body(&[ - &sse_event( - r#"{"candidates":[{"content":{"parts":[{"text":"Thinking hard","thought":true}],"role":"model"},"index":0}],"responseId":"resp-3"}"#, - ), - &sse_event( - r#"{"candidates":[{"content":{"parts":[{"text":"","thoughtSignature":"sig-on-empty-text"}],"role":"model"},"index":0}],"responseId":"resp-3"}"#, - ), - &sse_event( - r#"{"candidates":[{"content":{"parts":[{"text":"The answer"}],"role":"model"},"finishReason":"STOP","index":0}],"responseId":"resp-3"}"#, - ), - ]); - mock_sse_response(&server, "gemini-2.5-pro", &body).await; - - let config = GoogleConfig::new("test-api-key").with_base_url(server.uri()); - let provider = GoogleProvider::new(config); - let model = provider.model("gemini-2.5-pro"); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - let parts = collect_stream(result).await; - - // The reasoning block opens with id "0" and receives the thought text. - let reasoning_delta = parts.iter().find( - |p| matches!(p, StreamPart::ReasoningDelta { delta, .. } if delta == "Thinking hard"), - ); - assert!( - reasoning_delta.is_some(), - "thought text should stream as ReasoningDelta" - ); - - // The signature on the empty-text part must attach to the open - // reasoning block as a zero-length ReasoningDelta — not be dropped. - let sig_delta = parts.iter().find(|p| { - matches!(p, StreamPart::ReasoningDelta { id, delta, provider_metadata } - if id == "0" - && delta.is_empty() - && provider_metadata.as_ref().and_then(|m| m["google"]["thoughtSignature"].as_str()) - == Some("sig-on-empty-text")) - }); - assert!( - sig_delta.is_some(), - "empty-text thoughtSignature must ride the open reasoning block, got {parts:?}" - ); - } - - // ── should expose usage from the final chunk ────────────────────────────── - - #[tokio::test] - async fn should_expose_usage_from_final_chunk() { - let server = MockServer::start().await; - let body = sse_body(&[ - &sse_event( - r#"{"candidates":[{"content":{"parts":[{"text":"Hi"}],"role":"model"},"index":0}]}"#, - ), - &sse_event( - r#"{"candidates":[{"content":{"parts":[{"text":""}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":3,"totalTokenCount":13}}"#, - ), - ]); - mock_sse_response(&server, "gemini-2.0-flash", &body).await; - - let config = GoogleConfig::new("test-api-key").with_base_url(server.uri()); - let provider = GoogleProvider::new(config); - let model = provider.model("gemini-2.0-flash"); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - let parts = collect_stream(result).await; - - let finish = parts - .iter() - .find(|p| matches!(p, StreamPart::Finish { .. })); - match finish { - Some(StreamPart::Finish { usage, .. }) => { - assert_eq!(usage.input_tokens.total, Some(10)); - assert_eq!(usage.output_tokens.total, Some(3)); - } - other => panic!("expected Finish, got {other:?}"), - } - } - - // ── should stream text then tool call ──────────────────────────────────── - // TS: "should set finishReason to tool-calls when chunk contains functionCall" - - #[tokio::test] - async fn should_stream_text_then_tool_call() { - let server = MockServer::start().await; - let body = sse_body(&[ - &sse_event( - r#"{"candidates":[{"content":{"parts":[{"text":"Initial text response"}],"role":"model"},"index":0}]}"#, - ), - &sse_event( - r#"{"candidates":[{"content":{"parts":[{"functionCall":{"name":"test-tool","args":{"value":"example value"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":20,"totalTokenCount":30}}"#, - ), - ]); - mock_sse_response(&server, "gemini-2.0-flash", &body).await; - - let config = GoogleConfig::new("test-api-key").with_base_url(server.uri()); - let provider = GoogleProvider::new(config); - let model = provider.model("gemini-2.0-flash"); - - let result = model - .do_stream(&options_with_tools(test_prompt(), vec![weather_tool()])) - .await - .expect("do_stream should succeed"); - let parts = collect_stream(result).await; - - // Should have text deltas - let deltas = text_deltas(&parts); - assert_eq!(deltas, vec!["Initial text response".to_string()]); - - // Should have a tool call - let tool_call = parts.iter().find( - |p| matches!(p, StreamPart::ToolCall(RawToolCall { tool_name, .. }) if tool_name == "test-tool"), - ); - assert!(tool_call.is_some(), "should have ToolCall"); - - // STOP + has_tool_calls → ToolCalls - let finish = parts - .iter() - .find(|p| matches!(p, StreamPart::Finish { .. })); - match finish { - Some(StreamPart::Finish { - finish_reason, - usage, - .. - }) => { - assert_eq!(finish_reason.unified, FinishReasonUnified::ToolCalls); - assert_eq!(finish_reason.raw.as_deref(), Some("STOP")); - assert_eq!(usage.input_tokens.total, Some(10)); - assert_eq!(usage.output_tokens.total, Some(20)); - } - other => panic!("expected Finish, got {other:?}"), - } - } - - // ── should stream with no candidates (only usage) ──────────────────────── - - #[tokio::test] - async fn should_stream_with_no_candidates() { - let server = MockServer::start().await; - let body = sse_body(&[&sse_event( - r#"{"usageMetadata":{"promptTokenCount":5,"candidatesTokenCount":0,"totalTokenCount":5}}"#, - )]); - mock_sse_response(&server, "gemini-2.0-flash", &body).await; - - let config = GoogleConfig::new("test-api-key").with_base_url(server.uri()); - let provider = GoogleProvider::new(config); - let model = provider.model("gemini-2.0-flash"); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - let parts = collect_stream(result).await; - - // No text deltas - let deltas = text_deltas(&parts); - assert!(deltas.is_empty(), "should have no text deltas"); - - // Should still have a Finish with usage - let finish = parts - .iter() - .find(|p| matches!(p, StreamPart::Finish { .. })); - match finish { - Some(StreamPart::Finish { usage, .. }) => { - assert_eq!(usage.input_tokens.total, Some(5)); - } - other => panic!("expected Finish, got {other:?}"), - } - } - - // ── should stream response metadata with model version ─────────────────── - // TS: "should emit response-metadata with the provider responseId" - - #[tokio::test] - async fn should_stream_response_metadata_with_model_version() { - let server = MockServer::start().await; - let body = sse_body(&[ - &sse_event( - r#"{"candidates":[{"content":{"parts":[{"text":"Hi"}],"role":"model"},"index":0}],"responseId":"resp-xyz","modelVersion":"gemini-2.0-flash-001"}"#, - ), - &sse_event( - r#"{"candidates":[{"content":{"parts":[{"text":""}],"role":"model"},"finishReason":"STOP","index":0}]}"#, - ), - ]); - mock_sse_response(&server, "gemini-2.0-flash", &body).await; - - let config = GoogleConfig::new("test-api-key").with_base_url(server.uri()); - let provider = GoogleProvider::new(config); - let model = provider.model("gemini-2.0-flash"); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - let parts = collect_stream(result).await; - - let rm = parts - .iter() - .find(|p| matches!(p, StreamPart::ResponseMetadata(_))); - match rm { - Some(StreamPart::ResponseMetadata(ResponseMetadata { id, model_id, .. })) => { - assert_eq!(id.as_deref(), Some("resp-xyz")); - assert_eq!(model_id.as_deref(), Some("gemini-2.0-flash-001")); - } - other => panic!("expected ResponseMetadata, got {other:?}"), - } - } - - // ── should close text segment on finish ────────────────────────────────── - - #[tokio::test] - async fn should_close_text_segment_on_finish() { - let server = MockServer::start().await; - let body = sse_body(&[ - &sse_event( - r#"{"candidates":[{"content":{"parts":[{"text":"Hello world"}],"role":"model"},"index":0}]}"#, - ), - &sse_event( - r#"{"candidates":[{"content":{"parts":[{"text":""}],"role":"model"},"finishReason":"STOP","index":0}]}"#, - ), - ]); - mock_sse_response(&server, "gemini-2.0-flash", &body).await; - - let config = GoogleConfig::new("test-api-key").with_base_url(server.uri()); - let provider = GoogleProvider::new(config); - let model = provider.model("gemini-2.0-flash"); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - let parts = collect_stream(result).await; - - // Should have TextStart and TextEnd - let text_start = parts - .iter() - .find(|p| matches!(p, StreamPart::TextStart { .. })); - assert!(text_start.is_some(), "should have TextStart"); - - let text_end = parts - .iter() - .find(|p| matches!(p, StreamPart::TextEnd { .. })); - assert!(text_end.is_some(), "should have TextEnd"); - - // TextStart id should match TextEnd id - if let ( - Some(StreamPart::TextStart { id: start_id, .. }), - Some(StreamPart::TextEnd { id: end_id, .. }), - ) = (text_start, text_end) - { - assert_eq!(start_id, end_id); - } - } -} -// ════════════════════════════════════════════════════════════════════════════ - -mod error_handling { - use super::*; - - // ── should surface 401 as Auth ──────────────────────────────────────────── - - #[tokio::test] - async fn should_surface_401_as_auth_error() { - let server = MockServer::start().await; - mock_json_error( - &server, - "gemini-2.0-flash", - 401, - json!({ - "error": { - "code": 401, - "message": "API key not valid.", - "status": "UNAUTHENTICATED" - } - }), - ) - .await; - - let config = GoogleConfig::new("test-api-key").with_base_url(server.uri()); - let provider = GoogleProvider::new(config); - let model = provider.model("gemini-2.0-flash"); - - let err = model - .do_generate(&default_options(test_prompt())) - .await - .expect_err("should error"); - - assert_eq!(err.status_code(), Some(401), "got {err:?}"); - assert!(err.to_string().contains("API key not valid.")); - } - - // ── should surface 404 as ModelNotFound ─────────────────────────────────── - - #[tokio::test] - async fn should_surface_404_as_model_not_found() { - let server = MockServer::start().await; - mock_json_error( - &server, - "gemini-2.0-flash", - 404, - json!({ - "error": { - "code": 404, - "message": "models/not-a-model is not found", - "status": "NOT_FOUND" - } - }), - ) - .await; - - let config = GoogleConfig::new("test-api-key").with_base_url(server.uri()); - let provider = GoogleProvider::new(config); - let model = provider.model("gemini-2.0-flash"); - - let err = model - .do_generate(&default_options(test_prompt())) - .await - .expect_err("should error"); - - assert_eq!(err.status_code(), Some(404), "got {err:?}"); - assert!(err.to_string().contains("not found")); - } - - // ── should surface 429 as RateLimited ───────────────────────────────────── - - #[tokio::test] - async fn should_surface_429_as_rate_limited() { - let server = MockServer::start().await; - mock_json_error( - &server, - "gemini-2.0-flash", - 429, - json!({ - "error": { - "code": 429, - "message": "Resource exhausted.", - "status": "RESOURCE_EXHAUSTED" - } - }), - ) - .await; - - let config = GoogleConfig::new("test-api-key").with_base_url(server.uri()); - let provider = GoogleProvider::new(config); - let model = provider.model("gemini-2.0-flash"); - - let err = model - .do_generate(&default_options(test_prompt())) - .await - .expect_err("should error"); - - assert!(matches!(err, ref e if e.status_code() == Some(429))); - } - - // ── should surface 500 as Provider ──────────────────────────────────────── - - #[tokio::test] - async fn should_surface_500_as_provider_error() { - let server = MockServer::start().await; - mock_json_error( - &server, - "gemini-2.0-flash", - 500, - json!({ - "error": { - "code": 500, - "message": "Internal error.", - "status": "INTERNAL" - } - }), - ) - .await; - - let config = GoogleConfig::new("test-api-key").with_base_url(server.uri()); - let provider = GoogleProvider::new(config); - let model = provider.model("gemini-2.0-flash"); - - let err = model - .do_generate(&default_options(test_prompt())) - .await - .expect_err("should error"); - - match err { - AiMuxError::ApiCall(d) => assert!(d.to_string().contains("Internal error")), - other => panic!("expected Provider, got {other:?}"), - } - } - - // ── should surface stream errors ────────────────────────────────────────── - - #[tokio::test] - async fn should_surface_stream_http_error() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/models/gemini-2.0-flash:streamGenerateContent")) - .and(query_param("alt", "sse")) - .respond_with(ResponseTemplate::new(401).set_body_json(json!({ - "error": { "code": 401, "message": "bad key", "status": "UNAUTHENTICATED" } - }))) - .mount(&server) - .await; - - let config = GoogleConfig::new("test-api-key").with_base_url(server.uri()); - let provider = GoogleProvider::new(config); - let model = provider.model("gemini-2.0-flash"); - - let err = model - .do_stream(&default_options(test_prompt())) - .await - .expect_err("should error"); - - assert!(matches!(err, ref e if e.status_code() == Some(401))); - } - - // ── should surface 400 as Provider ─────────────────────────────────────── - - #[tokio::test] - async fn should_surface_400_as_provider_error() { - let server = MockServer::start().await; - mock_json_error( - &server, "gemini-2.0-flash", 400, - json!({ "error": { "code": 400, "message": "Invalid request.", "status": "INVALID_ARGUMENT" } }), - ) - .await; - - let config = GoogleConfig::new("test-api-key").with_base_url(server.uri()); - let provider = GoogleProvider::new(config); - let model = provider.model("gemini-2.0-flash"); - - let err = model - .do_generate(&default_options(test_prompt())) - .await - .expect_err("should error"); - match err { - AiMuxError::ApiCall(msg_d) => { - let msg = msg_d.to_string(); - assert!(msg.contains("Invalid request"), "msg was: {msg}") - } - other => panic!("expected Provider, got {other:?}"), - } - } - - // ── should surface 403 as Provider ─────────────────────────────────────── - - #[tokio::test] - async fn should_surface_403_as_provider_error() { - let server = MockServer::start().await; - mock_json_error( - &server, "gemini-2.0-flash", 403, - json!({ "error": { "code": 403, "message": "Permission denied.", "status": "PERMISSION_DENIED" } }), - ) - .await; - - let config = GoogleConfig::new("test-api-key").with_base_url(server.uri()); - let provider = GoogleProvider::new(config); - let model = provider.model("gemini-2.0-flash"); - - let err = model - .do_generate(&default_options(test_prompt())) - .await - .expect_err("should error"); - match err { - AiMuxError::ApiCall(msg_d) => { - let msg = msg_d.to_string(); - assert!(msg.contains("Permission denied"), "msg was: {msg}") - } - other => panic!("expected Provider, got {other:?}"), - } - } - - // ── should surface 500 on stream as Provider ───────────────────────────── - - #[tokio::test] - async fn should_surface_stream_500_as_provider_error() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/models/gemini-2.0-flash:streamGenerateContent")) - .and(query_param("alt", "sse")) - .respond_with(ResponseTemplate::new(500).set_body_json(json!({ - "error": { "code": 500, "message": "Internal error.", "status": "INTERNAL" } - }))) - .mount(&server) - .await; - - let config = GoogleConfig::new("test-api-key").with_base_url(server.uri()); - let provider = GoogleProvider::new(config); - let model = provider.model("gemini-2.0-flash"); - - let err = model - .do_stream(&default_options(test_prompt())) - .await - .expect_err("should error"); - match err { - AiMuxError::ApiCall(msg_d) => { - let msg = msg_d.to_string(); - assert!(msg.contains("Internal error"), "msg was: {msg}") - } - other => panic!("expected Provider, got {other:?}"), - } - } - - // ── should surface 404 on stream as ModelNotFound ──────────────────────── - - #[tokio::test] - async fn should_surface_stream_404_as_model_not_found() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/models/gemini-2.0-flash:streamGenerateContent")) - .and(query_param("alt", "sse")) - .respond_with(ResponseTemplate::new(404).set_body_json(json!({ - "error": { "code": 404, "message": "model not found", "status": "NOT_FOUND" } - }))) - .mount(&server) - .await; - - let config = GoogleConfig::new("test-api-key").with_base_url(server.uri()); - let provider = GoogleProvider::new(config); - let model = provider.model("gemini-2.0-flash"); - - let err = model - .do_stream(&default_options(test_prompt())) - .await - .expect_err("should error"); - assert!(matches!(err, ref e if e.status_code() == Some(404))); - } -} -// ════════════════════════════════════════════════════════════════════════════ - -mod request_body { - use super::*; - - // ── system message → top-level systemInstruction ────────────────────────── - - #[test] - fn system_message_becomes_system_instruction() { - let prompt: LanguageModelPrompt = vec![ - LanguageModelPromptMessage { - role: Role::System, - content: vec![ContentPart::text("You are a robot.")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hi")], - ..Default::default() - }, - ]; - let options = default_options(prompt); - let body = build_request_body("gemini-2.0-flash", &options); - - // systemInstruction is a top-level field, NOT in contents. - assert_eq!( - body["systemInstruction"]["parts"][0]["text"], - "You are a robot." - ); - // contents should have only the user message. - assert_eq!(body["contents"].as_array().unwrap().len(), 1); - assert_eq!(body["contents"][0]["role"], "user"); - assert_eq!(body["contents"][0]["parts"][0]["text"], "Hi"); - } - - // ── no system message → no systemInstruction ────────────────────────────── - - #[test] - fn no_system_message_omits_system_instruction() { - let body = build_request_body("gemini-2.0-flash", &default_options(test_prompt())); - assert!(body.get("systemInstruction").is_none()); - } - - // ── assistant role → model role ─────────────────────────────────────────── - - #[test] - fn assistant_role_becomes_model_role() { - let prompt: LanguageModelPrompt = vec![ - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::text("Hi there")], - ..Default::default() - }, - ]; - let body = build_request_body("gemini-2.0-flash", &default_options(prompt)); - - assert_eq!(body["contents"][1]["role"], "model"); - assert_eq!(body["contents"][1]["parts"][0]["text"], "Hi there"); - } - - // ── assistant tool call → functionCall part ─────────────────────────────── - - #[test] - fn assistant_tool_call_becomes_function_call_part() { - let prompt: LanguageModelPrompt = vec![ - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("What's the weather?")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::tool_call( - "call-1".to_string(), - "weather".to_string(), - json!({ "location": "SF" }), - )], - ..Default::default() - }, - ]; - let body = build_request_body("gemini-2.0-flash", &default_options(prompt)); - - let fc = &body["contents"][1]["parts"][0]["functionCall"]; - assert_eq!(fc["id"], "call-1"); - assert_eq!(fc["name"], "weather"); - assert_eq!(fc["args"]["location"], "SF"); - // No signature on the part → no thoughtSignature emitted. - assert!( - body["contents"][1]["parts"][0] - .get("thoughtSignature") - .is_none() - ); - } - - // ── assistant tool call with thought signature → part sibling ───────────── - - #[test] - fn assistant_tool_call_thought_signature_becomes_part_sibling() { - let prompt: LanguageModelPrompt = vec![ - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("What's the weather?")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::ToolCall { - tool_call_id: "call-1".to_string(), - tool_name: "weather".to_string(), - input: json!({ "location": "SF" }), - provider_executed: None, - thought_signature: Some( - "EuIDCt8DARFNMg/aRDRK3THWhBjzltCEy5/VM6ImWLJU8oHmnC75abdcZBMH".to_string(), - ), - provider_options: None, - }], - ..Default::default() - }, - ]; - let body = build_request_body("gemini-2.5-pro", &default_options(prompt)); - - // The signature must be a SIBLING of `functionCall` on the part - // (matching the response shape), not nested inside `functionCall`. - let part = &body["contents"][1]["parts"][0]; - assert_eq!( - part["thoughtSignature"], - "EuIDCt8DARFNMg/aRDRK3THWhBjzltCEy5/VM6ImWLJU8oHmnC75abdcZBMH" - ); - assert!(part["functionCall"].get("thoughtSignature").is_none()); - } - - #[test] - fn assistant_server_tool_round_trips_with_shared_google_vertex_wire_shape() { - let prompt: LanguageModelPrompt = vec![ - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Search the web")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ - ContentPart::ToolCall { - tool_call_id: "logical-call-id".to_string(), - tool_name: "server:GOOGLE_SEARCH_WEB".to_string(), - input: json!(r#"{"query":"Singapore weather"}"#), - provider_executed: Some(true), - thought_signature: None, - provider_options: Some(provider_namespace( - "google", - json!({ - "serverToolCallId": "server-call-1", - "serverToolType": "GOOGLE_SEARCH_WEB", - "thoughtSignature": "call-signature" - }), - )), - }, - ContentPart::ToolResult { - tool_call_id: "logical-call-id".to_string(), - tool_name: Some("server:GOOGLE_SEARCH_WEB".to_string()), - result: json!({ "results": [{ "title": "Sunny" }] }), - is_error: None, - preliminary: None, - dynamic: None, - provider_options: Some(provider_namespace( - "google", - json!({ - "serverToolCallId": "server-call-1", - "serverToolType": "GOOGLE_SEARCH_WEB", - "thoughtSignature": "result-signature" - }), - )), - }, - ], - ..Default::default() - }, - ]; - - let body = build_request_body("gemini-3-pro-preview", &default_options(prompt)); - assert_eq!( - body["contents"][1]["parts"], - json!([ - { - "toolCall": { - "toolType": "GOOGLE_SEARCH_WEB", - "args": { "query": "Singapore weather" }, - "id": "server-call-1" - }, - "thoughtSignature": "call-signature" - }, - { - "toolResponse": { - "toolType": "GOOGLE_SEARCH_WEB", - "response": { "results": [{ "title": "Sunny" }] }, - "id": "server-call-1" - }, - "thoughtSignature": "result-signature" - } - ]) - ); - } - - // ── tool result → functionResponse part in a user message ───────────────── - - #[test] - fn tool_result_becomes_function_response_part() { - let prompt: LanguageModelPrompt = vec![ - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("weather?")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::tool_call( - "call-1".to_string(), - "weather".to_string(), - json!({ "location": "SF" }), - )], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::Tool, - content: vec![ContentPart::tool_result( - "call-1".to_string(), - json!({ "temp": 70 }), - )], - ..Default::default() - }, - ]; - let body = build_request_body("gemini-2.0-flash", &default_options(prompt)); - - // The tool message becomes a user-role message with a functionResponse part. - let tool_msg = &body["contents"][2]; - assert_eq!(tool_msg["role"], "user"); - let fr = &tool_msg["parts"][0]["functionResponse"]; - assert_eq!(fr["id"], "call-1"); - // Without a tool_name the call id is used as the required `name` - // (fallback path). - assert_eq!(fr["name"], "call-1"); - assert_eq!(fr["response"]["name"], "call-1"); - // The serialized JSON output is a string under response.content. - let content_str = fr["response"]["content"].as_str().unwrap(); - assert!(content_str.contains("\"temp\":70")); - } - - #[test] - fn tool_result_uses_tool_name_for_function_response() { - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::Tool, - content: vec![ContentPart::ToolResult { - tool_call_id: "call-9".to_string(), - result: json!({ "temp": 21 }), - tool_name: Some("weather".to_string()), - is_error: None, - preliminary: None, - dynamic: None, - provider_options: None, - }], - ..Default::default() - }]; - let body = build_request_body("gemini-2.0-flash", &default_options(prompt)); - - let fr = &body["contents"][0]["parts"][0]["functionResponse"]; - assert_eq!(fr["id"], "call-9"); - // `name` carries the real tool name, not the opaque call id — - // Gemini pairs functionResponse with the prior functionCall by name. - assert_eq!(fr["name"], "weather"); - assert_eq!(fr["response"]["name"], "weather"); - } - - #[test] - fn tool_result_blank_tool_name_falls_back_to_call_id() { - // An explicitly blank tool_name is treated as unset and falls back - // to the call id (locks the empty-string filter branch). - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::Tool, - content: vec![ContentPart::ToolResult { - tool_call_id: "call-blank".to_string(), - result: json!({ "ok": true }), - tool_name: Some(String::new()), - is_error: None, - preliminary: None, - dynamic: None, - provider_options: None, - }], - ..Default::default() - }]; - let body = build_request_body("gemini-2.0-flash", &default_options(prompt)); - let fr = &body["contents"][0]["parts"][0]["functionResponse"]; - assert_eq!(fr["name"], "call-blank"); - } - - // ── tools → tools array with functionDeclarations ───────────────────────── - - #[test] - fn tools_become_function_declarations() { - let body = build_request_body( - "gemini-2.0-flash", - &options_with_tools(test_prompt(), vec![weather_tool()]), - ); - - let tools = body["tools"].as_array().unwrap(); - assert_eq!(tools.len(), 1); - let decls = tools[0]["functionDeclarations"].as_array().unwrap(); - assert_eq!(decls.len(), 1); - assert_eq!(decls[0]["name"], "weather"); - assert_eq!(decls[0]["description"], "Get the weather"); - // The JSON schema's $schema / additionalProperties are dropped by the - // OpenAPI conversion; the resulting parameters have type/properties/required. - let params = &decls[0]["parameters"]; - assert_eq!(params["type"], "object"); - assert_eq!(params["properties"]["location"]["type"], "string"); - assert_eq!(params["required"][0], "location"); - } - - // ── tool_choice auto → AUTO mode ────────────────────────────────────────── - - #[test] - fn tool_choice_auto_becomes_auto_mode() { - let body = build_request_body( - "gemini-2.0-flash", - &options_with_tools(test_prompt(), vec![weather_tool()]), - ); - assert_eq!(body["toolConfig"]["functionCallingConfig"]["mode"], "AUTO"); - } - - // ── tool_choice required → ANY mode ─────────────────────────────────────── - - #[test] - fn tool_choice_required_becomes_any_mode() { - let mut opts = options_with_tools(test_prompt(), vec![weather_tool()]); - opts.tool_choice = ToolChoice::Required; - let body = build_request_body("gemini-2.0-flash", &opts); - assert_eq!(body["toolConfig"]["functionCallingConfig"]["mode"], "ANY"); - } - - // ── tool_choice tool → ANY + allowedFunctionNames ───────────────────────── - - #[test] - fn tool_choice_tool_becomes_any_with_allowed_function_names() { - let mut opts = options_with_tools(test_prompt(), vec![weather_tool()]); - opts.tool_choice = ToolChoice::Tool { - tool_name: "weather".to_string(), - }; - let body = build_request_body("gemini-2.0-flash", &opts); - assert_eq!(body["toolConfig"]["functionCallingConfig"]["mode"], "ANY"); - let allowed = body["toolConfig"]["functionCallingConfig"]["allowedFunctionNames"] - .as_array() - .unwrap(); - assert_eq!(allowed.len(), 1); - assert_eq!(allowed[0], "weather"); - } - - // ── generation config maps standardized options ─────────────────────────── - - #[test] - fn generation_config_maps_standardized_options() { - let opts = CallOptions { - prompt: test_prompt(), - max_output_tokens: Some(256), - temperature: Some(0.5), - top_p: Some(0.9), - top_k: Some(40.0), - stop_sequences: Some(vec!["STOP".to_string()]), - ..default_options(test_prompt()) - }; - let body = build_request_body("gemini-2.0-flash", &opts); - let gc = &body["generationConfig"]; - assert_eq!(gc["maxOutputTokens"], 256); - assert_eq!(gc["temperature"], 0.5); - // f32 -> f64 round-trip introduces tiny error; compare with tolerance. - let top_p = gc["topP"].as_f64().unwrap(); - assert!((top_p - 0.9).abs() < 1e-5, "topP was {top_p}"); - assert_eq!(gc["topK"], 40.0); - assert_eq!(gc["stopSequences"][0], "STOP"); - } - - // ── JSON response format → responseMimeType ─────────────────────────────── - - #[test] - fn json_response_format_sets_response_mime_type() { - let mut opts = default_options(test_prompt()); - opts.response_format = Some(ResponseFormat::Json { - schema: Some(json!({ - "type": "object", - "properties": { "answer": { "type": "string" } }, - "required": ["answer"] - })), - name: None, - description: None, - }); - let body = build_request_body("gemini-2.0-flash", &opts); - assert_eq!( - body["generationConfig"]["responseMimeType"], - "application/json" - ); - assert_eq!(body["generationConfig"]["responseSchema"]["type"], "object"); - } - - // ── should pass seed in generationConfig ────────────────────────────────── - // TS: "should pass the model, messages, and options" (seed + temperature) - - #[test] - fn should_pass_seed_in_generation_config() { - let mut opts = default_options(test_prompt()); - opts.seed = Some(123); - opts.temperature = Some(0.5); - let body = build_request_body("gemini-2.0-flash", &opts); - assert_eq!(body["generationConfig"]["seed"], 123); - assert_eq!(body["generationConfig"]["temperature"], 0.5); - } - - // ── should pass presence and frequency penalty ─────────────────────────── - - #[test] - fn should_pass_presence_and_frequency_penalty() { - let mut opts = default_options(test_prompt()); - opts.presence_penalty = Some(0.5); - opts.frequency_penalty = Some(0.3); - let body = build_request_body("gemini-2.0-flash", &opts); - // f32 -> f64 round-trip introduces tiny error; compare with tolerance. - let pp = body["generationConfig"]["presencePenalty"] - .as_f64() - .unwrap(); - assert!((pp - 0.5).abs() < 1e-5, "presencePenalty was {pp}"); - let fp = body["generationConfig"]["frequencyPenalty"] - .as_f64() - .unwrap(); - assert!((fp - 0.3).abs() < 1e-5, "frequencyPenalty was {fp}"); - } - - // ── should omit generationConfig when no options set ───────────────────── - - #[test] - fn should_omit_generation_config_when_empty() { - let body = build_request_body("gemini-2.0-flash", &default_options(test_prompt())); - // The Rust impl omits generationConfig when empty (unlike TS which sends {}). - assert!(body.get("generationConfig").is_none()); - } - - // ── JSON response format without schema ────────────────────────────────── - - #[test] - fn json_response_format_without_schema() { - let mut opts = default_options(test_prompt()); - opts.response_format = Some(ResponseFormat::Json { - schema: None, - name: None, - description: None, - }); - let body = build_request_body("gemini-2.0-flash", &opts); - assert_eq!( - body["generationConfig"]["responseMimeType"], - "application/json" - ); - // No schema → no responseSchema field. - assert!(body["generationConfig"].get("responseSchema").is_none()); - } - - // ── should pass custom headers via do_generate ─────────────────────────── - // TS: "should pass headers" - - #[tokio::test] - async fn should_pass_custom_headers() { - let server = MockServer::start().await; - mock_json_response( - &server, "gemini-2.0-flash", - json!({ "candidates": [{ "content": { "parts": [{ "text": "hi" }], "role": "model" }, "finishReason": "STOP", "index": 0 }] }), - ) - .await; - - let config = GoogleConfig::new("test-api-key").with_base_url(server.uri()); - let provider = GoogleProvider::new(config); - let model = provider.model("gemini-2.0-flash"); - - let mut opts = default_options(test_prompt()); - let mut headers = std::collections::HashMap::new(); - headers.insert( - "Custom-Request-Header".to_string(), - "request-header-value".to_string(), - ); - opts.headers = Some(headers); - - model - .do_generate(&opts) - .await - .expect("do_generate should succeed"); - - // wiremock recorded the request — verify the custom header was sent. - let requests = server.received_requests().await; - assert!(requests.is_some(), "should have recorded requests"); - let reqs = requests.unwrap(); - assert!(!reqs.is_empty()); - let custom_header = reqs[0] - .headers - .iter() - .find(|(k, _)| k.as_str() == "custom-request-header"); - assert!(custom_header.is_some(), "custom header should be present"); - assert_eq!( - custom_header.unwrap().1.to_str().unwrap(), - "request-header-value" - ); - } - - // ── should send api key via x-goog-api-key header ──────────────────────── - - #[tokio::test] - async fn should_send_api_key_header() { - let server = MockServer::start().await; - mock_json_response( - &server, "gemini-2.0-flash", - json!({ "candidates": [{ "content": { "parts": [{ "text": "hi" }], "role": "model" }, "finishReason": "STOP", "index": 0 }] }), - ) - .await; - - let config = GoogleConfig::new("my-secret-key").with_base_url(server.uri()); - let provider = GoogleProvider::new(config); - let model = provider.model("gemini-2.0-flash"); - - model - .do_generate(&default_options(test_prompt())) - .await - .expect("ok"); - - let requests = server.received_requests().await; - let reqs = requests.as_ref().expect("recorded requests"); - let key_header = reqs[0] - .headers - .iter() - .find(|(k, _)| k.as_str() == "x-goog-api-key"); - assert!( - key_header.is_some(), - "x-goog-api-key header should be present" - ); - assert_eq!(key_header.unwrap().1.to_str().unwrap(), "my-secret-key"); - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// convert_to_google_messages (pure-function tests) -// ════════════════════════════════════════════════════════════════════════════ - -mod convert_messages { - use super::*; - - // ── multi-part user message ─────────────────────────────────────────────── - - #[test] - fn multi_part_user_message() { - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello"), ContentPart::text("World")], - ..Default::default() - }]; - let gp = convert_to_google_messages(&prompt); - assert!(gp.system_instruction.is_none()); - assert_eq!(gp.contents.len(), 1); - assert_eq!(gp.contents[0]["role"], "user"); - let parts = gp.contents[0]["parts"].as_array().unwrap(); - assert_eq!(parts.len(), 2); - assert_eq!(parts[0]["text"], "Hello"); - assert_eq!(parts[1]["text"], "World"); - } - - // ── multiple system messages ────────────────────────────────────────────── - - #[test] - fn multiple_system_messages_are_merged() { - let prompt: LanguageModelPrompt = vec![ - LanguageModelPromptMessage { - role: Role::System, - content: vec![ContentPart::text("Rule 1")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::System, - content: vec![ContentPart::text("Rule 2")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hi")], - ..Default::default() - }, - ]; - let gp = convert_to_google_messages(&prompt); - let sys = gp.system_instruction.expect("system instruction"); - let parts = sys["parts"].as_array().unwrap(); - assert_eq!(parts.len(), 2); - assert_eq!(parts[0]["text"], "Rule 1"); - assert_eq!(parts[1]["text"], "Rule 2"); - assert_eq!(gp.contents.len(), 1); - } - - // ── system message after a user message is dropped ────────────────────── - - #[test] - fn system_message_after_user_is_dropped() { - // The TS SDK throws `UnsupportedFunctionalityError` for this ordering. - // We can't return an error (function returns GooglePrompt, not Result), - // so the late system message is dropped rather than folded into - // `systemInstruction` (which would make Gemini treat it as a global rule). - let prompt: LanguageModelPrompt = vec![ - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hi")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::System, - content: vec![ContentPart::text("Late rule")], - ..Default::default() - }, - ]; - let gp = convert_to_google_messages(&prompt); - // The late system message must NOT appear in systemInstruction. - assert!( - gp.system_instruction.is_none(), - "late system message must not leak into systemInstruction" - ); - assert_eq!(gp.contents.len(), 1); - assert_eq!(gp.contents[0]["role"], "user"); - } - - // ── image part → inlineData ─────────────────────────────────────────────── - - #[test] - fn image_part_becomes_inline_data() { - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::image( - vec![1, 2, 3, 4], - "image/png".to_string(), - )], - ..Default::default() - }]; - let gp = convert_to_google_messages(&prompt); - let part = &gp.contents[0]["parts"][0]; - assert_eq!(part["inlineData"]["mimeType"], "image/png"); - // base64 of [1,2,3,4] = "AQIDBA==" - assert_eq!(part["inlineData"]["data"], "AQIDBA=="); - } - - // ── file part → inlineData ──────────────────────────────────────────────── - // TS: "should add file parts for base64 encoded files" - - #[test] - fn file_part_becomes_inline_data() { - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::file(vec![0, 1, 2, 3], "image/png".to_string())], - ..Default::default() - }]; - let gp = convert_to_google_messages(&prompt); - assert!(gp.system_instruction.is_none()); - assert_eq!(gp.contents.len(), 1); - let part = &gp.contents[0]["parts"][0]; - assert_eq!(part["inlineData"]["mimeType"], "image/png"); - // base64 of [0,1,2,3] = "AAECAw==" - assert_eq!(part["inlineData"]["data"], "AAECAw=="); - } - - // ── tool result with string output ─────────────────────────────────────── - // TS: "should convert tool result messages to function responses" - - #[test] - fn tool_result_with_string_output() { - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::Tool, - content: vec![ContentPart::tool_result( - "testCallId".to_string(), - json!("test result string"), - )], - ..Default::default() - }]; - let gp = convert_to_google_messages(&prompt); - assert_eq!(gp.contents.len(), 1); - assert_eq!(gp.contents[0]["role"], "user"); - let fr = &gp.contents[0]["parts"][0]["functionResponse"]; - assert_eq!(fr["id"], "testCallId"); - // String outputs pass through as-is (not stringified). - assert_eq!(fr["response"]["content"], "test result string"); - } - - // ── mixed text and image user message ──────────────────────────────────── - - #[test] - fn mixed_text_and_image_user_message() { - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ - ContentPart::text("What's in this image?"), - ContentPart::image(vec![1, 2, 3], "image/jpeg".to_string()), - ], - ..Default::default() - }]; - let gp = convert_to_google_messages(&prompt); - assert_eq!(gp.contents.len(), 1); - let parts = gp.contents[0]["parts"].as_array().unwrap(); - assert_eq!(parts.len(), 2); - assert_eq!(parts[0]["text"], "What's in this image?"); - assert_eq!(parts[1]["inlineData"]["mimeType"], "image/jpeg"); - } - - // ── empty assistant text is skipped ────────────────────────────────────── - - #[test] - fn empty_assistant_text_is_skipped() { - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::text("")], - ..Default::default() - }]; - let gp = convert_to_google_messages(&prompt); - // Empty assistant text → no parts → no contents entry. - assert!(gp.contents.is_empty()); - } - - // ── system only message (no user content) ──────────────────────────────── - // TS: "should store system message in system instruction" - - #[test] - fn system_only_message() { - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::System, - content: vec![ContentPart::text("You are a robot.")], - ..Default::default() - }]; - let gp = convert_to_google_messages(&prompt); - let sys = gp.system_instruction.expect("system instruction"); - assert_eq!(sys["parts"][0]["text"], "You are a robot."); - assert!(gp.contents.is_empty()); - } - - // ── tool call without id omits id field ────────────────────────────────── - - #[test] - fn tool_call_without_id_omits_id_field() { - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::tool_call( - "".to_string(), - "weather".to_string(), - json!({ "location": "SF" }), - )], - ..Default::default() - }]; - let gp = convert_to_google_messages(&prompt); - let fc = &gp.contents[0]["parts"][0]["functionCall"]; - assert_eq!(fc["name"], "weather"); - assert_eq!(fc["args"]["location"], "SF"); - // Empty tool_call_id → no "id" field emitted. - assert!(fc.get("id").is_none()); - } - - // ── mixed assistant text and tool call ─────────────────────────────────── - - #[test] - fn mixed_assistant_text_and_tool_call() { - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ - ContentPart::text("I'll check that for you."), - ContentPart::tool_call( - "call-1".to_string(), - "weather".to_string(), - json!({ "location": "SF" }), - ), - ], - ..Default::default() - }]; - let gp = convert_to_google_messages(&prompt); - assert_eq!(gp.contents[0]["role"], "model"); - let parts = gp.contents[0]["parts"].as_array().unwrap(); - assert_eq!(parts.len(), 2); - assert_eq!(parts[0]["text"], "I'll check that for you."); - assert_eq!(parts[1]["functionCall"]["name"], "weather"); - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// prepare_tools (pure-function tests) -// ════════════════════════════════════════════════════════════════════════════ - -mod prepare_tools_tests { - use super::*; - - // ── no tools → empty result ─────────────────────────────────────────────── - - #[test] - fn no_tools_yields_empty_result() { - let result = prepare_tools(&None, &ToolChoice::Auto); - assert!(result.tools.is_none()); - assert!(result.tool_config.is_none()); - } - - // ── empty tools array → empty result ────────────────────────────────────── - - #[test] - fn empty_tools_array_yields_empty_result() { - let result = prepare_tools(&Some(vec![]), &ToolChoice::Auto); - assert!(result.tools.is_none()); - assert!(result.tool_config.is_none()); - } - - // ── strict tool → VALIDATED mode ────────────────────────────────────────── - - #[test] - fn strict_tool_yields_validated_mode() { - let mut tool = weather_tool(); - tool.strict = Some(true); - let result = prepare_tools(&Some(vec![tool]), &ToolChoice::Auto); - let tc = result.tool_config.expect("tool config"); - assert_eq!(tc["functionCallingConfig"]["mode"], "VALIDATED"); - } - - // ── tool_choice none → NONE mode ───────────────────────────────────────── - // TS: "should handle tool choice 'none'" - - #[test] - fn tool_choice_none_becomes_none_mode() { - let result = prepare_tools(&Some(vec![weather_tool()]), &ToolChoice::None); - assert!(result.tools.is_some()); - assert_eq!( - result.tool_config.unwrap()["functionCallingConfig"]["mode"], - "NONE" - ); - } - - // ── strict tool with required choice → VALIDATED ───────────────────────── - // TS: "should use VALIDATED mode with toolChoice required when strict: true" - - #[test] - fn strict_tool_with_required_choice() { - let mut tool = weather_tool(); - tool.strict = Some(true); - let result = prepare_tools(&Some(vec![tool]), &ToolChoice::Required); - let tc = result.tool_config.expect("tool config"); - assert_eq!(tc["functionCallingConfig"]["mode"], "VALIDATED"); - } - - // ── strict tool with tool choice → VALIDATED + allowedFunctionNames ────── - - #[test] - fn strict_tool_with_tool_choice() { - let mut tool = weather_tool(); - tool.strict = Some(true); - let result = prepare_tools( - &Some(vec![tool]), - &ToolChoice::Tool { - tool_name: "weather".to_string(), - }, - ); - let tc = result.tool_config.expect("tool config"); - assert_eq!(tc["functionCallingConfig"]["mode"], "VALIDATED"); - let allowed = tc["functionCallingConfig"]["allowedFunctionNames"] - .as_array() - .unwrap(); - assert_eq!(allowed.len(), 1); - assert_eq!(allowed[0], "weather"); - } - - // ── multiple tools → multiple functionDeclarations ─────────────────────── - - #[test] - fn multiple_tools_become_multiple_declarations() { - let tool2 = FunctionTool::new( - "calendar".to_string(), - json!({ - "type": "object", - "properties": { "date": { "type": "string" } }, - "required": ["date"], - "additionalProperties": false, - }), - ) - .with_description("Get calendar".to_string()); - let result = prepare_tools(&Some(vec![weather_tool(), tool2]), &ToolChoice::Auto); - let tools = result.tools.expect("tools"); - let decls = tools[0]["functionDeclarations"].as_array().unwrap(); - assert_eq!(decls.len(), 2); - assert_eq!(decls[0]["name"], "weather"); - assert_eq!(decls[1]["name"], "calendar"); - } - - // ── tool without description → empty string ────────────────────────────── - - #[test] - fn tool_without_description_uses_empty_string() { - let tool = FunctionTool::new( - "noDesc".to_string(), - json!({ "type": "object", "properties": {} }), - ); - let result = prepare_tools(&Some(vec![tool]), &ToolChoice::Auto); - let tools = result.tools.unwrap(); - let decls = tools[0]["functionDeclarations"].as_array().unwrap(); - assert_eq!(decls[0]["name"], "noDesc"); - assert_eq!(decls[0]["description"], ""); - } - - // ── function tool with empty schema → null parameters ──────────────────── - // TS: "should correctly prepare function tools" (empty schema → parameters: undefined) - - #[test] - fn function_tool_with_empty_schema_yields_null_parameters() { - let tool = FunctionTool::new( - "testFunction".to_string(), - json!({ "type": "object", "properties": {} }), - ) - .with_description("A test function".to_string()); - let result = prepare_tools(&Some(vec![tool]), &ToolChoice::Auto); - let tools = result.tools.unwrap(); - let decls = tools[0]["functionDeclarations"].as_array().unwrap(); - assert_eq!(decls[0]["name"], "testFunction"); - assert_eq!(decls[0]["description"], "A test function"); - // Empty object schema at root → null parameters (OpenAPI conversion). - assert!(decls[0]["parameters"].is_null()); - // No tool_config when not strict and choice is auto → AUTO. - assert_eq!( - result.tool_config.unwrap()["functionCallingConfig"]["mode"], - "AUTO" - ); - } -} diff --git a/aimux-providers/tests/google_provider_tools_test.rs b/aimux-providers/tests/google_provider_tools_test.rs deleted file mode 100644 index ce68a8692..000000000 --- a/aimux-providers/tests/google_provider_tools_test.rs +++ /dev/null @@ -1,2594 +0,0 @@ -//! Tests for Google provider-defined tools (`google_search`, `code_execution`, -//! `url_context`, `google_maps`) and their response metadata (`groundingMetadata`, -//! `urlContextMetadata`). -//! -//! Translated from the TypeScript sources in -//! `reference/ai/packages/google/src/`: -//! - `google-prepare-tools.test.ts` — `prepareTools` request-body shape & warnings. -//! - `google-language-model.test.ts` — `doGenerate` / `doStream` provider-tool -//! request bodies, grounding/url-context metadata parsing, source extraction, -//! code-execution tool calls, and server-side `toolCall`/`toolResponse` parts. -//! -//! # TDD status (red tests by design) -//! -//! Provider-defined tools are NOT yet implemented in the Rust port: -//! - `aimux_providers::google::convert::prepare_tools` only accepts -//! `FunctionTool`s; `build_request_body` filters out `Tool::Provider(_)`. -//! - `GoogleModel::do_generate` / `do_stream` do not parse `executableCode` / -//! `codeExecutionResult` / `toolCall` / `toolResponse` parts, do not extract -//! sources from `groundingMetadata`, and the stream's `Finish` metadata is a -//! hard-coded `{ "google": {} }`. -//! -//! Therefore most tests here are expected to FAIL until the feature lands — -//! that is the intended TDD "red" state. They compile against the types that -//! already exist (`Tool::Provider`, `ProviderTool`, `GenerateContent::Source`, -//! `StreamPart::{ToolCall,ToolResult,Source}`). Tests that require a type or -//! function which does not exist at all in the Rust port are marked -//! `#[ignore]` with an explanatory comment. - -use aimux_core::tool::{RawToolCall, ToolResult}; -use futures::StreamExt; -use serde_json::{Value, json}; -use wiremock::matchers::{method, path, query_param}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -use aimux_core::content::ContentPart; -use aimux_core::generate::{GenerateTextOptions, generate_text, stream_text}; -use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{ - LanguageModelPrompt, LanguageModelPromptMessage, convert_to_language_model_prompt, -}; -use aimux_core::message::{ModelMessage, Role}; -use aimux_core::options::{CallOptions, ProviderTool, Tool}; -use aimux_core::result::{GenerateContent, Source, StreamResult}; -use aimux_core::stream_part::StreamPart; -use aimux_core::types::{ProviderMetadata, Warning}; - -use aimux_providers::google::convert::build_request_body; -use aimux_providers::{GoogleConfig, GoogleProvider}; - -// ── helpers ───────────────────────────────────────────────────────────────── - -/// The TS `TEST_PROMPT`: a single user text message "Hello". -fn test_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] -} - -/// Build `CallOptions` with everything unset except `prompt`. -fn default_options(prompt: LanguageModelPrompt) -> CallOptions { - CallOptions::new(prompt) -} - -/// Build `CallOptions` carrying a pre-built `Vec` (function and/or -/// provider-defined tools). -fn options_with_tools(prompt: LanguageModelPrompt, tools: Vec) -> CallOptions { - let mut opts = default_options(prompt); - opts.tools = Some(tools); - opts -} - -/// Construct a provider-defined `Tool` from its dotted `id`, `name`, and `args`. -fn provider_tool(id: &str, name: &str, args: Value) -> Tool { - Tool::Provider(ProviderTool { - id: id.to_string(), - name: name.to_string(), - args, - }) -} - -fn google_search_tool() -> Tool { - provider_tool("google.google_search", "google_search", json!({})) -} - -fn code_execution_tool() -> Tool { - provider_tool("google.code_execution", "code_execution", json!({})) -} - -fn url_context_tool() -> Tool { - provider_tool("google.url_context", "url_context", json!({})) -} - -fn google_maps_tool() -> Tool { - provider_tool("google.google_maps", "google_maps", json!({})) -} - -/// Build a `GoogleProvider` pointed at a mock `server`. -fn provider_at(uri: &str) -> GoogleProvider { - GoogleProvider::new(GoogleConfig::new("test-api-key").with_base_url(uri)) -} - -/// Mock a JSON `generateContent` response for `model`. -async fn mock_json_response(server: &MockServer, model: &str, body: Value) { - Mock::given(method("POST")) - .and(path(format!("/models/{model}:generateContent"))) - .respond_with(ResponseTemplate::new(200).set_body_json(body)) - .mount(server) - .await; -} - -/// Mock an SSE `streamGenerateContent` response for `model`. -async fn mock_sse_response(server: &MockServer, model: &str, sse_body: String) { - Mock::given(method("POST")) - .and(path(format!("/models/{model}:streamGenerateContent"))) - .and(query_param("alt", "sse")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(sse_body), - ) - .mount(server) - .await; -} - -/// Build an SSE body from a sequence of JSON chunk values. -fn sse_body(chunks: &[Value]) -> String { - let mut body = String::new(); - for chunk in chunks { - body.push_str(&format!("data: {chunk}\n\n")); - } - body -} - -/// Collect all `StreamPart`s from a `StreamResult` into a `Vec`. -async fn collect_stream(result: StreamResult) -> Vec { - let mut parts = Vec::new(); - let mut stream = result.stream; - while let Some(part) = stream.next().await { - match part { - Ok(p) => parts.push(p), - Err(e) => panic!("stream error: {e:?}"), - } - } - parts -} - -/// Extract `(tool_call_id, tool_name, input)` from streamed `ToolCall` parts. -fn stream_tool_calls(parts: &[StreamPart]) -> Vec<(String, String, String)> { - parts - .iter() - .filter_map(|p| match p { - StreamPart::ToolCall(RawToolCall { - tool_call_id, - tool_name, - input, - .. - }) => Some((tool_call_id.clone(), tool_name.clone(), input.clone())), - _ => None, - }) - .collect() -} - -/// Extract `(tool_call_id, output)` from streamed `ToolResult` parts. -/// -/// NOTE: `StreamPart::ToolResult` has no `tool_name` field — the TS asserts -/// `toolName: "code_execution"` on results, which cannot be expressed here yet. -fn stream_tool_results(parts: &[StreamPart]) -> Vec<(String, Value)> { - parts - .iter() - .filter_map(|p| match p { - StreamPart::ToolResult(ToolResult { - tool_call_id, - result, - .. - }) => Some((tool_call_id.clone(), result.clone())), - _ => None, - }) - .collect() -} - -/// Extract `(id, source_type, url, title)` from streamed `Source` parts. -fn stream_sources(parts: &[StreamPart]) -> Vec<(String, String, Option, Option)> { - parts - .iter() - .filter_map(|p| match p { - StreamPart::Source(Source { - id, - source_type, - url, - title, - .. - }) => Some((id.clone(), source_type.clone(), url.clone(), title.clone())), - _ => None, - }) - .collect() -} - -/// Extract `(tool_call_id, tool_name, input)` from `GenerateContent::ToolCall`. -fn gen_tool_calls(content: &[GenerateContent]) -> Vec<(String, String, String)> { - content - .iter() - .filter_map(|c| match c { - GenerateContent::ToolCall(RawToolCall { - tool_call_id, - tool_name, - input, - .. - }) => Some((tool_call_id.clone(), tool_name.clone(), input.clone())), - _ => None, - }) - .collect() -} - -/// Extract `(tool_call_id, tool_name, result)` from `GenerateContent::ToolResult`. -fn gen_tool_results(content: &[GenerateContent]) -> Vec<(String, String, Value)> { - content - .iter() - .filter_map(|c| match c { - GenerateContent::ToolResult(ToolResult { - tool_call_id, - tool_name, - result, - .. - }) => Some((tool_call_id.clone(), tool_name.clone(), result.clone())), - _ => None, - }) - .collect() -} - -/// The `provider_metadata` of the `GenerateContent::ToolCall` / `ToolResult` -/// whose `tool_name` is `name`. -fn gen_server_metadata(content: &[GenerateContent], name: &str) -> Vec { - content - .iter() - .filter_map(|c| match c { - GenerateContent::ToolCall(RawToolCall { - tool_name, - provider_metadata, - .. - }) - | GenerateContent::ToolResult(ToolResult { - tool_name, - provider_metadata, - .. - }) if tool_name == name => provider_metadata.clone(), - _ => None, - }) - .collect() -} - -/// Extract `(id, source_type, url, title)` from `GenerateContent::Source`. -/// -/// NOTE: `GenerateContent::Source` has no `filename` / `media_type` fields — -/// the TS `document` sources carry those, which cannot be expressed here yet. -fn gen_sources( - content: &[GenerateContent], -) -> Vec<(String, String, Option, Option)> { - content - .iter() - .filter_map(|c| match c { - GenerateContent::Source(Source { - id, - source_type, - url, - title, - .. - }) => Some((id.clone(), source_type.clone(), url.clone(), title.clone())), - _ => None, - }) - .collect() -} - -// ════════════════════════════════════════════════════════════════════════════ -// prepareTools / request body -// -// Translated from `google-prepare-tools.test.ts` and the "search tool -// selection" describe block in `google-language-model.test.ts`. The Rust port -// routes everything through `build_request_body` (the public entry point) — -// the standalone `prepare_tools` only accepts `FunctionTool`s. -// ════════════════════════════════════════════════════════════════════════════ - -mod prepare_tools { - use super::*; - - // ── provider-defined tools → googleSearch / codeExecution / urlContext ───── - - #[test] - fn single_google_search_tool_becomes_google_search() { - // TS: "should correctly prepare single provider-defined tool" - let body = build_request_body( - "gemini-2.5-flash", - &options_with_tools(test_prompt(), vec![google_search_tool()]), - ); - assert_eq!(body["tools"], json!([{ "googleSearch": {} }])); - assert!(body.get("toolConfig").is_none()); - } - - #[test] - fn provider_tools_become_array_of_tool_objects() { - // TS: "should correctly prepare provider-defined tools as array" - let body = build_request_body( - "gemini-2.5-flash", - &options_with_tools( - test_prompt(), - vec![ - google_search_tool(), - url_context_tool(), - provider_tool( - "google.file_search", - "file_search", - json!({ "fileSearchStoreNames": ["projects/foo/fileSearchStores/bar"] }), - ), - ], - ), - ); - assert_eq!( - body["tools"], - json!([ - { "googleSearch": {} }, - { "urlContext": {} }, - { "fileSearch": { "fileSearchStoreNames": ["projects/foo/fileSearchStores/bar"] } } - ]) - ); - assert!(body.get("toolConfig").is_none()); - } - - #[test] - fn code_execution_tool_becomes_code_execution() { - // TS: "should handle code execution tool" - let body = build_request_body( - "gemini-2.5-flash", - &options_with_tools(test_prompt(), vec![code_execution_tool()]), - ); - assert_eq!(body["tools"], json!([{ "codeExecution": {} }])); - assert!(body.get("toolConfig").is_none()); - } - - #[test] - fn url_context_tool_becomes_url_context() { - // TS: "should handle url context tool alone" - let body = build_request_body( - "gemini-2.5-flash", - &options_with_tools(test_prompt(), vec![url_context_tool()]), - ); - assert_eq!(body["tools"], json!([{ "urlContext": {} }])); - assert!(body.get("toolConfig").is_none()); - } - - #[test] - fn google_maps_tool_becomes_google_maps() { - // TS: "should handle google maps tool" - let body = build_request_body( - "gemini-2.5-flash", - &options_with_tools(test_prompt(), vec![google_maps_tool()]), - ); - assert_eq!(body["tools"], json!([{ "googleMaps": {} }])); - assert!(body.get("toolConfig").is_none()); - } - - #[test] - fn google_search_passes_search_types_args_through() { - // TS: "should pass searchTypes args through for google search" - let body = build_request_body( - "gemini-3.1-flash-image-preview", - &options_with_tools( - test_prompt(), - vec![provider_tool( - "google.google_search", - "google_search", - json!({ "searchTypes": { "webSearch": {}, "imageSearch": {} } }), - )], - ), - ); - assert_eq!( - body["tools"], - json!([{ "googleSearch": { "searchTypes": { "webSearch": {}, "imageSearch": {} } } }]) - ); - } - - #[test] - fn google_search_passes_time_range_filter_args_through() { - // TS: "should pass timeRangeFilter args through for google search" - let body = build_request_body( - "gemini-2.5-flash", - &options_with_tools( - test_prompt(), - vec![provider_tool( - "google.google_search", - "google_search", - json!({ - "timeRangeFilter": { - "startTime": "2025-01-01T00:00:00Z", - "endTime": "2025-12-31T23:59:59Z", - } - }), - )], - ), - ); - assert_eq!( - body["tools"][0]["googleSearch"]["timeRangeFilter"], - json!({ - "startTime": "2025-01-01T00:00:00Z", - "endTime": "2025-12-31T23:59:59Z", - }) - ); - } - - // ── combination of function + provider-defined tools (Gemini 3) ─────────── - - #[test] - fn combine_function_and_provider_tools_on_gemini_3() { - // TS: "should combine function and provider-defined tools on Gemini 3" - // Gemini 3 keeps both function and provider tools, emits VALIDATED mode - // with `includeServerSideToolInvocations: true`. - let function_tool = Tool::Function( - aimux_core::tool::FunctionTool::new( - "testFunction".to_string(), - json!({ "type": "object", "properties": {} }), - ) - .with_description("A test function".to_string()), - ); - let body = build_request_body( - "gemini-3.1-flash-lite-preview", - &options_with_tools(test_prompt(), vec![google_search_tool(), function_tool]), - ); - assert_eq!( - body["tools"], - json!([ - { "googleSearch": {} }, - { "functionDeclarations": [ - { "name": "testFunction", "description": "A test function" } - ] } - ]) - ); - assert_eq!( - body["toolConfig"], - json!({ - "functionCallingConfig": { "mode": "VALIDATED" }, - "includeServerSideToolInvocations": true, - }) - ); - } - - #[test] - fn newest_tool_support_for_unknown_future_model() { - // TS: "should use newest tool support for an unknown future Gemini model" - let function_tool = Tool::Function( - aimux_core::tool::FunctionTool::new( - "getWeather".to_string(), - json!({ - "type": "object", - "properties": { "location": { "type": "string" } } - }), - ) - .with_description("Get the weather".to_string()), - ); - let body = build_request_body( - "gemini-99-pro-preview", - &options_with_tools( - test_prompt(), - vec![ - google_search_tool(), - provider_tool( - "google.enterprise_web_search", - "enterprise_web_search", - json!({}), - ), - url_context_tool(), - code_execution_tool(), - provider_tool( - "google.file_search", - "file_search", - json!({ "fileSearchStoreNames": ["fileSearchStores/example-store"] }), - ), - function_tool, - ], - ), - ); - assert_eq!( - body["tools"], - json!([ - { "googleSearch": {} }, - { "enterpriseWebSearch": {} }, - { "urlContext": {} }, - { "codeExecution": {} }, - { "fileSearch": { "fileSearchStoreNames": ["fileSearchStores/example-store"] } }, - { "functionDeclarations": [ - { "name": "getWeather", "description": "Get the weather", - "parameters": { "type": "object", - "properties": { "location": { "type": "string" } } } } - ] } - ]) - ); - assert_eq!( - body["toolConfig"], - json!({ - "functionCallingConfig": { "mode": "VALIDATED" }, - "includeServerSideToolInvocations": true, - }) - ); - } - - // ── unsupported-tool warnings (surface via do_generate) ─────────────────── - // - // `build_request_body` does not return warnings, so these go through - // `do_generate` and assert on `result.warnings`. - - #[tokio::test] - async fn warn_for_unsupported_provider_tool() { - // TS: "should add warnings for unsupported tools" - let server = MockServer::start().await; - mock_json_response( - &server, - "gemini-2.5-flash", - json!({ - "candidates": [{ - "content": { "parts": [{ "text": "hi" }], "role": "model" }, - "finishReason": "STOP", - "index": 0 - }] - }), - ) - .await; - - let model = provider_at(&server.uri()).model("gemini-2.5-flash"); - let result = model - .do_generate(&options_with_tools( - test_prompt(), - vec![provider_tool( - "unsupported.tool", - "unsupported_tool", - json!({}), - )], - )) - .await - .expect("do_generate should succeed"); - - let has_warning = result.warnings.iter().any(|w| matches!( - w, - Warning::Unsupported { feature, .. } if feature == "provider-defined tool unsupported.tool" - )); - assert!( - has_warning, - "expected an unsupported-tool warning, got {:?}", - result.warnings - ); - } - - #[tokio::test] - async fn warn_for_google_search_on_unsupported_model() { - // TS: "should add warnings for google search on unsupported models" - // + "should warn for google search on non-gemini-2 models" - let server = MockServer::start().await; - mock_json_response( - &server, - "gemini-1.5-flash", - json!({ - "candidates": [{ - "content": { "parts": [{ "text": "hi" }], "role": "model" }, - "finishReason": "STOP", - "index": 0 - }] - }), - ) - .await; - - let model = provider_at(&server.uri()).model("gemini-1.5-flash"); - let result = model - .do_generate(&options_with_tools( - test_prompt(), - vec![google_search_tool()], - )) - .await - .expect("do_generate should succeed"); - - let has_warning = result.warnings.iter().any(|w| matches!( - w, - Warning::Unsupported { feature, details } if feature == "provider-defined tool google.google_search" - && details.as_deref() == Some("Google Search requires Gemini 2.0 or newer.") - )); - assert!( - has_warning, - "expected a google_search unsupported-model warning, got {:?}", - result.warnings - ); - } - - #[tokio::test] - async fn warn_when_mixing_function_and_provider_tools_pre_gemini_3() { - // TS: "should warn when mixing function and provider-defined tools" - // On pre-Gemini-3 models the function tool is dropped and a warning is - // emitted for the combination. - let server = MockServer::start().await; - mock_json_response( - &server, - "gemini-2.5-flash", - json!({ - "candidates": [{ - "content": { "parts": [{ "text": "hi" }], "role": "model" }, - "finishReason": "STOP", - "index": 0 - }] - }), - ) - .await; - - let function_tool = Tool::Function( - aimux_core::tool::FunctionTool::new( - "testFunction".to_string(), - json!({ "type": "object", "properties": {} }), - ) - .with_description("A test function".to_string()), - ); - let model = provider_at(&server.uri()).model("gemini-2.5-flash"); - let result = model - .do_generate(&options_with_tools( - test_prompt(), - vec![function_tool, google_search_tool()], - )) - .await - .expect("do_generate should succeed"); - - let has_warning = result.warnings.iter().any(|w| matches!( - w, - Warning::Unsupported { feature, .. } if feature == "combination of function and provider-defined tools" - )); - assert!( - has_warning, - "expected a mixed-tools warning, got {:?}", - result.warnings - ); - // The request body should keep only the provider tool (function dropped). - let body = result.request_body.as_ref().expect("request body"); - assert_eq!(body["tools"], json!([{ "googleSearch": {} }])); - } - - // ── search tool selection (request body via do_generate) ────────────────── - - #[tokio::test] - async fn search_tool_selection_google_search_for_gemini_2_0_pro() { - // TS: "should use googleSearch for gemini-2.0-pro" - let server = MockServer::start().await; - mock_json_response( - &server, - "gemini-2.0-pro", - json!({ - "candidates": [{ - "content": { "parts": [{ "text": "hi" }], "role": "model" }, - "finishReason": "STOP", - "index": 0 - }] - }), - ) - .await; - - let model = provider_at(&server.uri()).model("gemini-2.0-pro"); - let result = model - .do_generate(&options_with_tools( - test_prompt(), - vec![google_search_tool()], - )) - .await - .expect("do_generate should succeed"); - - let body = result.request_body.as_ref().expect("request body"); - assert_eq!(body["tools"], json!([{ "googleSearch": {} }])); - } - - #[tokio::test] - async fn search_tool_selection_url_context_for_gemini_2_0_pro() { - // TS: "should use urlContextTool for gemini-2.0-pro" - let server = MockServer::start().await; - mock_json_response( - &server, - "gemini-2.0-pro", - json!({ - "candidates": [{ - "content": { "parts": [{ "text": "hi" }], "role": "model" }, - "finishReason": "STOP", - "index": 0 - }] - }), - ) - .await; - - let model = provider_at(&server.uri()).model("gemini-2.0-pro"); - let result = model - .do_generate(&options_with_tools(test_prompt(), vec![url_context_tool()])) - .await - .expect("do_generate should succeed"); - - let body = result.request_body.as_ref().expect("request body"); - assert_eq!(body["tools"], json!([{ "urlContext": {} }])); - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// doGenerate — response parsing (grounding metadata, sources, code execution, -// server-side tools) -// -// Translated from `google-language-model.test.ts` `describe('doGenerate')`. -// ════════════════════════════════════════════════════════════════════════════ - -mod do_generate { - use super::*; - - /// A `prepareJsonResponse`-shaped body: one candidate with a text part, - /// `finishReason: STOP`, and optional `groundingMetadata`. - fn json_text_response(content: &str, grounding: Option) -> Value { - let mut candidate = json!({ - "content": { "parts": [{ "text": content }], "role": "model" }, - "finishReason": "STOP", - "index": 0 - }); - if let Some(g) = grounding { - candidate["groundingMetadata"] = g; - } - json!({ - "candidates": [candidate], - "usageMetadata": { "promptTokenCount": 1, "candidatesTokenCount": 2, "totalTokenCount": 3 } - }) - } - - // ── grounding metadata exposed in provider metadata ─────────────────────── - // TS: "should expose grounding metadata in provider metadata" - - #[tokio::test] - async fn expose_grounding_metadata_in_provider_metadata() { - let grounding = json!({ - "webSearchQueries": ["What's the weather in Chicago this weekend?"], - "searchEntryPoint": { "renderedContent": "Sample rendered content for search results" }, - "groundingChunks": [{ - "web": { "uri": "https://example.com/weather", "title": "Chicago Weather Forecast" } - }], - "groundingSupports": [{ - "segment": { - "startIndex": 0, - "endIndex": 65, - "text": "Chicago weather changes rapidly, so layers let you adjust easily." - }, - "groundingChunkIndices": [0], - "confidenceScores": [0.99] - }], - "retrievalMetadata": { "webDynamicRetrievalScore": 0.96879 } - }); - let server = MockServer::start().await; - mock_json_response( - &server, - "gemini-pro", - json_text_response("test response", Some(grounding.clone())), - ) - .await; - - let model = provider_at(&server.uri()).model("gemini-pro"); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - let pm = result - .provider_metadata - .as_ref() - .expect("provider metadata"); - assert_eq!(pm["google"]["groundingMetadata"], grounding); - } - - // ── source extraction from grounding metadata ───────────────────────────── - // - // The Rust port does not yet extract `GenerateContent::Source` items from - // `groundingMetadata.groundingChunks`. These tests are red until that lands. - - #[tokio::test] - async fn extract_sources_from_grounding_metadata() { - // TS: "should extract sources from grounding metadata" - let server = MockServer::start().await; - mock_json_response( - &server, - "gemini-pro", - json_text_response( - "test response", - Some(json!({ - "groundingChunks": [{ - "web": { "uri": "https://source.example.com", "title": "Source Title" } - }] - })), - ), - ) - .await; - - let model = provider_at(&server.uri()).model("gemini-pro"); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - // Text part should be present. - assert!(result.content.iter().any(|c| matches!( - c, - GenerateContent::Text { text, .. } if text == "test response" - ))); - // A url source should be extracted (red: not implemented). - let sources = gen_sources(&result.content); - let has_url = sources.iter().any(|(_, st, url, title)| { - st == "url" - && url.as_deref() == Some("https://source.example.com") - && title.as_deref() == Some("Source Title") - }); - assert!(has_url, "expected a url source, got {sources:?}"); - } - - #[tokio::test] - async fn extract_sources_from_rag_retrieved_context() { - // TS: "should extract sources from RAG retrievedContext chunks" - let server = MockServer::start().await; - mock_json_response( - &server, - "gemini-pro", - json_text_response( - "test response with RAG", - Some(json!({ - "groundingChunks": [ - { "web": { "uri": "https://web.example.com", "title": "Web Source" } }, - { "retrievedContext": { - "uri": "gs://rag-corpus/document.pdf", - "title": "RAG Document", - "text": "Retrieved context..." - } }, - { "retrievedContext": { - "uri": "https://external-rag-source.com/page", - "title": "External RAG Source", - "text": "External retrieved context..." - } } - ] - })), - ), - ) - .await; - - let model = provider_at(&server.uri()).model("gemini-pro"); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - let sources = gen_sources(&result.content); - // web → url source; gs:// → document source; external rag → url source. - assert!(sources.iter().any(|(_, st, url, title)| { - st == "url" - && url.as_deref() == Some("https://web.example.com") - && title.as_deref() == Some("Web Source") - })); - // document source from gs:// (filename/mediaType not representable in Rust Source). - assert!(sources.iter().any(|(_, st, url, title)| { - st == "document" && url.is_none() && title.as_deref() == Some("RAG Document") - })); - assert!(sources.iter().any(|(_, st, url, title)| { - st == "url" - && url.as_deref() == Some("https://external-rag-source.com/page") - && title.as_deref() == Some("External RAG Source") - })); - } - - #[tokio::test] - async fn extract_sources_from_file_search_retrieved_context() { - // TS: "should extract sources from File Search retrievedContext (new format)" - let server = MockServer::start().await; - mock_json_response( - &server, - "gemini-pro", - json_text_response( - "test response with File Search", - Some(json!({ - "groundingChunks": [ - { "retrievedContext": { - "text": "Sample content for testing...", - "fileSearchStore": "fileSearchStores/test-store-xyz", - "title": "Test Document" - } }, - { "retrievedContext": { - "text": "Another document content...", - "fileSearchStore": "fileSearchStores/another-store-abc" - } } - ] - })), - ), - ) - .await; - - let model = provider_at(&server.uri()).model("gemini-pro"); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - let sources = gen_sources(&result.content); - assert!(sources.iter().any(|(_, st, _, title)| { - st == "document" && title.as_deref() == Some("Test Document") - })); - // Missing title defaults to "Unknown Document". - assert!(sources.iter().any(|(_, st, _, title)| { - st == "document" && title.as_deref() == Some("Unknown Document") - })); - } - - #[tokio::test] - async fn handle_url_sources_with_undefined_title() { - // TS: "should handle URL sources with undefined title correctly" - let server = MockServer::start().await; - mock_json_response( - &server, - "gemini-pro", - json_text_response( - "test response with URLs", - Some(json!({ - "groundingChunks": [ - { "web": { "uri": "https://example.com/page1" } }, - { "retrievedContext": { "uri": "https://example.com/page2" } } - ] - })), - ), - ) - .await; - - let model = provider_at(&server.uri()).model("gemini-pro"); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - let sources = gen_sources(&result.content); - assert!(sources.iter().any(|(_, st, url, title)| { - st == "url" && url.as_deref() == Some("https://example.com/page1") && title.is_none() - })); - assert!(sources.iter().any(|(_, st, url, title)| { - st == "url" && url.as_deref() == Some("https://example.com/page2") && title.is_none() - })); - } - - #[tokio::test] - async fn extract_sources_from_maps_grounding_metadata() { - // TS: "should extract sources from maps grounding metadata" - let server = MockServer::start().await; - mock_json_response( - &server, - "gemini-pro", - json_text_response( - "test response with Maps", - Some(json!({ - "groundingChunks": [ - { "maps": { - "uri": "https://maps.google.com/maps?cid=12345", - "title": "Best Italian Restaurant", - "placeId": "ChIJ12345" - } }, - { "maps": { "uri": "https://maps.google.com/maps?cid=67890" } } - ] - })), - ), - ) - .await; - - let model = provider_at(&server.uri()).model("gemini-pro"); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - let sources = gen_sources(&result.content); - assert!(sources.iter().any(|(_, st, url, title)| { - st == "url" - && url.as_deref() == Some("https://maps.google.com/maps?cid=12345") - && title.as_deref() == Some("Best Italian Restaurant") - })); - assert!(sources.iter().any(|(_, st, url, title)| { - st == "url" - && url.as_deref() == Some("https://maps.google.com/maps?cid=67890") - && title.is_none() - })); - } - - #[tokio::test] - async fn extract_sources_from_image_grounding_metadata() { - // TS: "should extract sources from image grounding metadata" - let server = MockServer::start().await; - mock_json_response( - &server, - "gemini-pro", - json_text_response( - "test response with image search", - Some(json!({ - "groundingChunks": [ - { "image": { - "sourceUri": "https://example.com/article", - "imageUri": "https://example.com/image.jpg", - "title": "Image Result", - "domain": "example.com" - } }, - { "image": { - "sourceUri": "https://other.example.com/page", - "imageUri": "https://other.example.com/photo.png" - } } - ] - })), - ), - ) - .await; - - let model = provider_at(&server.uri()).model("gemini-pro"); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - let sources = gen_sources(&result.content); - assert!(sources.iter().any(|(_, st, url, title)| { - st == "url" - && url.as_deref() == Some("https://example.com/article") - && title.as_deref() == Some("Image Result") - })); - assert!(sources.iter().any(|(_, st, url, title)| { - st == "url" - && url.as_deref() == Some("https://other.example.com/page") - && title.is_none() - })); - } - - #[tokio::test] - async fn handle_mixed_source_types_with_title_defaults() { - // TS: "should handle mixed source types with correct title defaults" - let server = MockServer::start().await; - mock_json_response( - &server, - "gemini-pro", - json_text_response( - "test response with mixed sources", - Some(json!({ - "groundingChunks": [ - { "web": { "uri": "https://web.example.com" } }, - { "retrievedContext": { "uri": "https://external.example.com" } }, - { "retrievedContext": { "uri": "gs://bucket/document.pdf" } }, - { "retrievedContext": { "fileSearchStore": "fileSearchStores/store-123" } } - ] - })), - ), - ) - .await; - - let model = provider_at(&server.uri()).model("gemini-pro"); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - let sources = gen_sources(&result.content); - assert!(sources.iter().any(|(_, st, url, title)| { - st == "url" && url.as_deref() == Some("https://web.example.com") && title.is_none() - })); - assert!(sources.iter().any(|(_, st, url, title)| { - st == "url" && url.as_deref() == Some("https://external.example.com") && title.is_none() - })); - // gs:// bucket path → document source with "Unknown Document" title. - assert!(sources.iter().any(|(_, st, _, title)| { - st == "document" && title.as_deref() == Some("Unknown Document") - })); - } - - // ── code execution tool calls ───────────────────────────────────────────── - // - // Gemini may emit more than one `codeExecutionResult` for a single - // `executableCode`; every result must retain the originating call id. - - #[tokio::test] - async fn code_execution_request_body_contains_code_execution() { - // TS: "should handle code execution tool calls" (request body portion) - let server = MockServer::start().await; - mock_json_response( - &server, - "gemini-2.0-pro", - json!({ - "candidates": [{ - "content": { - "parts": [ - { "executableCode": { "language": "PYTHON", "code": "print(1+1)" } }, - { "codeExecutionResult": { "outcome": "OUTCOME_OK", "output": "2" } } - ], - "role": "model" - }, - "finishReason": "STOP" - }] - }), - ) - .await; - - let model = provider_at(&server.uri()).model("gemini-2.0-pro"); - let result = model - .do_generate(&options_with_tools( - test_prompt(), - vec![code_execution_tool()], - )) - .await - .expect("do_generate should succeed"); - - let body = result.request_body.as_ref().expect("request body"); - assert_eq!(body["tools"], json!([{ "codeExecution": {} }])); - } - - #[tokio::test] - async fn code_execution_tool_call_content() { - // TS: "should handle code execution tool calls" (content portion) - let server = MockServer::start().await; - mock_json_response( - &server, - "gemini-2.0-pro", - json!({ - "candidates": [{ - "content": { - "parts": [ - { "executableCode": { "language": "PYTHON", "code": "print(1+1)" } }, - { "codeExecutionResult": { "outcome": "OUTCOME_OK", "output": "2" } } - ], - "role": "model" - }, - "finishReason": "STOP" - }] - }), - ) - .await; - - let model = provider_at(&server.uri()).model("gemini-2.0-pro"); - let result = model - .do_generate(&options_with_tools( - test_prompt(), - vec![code_execution_tool()], - )) - .await - .expect("do_generate should succeed"); - - let calls = gen_tool_calls(&result.content); - let has_call = calls.iter().any(|(_, name, input)| { - name == "code_execution" && *input == r#"{"language":"PYTHON","code":"print(1+1)"}"# - }); - assert!( - has_call, - "expected a code_execution tool-call, got {calls:?}" - ); - // The tool-result half of the TS assertion lives in - // `code_execution_tool_result_content` below. - } - - #[tokio::test] - async fn renamed_code_execution_passes_core_generate_boundary() { - let server = MockServer::start().await; - mock_json_response( - &server, - "gemini-2.0-pro", - json!({ - "candidates": [{ - "content": { - "parts": [ - { "executableCode": { "language": "PYTHON", "code": "print(2)" } }, - { "codeExecutionResult": { "outcome": "OUTCOME_OK", "output": "2" } }, - { "codeExecutionResult": { "outcome": "OUTCOME_OK", "output": "still 2" } } - ], - "role": "model" - }, - "finishReason": "STOP" - }] - }), - ) - .await; - let model = provider_at(&server.uri()).model("gemini-2.0-pro"); - let tool = provider_tool("google.code_execution", "runCode", json!({})); - - let result = generate_text( - &model, - "Run code", - GenerateTextOptions { - tools: Some(vec![tool.clone()]), - ..GenerateTextOptions::default() - }, - ) - .await - .expect("renamed code execution tool should pass Core validation"); - - let call = result.tool_calls.first().expect("code execution call"); - assert_eq!(call.tool_name, "runCode"); - assert_eq!(call.provider_executed, Some(true)); - assert_eq!(call.invalid, None); - assert_eq!(call.input["code"], "print(2)"); - assert_eq!( - serde_json::to_value( - &call.provider_metadata.as_ref().expect("call metadata")["google"] - ) - .unwrap(), - json!({ - "serverToolCallId": call.tool_call_id, - "serverToolType": "code_execution", - }) - ); - let raw_results: Vec<_> = result - .raw - .content - .iter() - .filter_map(|content| match content { - GenerateContent::ToolResult(ToolResult { - tool_call_id, - tool_name, - result, - provider_metadata, - .. - }) if tool_name == "runCode" => Some(( - tool_call_id, - result, - provider_metadata.as_ref().expect("result metadata"), - )), - _ => None, - }) - .collect(); - assert_eq!(raw_results.len(), 2); - assert!(raw_results.iter().all(|(id, _, metadata)| { - *id == &call.tool_call_id - && metadata["google"]["serverToolCallId"] == call.tool_call_id - && metadata["google"]["serverToolType"] == "code_execution" - })); - - let mut messages = vec![ModelMessage::user("Run code")]; - messages.extend(result.response_messages); - messages.push(ModelMessage::user("Continue")); - let mut next_options = CallOptions::new(convert_to_language_model_prompt(&messages, None)); - next_options.tools = Some(vec![tool]); - let replay = build_request_body("gemini-2.0-pro", &next_options); - let assistant = replay["contents"] - .as_array() - .unwrap() - .iter() - .find(|content| content["role"] == "model") - .expect("assistant replay content"); - assert_eq!( - assistant["parts"], - json!([ - { "executableCode": { "language": "PYTHON", "code": "print(2)" } }, - { "codeExecutionResult": { "outcome": "OUTCOME_OK", "output": "2" } }, - { "codeExecutionResult": { "outcome": "OUTCOME_OK", "output": "still 2" } }, - ]) - ); - } - - /// TS: "should handle code execution tool calls" — the `tool-result` portion. - /// - /// `GenerateContent::ToolResult` now exists, so the TS assertion is - /// expressible: the `codeExecutionResult` part becomes a `ToolResult` - /// named `code_execution` carrying `{ outcome, output }`, paired with the - /// id of the `executableCode` call it answers. - #[tokio::test] - async fn code_execution_tool_result_content() { - let server = MockServer::start().await; - mock_json_response( - &server, - "gemini-2.0-pro", - json!({ - "candidates": [{ - "content": { - "parts": [ - { "executableCode": { "language": "PYTHON", "code": "print(1+1)" } }, - { "codeExecutionResult": { "outcome": "OUTCOME_OK", "output": "2" } }, - { "codeExecutionResult": { "outcome": "OUTCOME_OK", "output": "still 2" } } - ], - "role": "model" - }, - "finishReason": "STOP" - }] - }), - ) - .await; - - let model = provider_at(&server.uri()).model("gemini-2.0-pro"); - let result = model - .do_generate(&options_with_tools( - test_prompt(), - vec![code_execution_tool()], - )) - .await - .expect("do_generate should succeed"); - - let calls = gen_tool_calls(&result.content); - assert_eq!(calls.len(), 1, "one executableCode part → one tool-call"); - assert_eq!(calls[0].1, "code_execution"); - assert_eq!(calls[0].2, r#"{"language":"PYTHON","code":"print(1+1)"}"#); - - let results = gen_tool_results(&result.content); - assert_eq!( - results.len(), - 2, - "each codeExecutionResult part becomes a tool-result" - ); - assert_eq!(results[0].1, "code_execution"); - assert_eq!( - results[0].2, - json!({ "outcome": "OUTCOME_OK", "output": "2" }), - "the TS `{{ outcome, output }}` result shape" - ); - assert_eq!( - results[0].0, calls[0].0, - "the result must carry the id of the call it answers" - ); - assert_eq!(results[1].0, calls[0].0); - assert_eq!( - results[1].2, - json!({ "outcome": "OUTCOME_OK", "output": "still 2" }) - ); - assert!( - !results[0].0.is_empty(), - "tool_call_id must not be the empty string" - ); - } - - #[tokio::test] - async fn code_execution_result_with_missing_output_field() { - // TS: "should handle code execution result with missing output field" - let server = MockServer::start().await; - mock_json_response( - &server, - "gemini-2.0-pro", - json!({ - "candidates": [{ - "content": { - "parts": [ - { "executableCode": { - "language": "PYTHON", - "code": "import PIL.Image\nimg = PIL.Image.open('input.png')\nimg.save('output.png')\n" - } }, - { "codeExecutionResult": { "outcome": "OUTCOME_OK" } } - ], - "role": "model" - }, - "finishReason": "STOP" - }] - }), - ) - .await; - - let model = provider_at(&server.uri()).model("gemini-2.0-pro"); - let result = model - .do_generate(&options_with_tools( - test_prompt(), - vec![code_execution_tool()], - )) - .await - .expect("do_generate should succeed"); - - let calls = gen_tool_calls(&result.content); - let has_call = calls.iter().any(|(_, name, _)| name == "code_execution"); - assert!( - has_call, - "expected a code_execution tool-call, got {calls:?}" - ); - // NOTE: TS asserts the result's output defaults to "" when missing — - // pending GenerateContent::ToolResult. - } - - // ── code execution finish reason ────────────────────────────────────────── - - #[tokio::test] - async fn stop_finish_reason_for_code_execution() { - // TS: "should return stop finish reason for code execution (provider-executed tool)" - // Provider-executed tools must NOT trigger a 'tool-calls' finish reason. - let server = MockServer::start().await; - mock_json_response( - &server, - "gemini-2.0-pro", - json!({ - "candidates": [{ - "content": { - "parts": [ - { "executableCode": { "language": "PYTHON", "code": "print(1+1)" } }, - { "codeExecutionResult": { "outcome": "OUTCOME_OK", "output": "2" } } - ], - "role": "model" - }, - "finishReason": "STOP" - }] - }), - ) - .await; - - let model = provider_at(&server.uri()).model("gemini-2.0-pro"); - let result = model - .do_generate(&options_with_tools( - test_prompt(), - vec![code_execution_tool()], - )) - .await - .expect("do_generate should succeed"); - - assert_eq!( - result.finish_reason.unified, - aimux_core::types::FinishReasonUnified::Stop - ); - assert_eq!(result.finish_reason.raw.as_deref(), Some("STOP")); - // The code execution tool call should be present (red: not parsed). - assert!( - gen_tool_calls(&result.content) - .iter() - .any(|(_, name, _)| name == "code_execution"), - "expected a code_execution tool-call in content" - ); - } - - #[tokio::test] - async fn stop_finish_reason_for_code_execution_with_text_response() { - // TS: "should return stop finish reason for code execution with text - // response (structured output scenario)" - let server = MockServer::start().await; - mock_json_response( - &server, - "gemini-2.0-pro", - json!({ - "candidates": [{ - "content": { - "parts": [ - { "executableCode": { - "language": "PYTHON", - "code": "primes = [2, 3, 5, 7, 11]\nprint(sum(primes))" - } }, - { "codeExecutionResult": { "outcome": "OUTCOME_OK", "output": "28" } }, - { "text": "{\"answer\": 28, \"explanation\": \"Sum of first 5 primes\"}" } - ], - "role": "model" - }, - "finishReason": "STOP" - }] - }), - ) - .await; - - let model = provider_at(&server.uri()).model("gemini-2.0-pro"); - let result = model - .do_generate(&options_with_tools( - test_prompt(), - vec![code_execution_tool()], - )) - .await - .expect("do_generate should succeed"); - - assert_eq!( - result.finish_reason.unified, - aimux_core::types::FinishReasonUnified::Stop - ); - // Text content is included. - assert!(result.content.iter().any(|c| matches!( - c, - GenerateContent::Text { text, .. } if text == "{\"answer\": 28, \"explanation\": \"Sum of first 5 primes\"}" - ))); - // The code execution tool call should be present (red: not parsed). - assert!( - gen_tool_calls(&result.content) - .iter() - .any(|(_, name, _)| name == "code_execution"), - "expected a code_execution tool-call in content" - ); - } - - #[tokio::test] - async fn tool_calls_finish_reason_when_code_execution_combined_with_function_tools() { - // TS: "should return tool-calls finish reason when code execution is - // combined with function tools" - let server = MockServer::start().await; - mock_json_response( - &server, - "gemini-2.0-pro", - json!({ - "candidates": [{ - "content": { - "parts": [ - { "executableCode": { "language": "PYTHON", "code": "print(1+1)" } }, - { "codeExecutionResult": { "outcome": "OUTCOME_OK", "output": "2" } }, - { "functionCall": { "name": "test-tool", "args": { "value": "test" } } } - ], - "role": "model" - }, - "finishReason": "STOP" - }] - }), - ) - .await; - - let function_tool = Tool::Function(aimux_core::tool::FunctionTool::new( - "test-tool".to_string(), - json!({ "type": "object", "properties": { "value": { "type": "string" } } }), - )); - let model = provider_at(&server.uri()).model("gemini-2.0-pro"); - let result = model - .do_generate(&options_with_tools( - test_prompt(), - vec![code_execution_tool(), function_tool], - )) - .await - .expect("do_generate should succeed"); - - // STOP + a client-executed function tool → 'tool-calls'. - assert_eq!( - result.finish_reason.unified, - aimux_core::types::FinishReasonUnified::ToolCalls - ); - assert_eq!(result.finish_reason.raw.as_deref(), Some("STOP")); - // The code execution tool call should ALSO be present (red: not parsed). - assert!( - gen_tool_calls(&result.content) - .iter() - .any(|(_, name, _)| name == "code_execution"), - "expected a code_execution tool-call alongside the function call" - ); - } - - // ── server-side toolCall / toolResponse parts ───────────────────────────── - // - // Gemini 3 can return `toolCall` + `toolResponse` parts for provider tools - // (e.g. GOOGLE_SEARCH_WEB). Both are parsed now: the call becomes a - // `ToolCall` named `server:` and the response a `ToolResult` with - // the same name, each carrying - // `providerMetadata.google.{serverToolCallId,serverToolType,thoughtSignature}`. - - #[tokio::test] - async fn handle_server_side_tool_call_and_tool_response_parts() { - // TS: "should handle server-side toolCall and toolResponse parts (tool combination)" - let server = MockServer::start().await; - mock_json_response( - &server, - "gemini-3-pro-preview", - json!({ - "candidates": [{ - "content": { - "parts": [ - { "toolCall": { - "toolType": "GOOGLE_SEARCH_WEB", - "args": { "query": "San Francisco weather" }, - "id": "server-call-1" - }, "thoughtSignature": "sig-abc" }, - { "toolResponse": { - "toolType": "GOOGLE_SEARCH_WEB", - "response": { "results": [{ "title": "Weather in SF" }] }, - "id": "server-call-1" - }, "thoughtSignature": "sig-def" }, - { "text": "The weather in San Francisco is sunny." } - ], - "role": "model" - }, - "finishReason": "STOP" - }], - "usageMetadata": { "promptTokenCount": 10, "candidatesTokenCount": 20, "totalTokenCount": 30 } - }), - ) - .await; - - let function_tool = Tool::Function(aimux_core::tool::FunctionTool::new( - "weather".to_string(), - json!({ "type": "object", "properties": { "location": { "type": "string" } } }), - )); - let model = provider_at(&server.uri()).model("gemini-3-pro-preview"); - let result = model - .do_generate(&options_with_tools( - test_prompt(), - vec![google_search_tool(), function_tool], - )) - .await - .expect("do_generate should succeed"); - - // Text is present. - assert!(result.content.iter().any(|c| matches!( - c, - GenerateContent::Text { text, .. } if text == "The weather in San Francisco is sunny." - ))); - - // Server tool call with toolName "server:GOOGLE_SEARCH_WEB". - let calls = gen_tool_calls(&result.content); - assert_eq!(calls.len(), 1, "one toolCall part → one tool-call"); - assert_eq!(calls[0].0, "server-call-1", "the server-assigned id"); - assert_eq!(calls[0].1, "server:GOOGLE_SEARCH_WEB"); - assert_eq!(calls[0].2, r#"{"query":"San Francisco weather"}"#); - - // The matching toolResponse part. - let results = gen_tool_results(&result.content); - assert_eq!(results.len(), 1, "one toolResponse part → one tool-result"); - assert_eq!( - results[0].0, "server-call-1", - "the result is paired with its call" - ); - assert_eq!(results[0].1, "server:GOOGLE_SEARCH_WEB"); - assert_eq!( - results[0].2, - json!({ "results": [{ "title": "Weather in SF" }] }), - "the server tool's response payload must survive verbatim" - ); - - // Per-part providerMetadata: server ids/types plus the thoughtSignature - // (which must be echoed back on the follow-up turn). - let meta = gen_server_metadata(&result.content, "server:GOOGLE_SEARCH_WEB"); - assert_eq!(meta.len(), 2, "both the call and the result carry metadata"); - assert_eq!( - serde_json::to_value(&meta[0]["google"]).unwrap(), - json!({ - "serverToolCallId": "server-call-1", - "serverToolType": "GOOGLE_SEARCH_WEB", - "thoughtSignature": "sig-abc", - }) - ); - assert_eq!( - serde_json::to_value(&meta[1]["google"]).unwrap(), - json!({ - "serverToolCallId": "server-call-1", - "serverToolType": "GOOGLE_SEARCH_WEB", - "thoughtSignature": "sig-def", - }) - ); - - // Provider-executed tools do NOT flip the finish reason to tool-calls. - assert_eq!( - result.finish_reason.unified, - aimux_core::types::FinishReasonUnified::Stop - ); - } - - #[tokio::test] - async fn stop_finish_reason_for_server_tool_calls() { - // TS: "should return stop finish reason for server tool calls (provider-executed)" - let server = MockServer::start().await; - mock_json_response( - &server, - "gemini-3-pro-preview", - json!({ - "candidates": [{ - "content": { - "parts": [ - { "toolCall": { "toolType": "GOOGLE_SEARCH_WEB", "args": {}, "id": "sc-1" } }, - { "toolResponse": { "toolType": "GOOGLE_SEARCH_WEB", "response": { "results": [] }, "id": "sc-1" } } - ], - "role": "model" - }, - "finishReason": "STOP" - }], - "usageMetadata": { "promptTokenCount": 5, "candidatesTokenCount": 10, "totalTokenCount": 15 } - }), - ) - .await; - - let model = provider_at(&server.uri()).model("gemini-3-pro-preview"); - let result = model - .do_generate(&options_with_tools( - test_prompt(), - vec![google_search_tool()], - )) - .await - .expect("do_generate should succeed"); - - // Provider-executed server tools → stop, not tool-calls. - assert_eq!( - result.finish_reason.unified, - aimux_core::types::FinishReasonUnified::Stop - ); - assert_eq!(result.finish_reason.raw.as_deref(), Some("STOP")); - // The server tool call should be present in content (red: not parsed). - assert!( - gen_tool_calls(&result.content) - .iter() - .any(|(_, name, _)| name == "server:GOOGLE_SEARCH_WEB"), - "expected a server-side tool-call in content" - ); - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// doStream — streaming provider tools (grounding/url-context metadata on -// finish, code execution, server-side tools, source events) -// -// Translated from `google-language-model.test.ts` `describe('doStream')`. -// ════════════════════════════════════════════════════════════════════════════ - -mod do_stream { - use super::*; - - /// A single SSE chunk carrying `content.parts: [{ text }]`, optional - /// `finishReason`, and optional `groundingMetadata` / `urlContextMetadata`. - fn chunk( - text: &str, - finish_reason: Option<&str>, - grounding: Option, - url_context: Option, - ) -> Value { - let mut candidate = json!({ - "content": { "parts": [{ "text": text }], "role": "model" } - }); - if let Some(r) = finish_reason { - candidate["finishReason"] = json!(r); - } - if let Some(g) = grounding { - candidate["groundingMetadata"] = g; - } - if let Some(u) = url_context { - candidate["urlContextMetadata"] = u; - } - json!({ "candidates": [candidate] }) - } - - /// Extract the `Finish` part's provider metadata from a collected stream. - fn finish_provider_metadata(parts: &[StreamPart]) -> Option { - parts.iter().find_map(|p| match p { - StreamPart::Finish { - provider_metadata, .. - } => provider_metadata.clone(), - _ => None, - }) - } - - // ── grounding / url context metadata on finish ──────────────────────────── - - #[tokio::test] - async fn expose_grounding_metadata_in_provider_metadata_on_finish() { - // TS: "should expose grounding metadata in provider metadata on finish" - let grounding = json!({ - "webSearchQueries": ["What's the weather in Chicago this weekend?"], - "searchEntryPoint": { "renderedContent": "Sample rendered content for search results" }, - "groundingChunks": [{ - "web": { "uri": "https://example.com/weather", "title": "Chicago Weather Forecast" } - }], - "groundingSupports": [{ - "segment": { - "startIndex": 0, - "endIndex": 65, - "text": "Chicago weather changes rapidly, so layers let you adjust easily." - }, - "groundingChunkIndices": [0], - "confidenceScores": [0.99] - }], - "retrievalMetadata": { "webDynamicRetrievalScore": 0.96879 } - }); - let server = MockServer::start().await; - mock_sse_response( - &server, - "gemini-pro", - sse_body(&[chunk("test", Some("STOP"), Some(grounding.clone()), None)]), - ) - .await; - - let model = provider_at(&server.uri()).model("gemini-pro"); - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - let parts = collect_stream(result).await; - - let pm = finish_provider_metadata(&parts).expect("finish part"); - assert_eq!(pm["google"]["groundingMetadata"], grounding); - } - - #[tokio::test] - async fn preserve_grounding_metadata_before_finish_reason_chunk() { - // TS: "should preserve grounding metadata when it arrives before the - // finishReason chunk" - let server = MockServer::start().await; - mock_sse_response( - &server, - "gemini-pro", - sse_body(&[ - chunk( - "hello", - None, - Some(json!({ - "webSearchQueries": ["super bowl 2026 halftime show"], - "groundingChunks": [{ - "web": { "uri": "https://example.com/superbowl", "title": "Super Bowl 2026" } - }] - })), - None, - ), - { - let mut c = chunk(" world", Some("STOP"), None, None); - c["usageMetadata"] = json!({ - "promptTokenCount": 38, - "candidatesTokenCount": 1335, - "totalTokenCount": 1890 - }); - c - }, - ]), - ) - .await; - - let model = provider_at(&server.uri()).model("gemini-pro"); - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - let parts = collect_stream(result).await; - - let pm = finish_provider_metadata(&parts).expect("finish part"); - assert_eq!( - pm["google"]["groundingMetadata"], - json!({ - "webSearchQueries": ["super bowl 2026 halftime show"], - "groundingChunks": [{ - "web": { "uri": "https://example.com/superbowl", "title": "Super Bowl 2026" } - }] - }) - ); - // A source event should be emitted for the grounding chunk (red). - assert_eq!(stream_sources(&parts).len(), 1); - } - - #[tokio::test] - async fn preserve_url_context_metadata_before_finish_reason_chunk() { - // TS: "should preserve url context metadata when it arrives before the - // finishReason chunk" - let server = MockServer::start().await; - mock_sse_response( - &server, - "gemini-pro", - sse_body(&[ - chunk( - "hello", - None, - None, - Some(json!({ - "urlMetadata": [{ - "retrievedUrl": "https://example.com/page", - "urlRetrievalStatus": "URL_RETRIEVAL_STATUS_SUCCESS" - }] - })), - ), - { - let mut c = chunk(" world", Some("STOP"), None, None); - c["usageMetadata"] = json!({ - "promptTokenCount": 10, - "candidatesTokenCount": 5, - "totalTokenCount": 15 - }); - c - }, - ]), - ) - .await; - - let model = provider_at(&server.uri()).model("gemini-pro"); - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - let parts = collect_stream(result).await; - - let pm = finish_provider_metadata(&parts).expect("finish part"); - assert_eq!( - pm["google"]["urlContextMetadata"], - json!({ - "urlMetadata": [{ - "retrievedUrl": "https://example.com/page", - "urlRetrievalStatus": "URL_RETRIEVAL_STATUS_SUCCESS" - }] - }) - ); - } - - #[tokio::test] - async fn expose_url_context_metadata_in_provider_metadata_on_finish() { - // TS: "should expose url context metadata in provider metadata on finish" - let url_context = json!({ - "urlMetadata": [{ - "retrievedUrl": "https://example.com/weather", - "urlRetrievalStatus": "URL_RETRIEVAL_STATUS_SUCCESS" - }] - }); - let server = MockServer::start().await; - mock_sse_response( - &server, - "gemini-pro", - sse_body(&[chunk("test", Some("STOP"), None, Some(url_context.clone()))]), - ) - .await; - - let model = provider_at(&server.uri()).model("gemini-pro"); - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - let parts = collect_stream(result).await; - - let pm = finish_provider_metadata(&parts).expect("finish part"); - assert_eq!(pm["google"]["urlContextMetadata"], url_context); - } - - // ── streaming code execution ────────────────────────────────────────────── - - #[tokio::test] - async fn stream_code_execution_tool_calls_and_results() { - // TS: "should stream code execution tool calls and results" - let server = MockServer::start().await; - mock_sse_response( - &server, - "gemini-2.0-pro", - sse_body(&[ - json!({ - "candidates": [{ - "content": { - "parts": [{ "executableCode": { "language": "PYTHON", "code": "print(\"hello\")" } }] - } - }] - }), - json!({ - "candidates": [{ - "content": { - "parts": [ - { "codeExecutionResult": { "outcome": "OUTCOME_OK", "output": "hello\n" } }, - { "codeExecutionResult": { "outcome": "OUTCOME_OK", "output": "second result\n" } } - ] - }, - "finishReason": "STOP" - }] - }), - ]), - ) - .await; - - let model = provider_at(&server.uri()).model("gemini-2.0-pro"); - let tool = provider_tool("google.code_execution", "runCode", json!({})); - let result = model - .do_stream(&options_with_tools(test_prompt(), vec![tool.clone()])) - .await - .expect("do_stream should succeed"); - let parts = collect_stream(result).await; - - let calls = stream_tool_calls(&parts); - let has_call = calls.iter().any(|(_, name, input)| { - name == "runCode" && *input == r#"{"language":"PYTHON","code":"print(\"hello\")"}"# - }); - assert!( - has_call, - "expected a code_execution tool-call, got {calls:?}" - ); - - let results = stream_tool_results(&parts); - let has_result = results - .iter() - .any(|(_, output)| *output == json!({ "outcome": "OUTCOME_OK", "output": "hello\n" })); - assert!( - has_result, - "expected a code_execution tool-result, got {results:?}" - ); - assert_eq!(results.len(), 2); - assert!(results.iter().all(|(id, _)| id == &calls[0].0)); - assert!(parts.iter().any(|part| matches!( - part, - StreamPart::ToolCall(RawToolCall { - tool_call_id, - tool_name, - provider_executed: Some(true), - provider_metadata: Some(metadata), - .. - }) if tool_name == "runCode" - && serde_json::to_value(&metadata["google"]).unwrap() == json!({ - "serverToolCallId": tool_call_id, - "serverToolType": "code_execution", - }) - ))); - assert!(parts.iter().any(|part| matches!( - part, - StreamPart::ToolResult(ToolResult { - tool_call_id, - tool_name, - provider_metadata: Some(metadata), - .. - }) if tool_name == "runCode" - && serde_json::to_value(&metadata["google"]).unwrap() == json!({ - "serverToolCallId": tool_call_id, - "serverToolType": "code_execution", - }) - ))); - - let result = stream_text( - &model, - "Run code", - GenerateTextOptions { - tools: Some(vec![tool.clone()]), - ..GenerateTextOptions::default() - }, - ) - .await - .expect("stream_text should start") - .consume() - .await - .expect("renamed code execution tool should pass Core validation"); - let call = result.tool_calls.first().expect("code execution call"); - assert_eq!(call.tool_name, "runCode"); - assert_eq!(call.provider_executed, Some(true)); - assert_eq!(call.invalid, None); - - let mut messages = vec![ModelMessage::user("Run code")]; - messages.extend(result.response_messages); - messages.push(ModelMessage::user("Continue")); - let mut next_options = CallOptions::new(convert_to_language_model_prompt(&messages, None)); - next_options.tools = Some(vec![tool]); - let replay = build_request_body("gemini-2.0-pro", &next_options); - let assistant = replay["contents"] - .as_array() - .unwrap() - .iter() - .find(|content| content["role"] == "model") - .expect("assistant replay content"); - assert_eq!( - assistant["parts"], - json!([ - { "executableCode": { "language": "PYTHON", "code": "print(\"hello\")" } }, - { "codeExecutionResult": { "outcome": "OUTCOME_OK", "output": "hello\n" } }, - { "codeExecutionResult": { "outcome": "OUTCOME_OK", "output": "second result\n" } }, - ]) - ); - } - - #[tokio::test] - async fn stream_code_execution_result_with_missing_output_field() { - // TS: "should stream code execution result with missing output field" - let server = MockServer::start().await; - mock_sse_response( - &server, - "gemini-2.0-pro", - sse_body(&[ - json!({ - "candidates": [{ - "content": { - "parts": [{ "executableCode": { - "language": "PYTHON", - "code": "img = PIL.Image.open('input.png')\nimg.save('output.png')\n" - } }] - } - }] - }), - json!({ - "candidates": [{ - "content": { - "parts": [{ "codeExecutionResult": { "outcome": "OUTCOME_OK" } }] - }, - "finishReason": "STOP" - }] - }), - ]), - ) - .await; - - let model = provider_at(&server.uri()).model("gemini-2.0-pro"); - let result = model - .do_stream(&options_with_tools( - test_prompt(), - vec![code_execution_tool()], - )) - .await - .expect("do_stream should succeed"); - let parts = collect_stream(result).await; - - assert!( - stream_tool_calls(&parts) - .iter() - .any(|(_, name, _)| name == "code_execution"), - "expected a code_execution tool-call" - ); - // Missing output defaults to "" (red: result not emitted at all yet). - let results = stream_tool_results(&parts); - let has_empty = results - .iter() - .any(|(_, output)| *output == json!({ "outcome": "OUTCOME_OK", "output": "" })); - assert!( - has_empty, - "expected a tool-result with empty output, got {results:?}" - ); - } - - #[tokio::test] - async fn stop_finish_reason_for_streamed_code_execution() { - // TS: "should return stop finish reason for streamed code execution - // (provider-executed tool)" - let server = MockServer::start().await; - mock_sse_response( - &server, - "gemini-2.0-pro", - sse_body(&[ - json!({ - "candidates": [{ - "content": { - "parts": [{ "executableCode": { "language": "PYTHON", "code": "print(\"hello\")" } }] - } - }] - }), - json!({ - "candidates": [{ - "content": { - "parts": [ - { "codeExecutionResult": { "outcome": "OUTCOME_OK", "output": "hello\n" } }, - { "text": "{\"result\": \"hello\"}" } - ] - }, - "finishReason": "STOP" - }] - }), - ]), - ) - .await; - - let model = provider_at(&server.uri()).model("gemini-2.0-pro"); - let result = model - .do_stream(&options_with_tools( - test_prompt(), - vec![code_execution_tool()], - )) - .await - .expect("do_stream should succeed"); - let parts = collect_stream(result).await; - - let finish = parts.iter().find_map(|p| match p { - StreamPart::Finish { finish_reason, .. } => Some(finish_reason), - _ => None, - }); - let finish = finish.expect("finish part"); - assert_eq!(finish.unified, aimux_core::types::FinishReasonUnified::Stop); - assert_eq!(finish.raw.as_deref(), Some("STOP")); - // Provider-executed tool call/result should be streamed (red). - assert!( - stream_tool_calls(&parts) - .iter() - .any(|(_, name, _)| name == "code_execution"), - "expected a code_execution tool-call in the stream" - ); - } - - /// TS: "should stream server-side toolCall and toolResponse parts (tool combination)". - /// - /// Both parts are streamed now: `StreamPart::ToolCall` / - /// `StreamPart::ToolResult` named `server:`, each carrying - /// `providerMetadata.google.{serverToolCallId,serverToolType,thoughtSignature}`. - #[tokio::test] - async fn stream_server_side_tool_call_and_tool_response_parts() { - let server = MockServer::start().await; - mock_sse_response( - &server, - "gemini-3-pro-preview", - sse_body(&[ - json!({ - "candidates": [{ - "content": { - "parts": [{ "toolCall": { - "toolType": "GOOGLE_SEARCH_WEB", - "args": { "query": "San Francisco weather" }, - "id": "server-call-1" - }, "thoughtSignature": "sig-abc" }] - } - }] - }), - json!({ - "candidates": [{ - "content": { - "parts": [ - { "toolResponse": { - "toolType": "GOOGLE_SEARCH_WEB", - "response": { "results": [{ "title": "Weather in SF" }] }, - "id": "server-call-1" - }, "thoughtSignature": "sig-def" }, - { "text": "The weather in San Francisco is sunny." } - ] - }, - "finishReason": "STOP" - }] - }), - ]), - ) - .await; - - let function_tool = Tool::Function(aimux_core::tool::FunctionTool::new( - "weather".to_string(), - json!({ "type": "object", "properties": { "location": { "type": "string" } } }), - )); - let model = provider_at(&server.uri()).model("gemini-3-pro-preview"); - let result = model - .do_stream(&options_with_tools( - test_prompt(), - vec![google_search_tool(), function_tool], - )) - .await - .expect("do_stream should succeed"); - let parts = collect_stream(result).await; - - let calls = stream_tool_calls(&parts); - assert_eq!(calls.len(), 1, "one toolCall part → one streamed tool-call"); - assert_eq!(calls[0].0, "server-call-1"); - assert_eq!(calls[0].1, "server:GOOGLE_SEARCH_WEB"); - assert_eq!(calls[0].2, r#"{"query":"San Francisco weather"}"#); - - let results = stream_tool_results(&parts); - assert_eq!( - results.len(), - 1, - "one toolResponse part → one streamed tool-result" - ); - assert_eq!( - results[0].0, "server-call-1", - "the streamed result is paired with its call" - ); - assert_eq!( - results[0].1, - json!({ "results": [{ "title": "Weather in SF" }] }), - "the server tool's response payload must survive verbatim" - ); - - // Per-part providerMetadata (ids/types + the thoughtSignature that must - // be echoed back on the follow-up turn). - let meta: Vec = parts - .iter() - .filter_map(|p| match p { - StreamPart::ToolCall(RawToolCall { - tool_name, - provider_metadata, - .. - }) - | StreamPart::ToolResult(ToolResult { - tool_name, - provider_metadata, - .. - }) if tool_name == "server:GOOGLE_SEARCH_WEB" => provider_metadata.clone(), - _ => None, - }) - .collect(); - assert_eq!(meta.len(), 2, "both the call and the result carry metadata"); - assert_eq!( - serde_json::to_value(&meta[0]["google"]).unwrap(), - json!({ - "serverToolCallId": "server-call-1", - "serverToolType": "GOOGLE_SEARCH_WEB", - "thoughtSignature": "sig-abc", - }) - ); - assert_eq!( - serde_json::to_value(&meta[1]["google"]).unwrap(), - json!({ - "serverToolCallId": "server-call-1", - "serverToolType": "GOOGLE_SEARCH_WEB", - "thoughtSignature": "sig-def", - }) - ); - - // The trailing text is still streamed, and the finish reason is Stop - // (provider-executed tools do not flip it to tool-calls). - let text: String = parts - .iter() - .filter_map(|p| match p { - StreamPart::TextDelta { delta, .. } => Some(delta.as_str()), - _ => None, - }) - .collect(); - assert_eq!(text, "The weather in San Francisco is sunny."); - let finish = parts - .iter() - .find_map(|p| match p { - StreamPart::Finish { finish_reason, .. } => Some(finish_reason), - _ => None, - }) - .expect("finish part"); - assert_eq!(finish.unified, aimux_core::types::FinishReasonUnified::Stop); - } - - // ── streaming source events ─────────────────────────────────────────────── - // - // The Rust port does not yet emit `StreamPart::Source` from - // `groundingMetadata`. These tests are red until that lands. - - #[tokio::test] - async fn stream_source_events() { - // TS: "should stream source events" - let server = MockServer::start().await; - mock_sse_response( - &server, - "gemini-pro", - sse_body(&[chunk( - "Some initial text", - Some("STOP"), - Some(json!({ - "groundingChunks": [{ - "web": { "uri": "https://source.example.com", "title": "Source Title" } - }] - })), - None, - )]), - ) - .await; - - let model = provider_at(&server.uri()).model("gemini-pro"); - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - let parts = collect_stream(result).await; - - let sources = stream_sources(&parts); - let has = sources.iter().any(|(_, st, url, title)| { - st == "url" - && url.as_deref() == Some("https://source.example.com") - && title.as_deref() == Some("Source Title") - }); - assert!(has, "expected a url source event, got {sources:?}"); - } - - #[tokio::test] - async fn stream_source_events_from_image_grounding_metadata() { - // TS: "should stream source events from image grounding metadata" - let server = MockServer::start().await; - mock_sse_response( - &server, - "gemini-pro", - sse_body(&[chunk( - "image search response", - Some("STOP"), - Some(json!({ - "groundingChunks": [{ - "image": { - "sourceUri": "https://example.com/article", - "imageUri": "https://example.com/image.jpg", - "title": "Image Source", - "domain": "example.com" - } - }] - })), - None, - )]), - ) - .await; - - let model = provider_at(&server.uri()).model("gemini-pro"); - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - let parts = collect_stream(result).await; - - let sources = stream_sources(&parts); - let has = sources.iter().any(|(_, st, url, title)| { - st == "url" - && url.as_deref() == Some("https://example.com/article") - && title.as_deref() == Some("Image Source") - }); - assert!(has, "expected an image url source event, got {sources:?}"); - } - - #[tokio::test] - async fn stream_sources_during_intermediate_chunks() { - // TS: "should stream sources during intermediate chunks" - let server = MockServer::start().await; - mock_sse_response( - &server, - "gemini-pro", - sse_body(&[ - chunk( - "text", - None, - Some(json!({ - "groundingChunks": [ - { "web": { "uri": "https://a.com", "title": "A" } }, - { "web": { "uri": "https://b.com", "title": "B" } } - ] - })), - None, - ), - chunk("more", Some("STOP"), None, None), - ]), - ) - .await; - - let model = provider_at(&server.uri()).model("gemini-pro"); - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - let parts = collect_stream(result).await; - - let sources = stream_sources(&parts); - assert!(sources.iter().any(|(_, st, url, title)| { - st == "url" && url.as_deref() == Some("https://a.com") && title.as_deref() == Some("A") - })); - assert!(sources.iter().any(|(_, st, url, title)| { - st == "url" && url.as_deref() == Some("https://b.com") && title.as_deref() == Some("B") - })); - } - - #[tokio::test] - async fn deduplicate_sources_across_chunks() { - // TS: "should deduplicate sources across chunks" - let server = MockServer::start().await; - mock_sse_response( - &server, - "gemini-pro", - sse_body(&[ - chunk( - "first chunk", - None, - Some(json!({ - "groundingChunks": [ - { "web": { "uri": "https://example.com", "title": "Example" } }, - { "web": { "uri": "https://unique.com", "title": "Unique" } } - ] - })), - None, - ), - chunk( - "second chunk", - None, - Some(json!({ - "groundingChunks": [ - { "web": { "uri": "https://example.com", "title": "Example Duplicate" } }, - { "web": { "uri": "https://another.com", "title": "Another" } } - ] - })), - None, - ), - chunk("final chunk", Some("STOP"), None, None), - ]), - ) - .await; - - let model = provider_at(&server.uri()).model("gemini-pro"); - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - let parts = collect_stream(result).await; - - let sources = stream_sources(&parts); - // The duplicate https://example.com appears in two chunks but should be - // emitted only once (keeping the first title). - let example: Vec<_> = sources - .iter() - .filter(|(_, _, url, _)| url.as_deref() == Some("https://example.com")) - .collect(); - assert_eq!( - example.len(), - 1, - "duplicate source should be emitted once, got {sources:?}" - ); - assert_eq!(example[0].3.as_deref(), Some("Example")); - - // Unique + Another are also present. - assert!( - sources - .iter() - .any(|(_, _, url, _)| url.as_deref() == Some("https://unique.com")) - ); - assert!( - sources - .iter() - .any(|(_, _, url, _)| url.as_deref() == Some("https://another.com")) - ); - assert_eq!( - sources.len(), - 3, - "expected 3 deduplicated sources, got {sources:?}" - ); - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// Schema validation gaps -// -// The TS suite has `describe('groundingMetadataSchema')` (16 tests) and -// `describe('urlContextMetadata')` (3 tests) that validate the zod schemas -// returned by `getGroundingMetadataSchema()` / `getUrlContextMetadataSchema()`. -// -// The Rust port stores `groundingMetadata` / `urlContextMetadata` as untyped -// `serde_json::Value` (see `Candidate` in `types.rs`) and exposes them -// verbatim in provider metadata — there is no schema type to validate against. -// These schema-validation tests therefore have no direct Rust equivalent; the -// structural coverage they provide is instead carried by the response-parsing -// tests above, which assert on the concrete `groundingMetadata` shape. -// -// The ignored tests below are placeholders that document the gap; they build -// the same metadata payloads and would assert schema acceptance once a typed -// schema exists. -// ════════════════════════════════════════════════════════════════════════════ - -mod schema_validation_gaps { - use super::*; - - #[test] - #[ignore = "no get_grounding_metadata_schema() in Rust; metadata is untyped serde_json::Value"] - fn grounding_metadata_schema_validates_web_search_results() { - // TS: "validates complete grounding metadata with web search results" - let _metadata = json!({ - "webSearchQueries": ["What's the weather in Chicago this weekend?"], - "searchEntryPoint": { "renderedContent": "Sample rendered content for search results" }, - "groundingChunks": [{ - "web": { "uri": "https://example.com/weather", "title": "Chicago Weather Forecast" } - }], - "groundingSupports": [{ - "segment": { "startIndex": 0, "endIndex": 65, "text": "..." }, - "groundingChunkIndices": [0], - "confidenceScores": [0.99] - }], - "retrievalMetadata": { "webDynamicRetrievalScore": 0.96879 } - }); - // TODO: once a typed schema exists, assert it accepts this payload. - } - - #[test] - #[ignore = "no get_grounding_metadata_schema() in Rust; metadata is untyped serde_json::Value"] - fn grounding_metadata_schema_validates_vertex_ai_search_results() { - // TS: "validates complete grounding metadata with Vertex AI Search results" - let _metadata = json!({ - "retrievalQueries": ["How to make appointment to renew driving license?"], - "groundingChunks": [{ - "retrievedContext": { - "uri": "https://vertexaisearch.cloud.google.com/grounding-api-redirect/AXiHM.....QTN92V5ePQ==", - "title": "dmv" - } - }], - "groundingSupports": [{ - "segment": { "startIndex": 25, "endIndex": 147 }, - "segment_text": "ipsum lorem ...", - "supportChunkIndices": [1, 2], - "confidenceScore": [0.9541752, 0.97726375] - }] - }); - } - - #[test] - #[ignore = "no get_grounding_metadata_schema() in Rust; metadata is untyped serde_json::Value"] - fn grounding_metadata_schema_validates_file_search_store_format() { - // TS: "validates groundingChunks[].retrievedContext with fileSearchStore (new format)" - let _metadata = json!({ - "groundingChunks": [{ - "retrievedContext": { - "text": "Sample content for testing...", - "fileSearchStore": "fileSearchStores/test-store-xyz", - "title": "Test Document" - } - }] - }); - } - - #[test] - #[ignore = "no get_grounding_metadata_schema() in Rust; metadata is untyped serde_json::Value"] - fn grounding_metadata_schema_validates_image_and_maps_chunks() { - // TS: "validates groundingChunks[].image" + "validates grounding metadata with maps chunks" - let _metadata = json!({ - "imageSearchQueries": ["Super Bowl halftime show in space"], - "groundingChunks": [ - { "image": { - "sourceUri": "https://example.com/article", - "imageUri": "https://example.com/image.jpg", - "title": "Image Title", - "domain": "example.com" - } }, - { "maps": { - "uri": "https://maps.google.com/maps?cid=12345", - "title": "Best Italian Restaurant", - "text": "A great Italian restaurant", - "placeId": "ChIJ12345" - } } - ] - }); - } - - #[test] - #[ignore = "no get_grounding_metadata_schema() in Rust; metadata is untyped serde_json::Value"] - fn grounding_metadata_schema_validates_partial_and_empty() { - // TS: "validates partial grounding metadata" + "validates empty grounding metadata" - let _partial = json!({ "webSearchQueries": ["sample query"] }); - let _empty = json!({}); - } - - #[test] - #[ignore = "no get_grounding_metadata_schema() in Rust; metadata is untyped serde_json::Value"] - fn grounding_metadata_schema_rejects_invalid_data_types() { - // TS: "rejects invalid data types" — a typed schema would reject these. - let _invalid = json!({ - "webSearchQueries": "not an array", - "groundingSupports": [{ "confidenceScores": "not an array" }] - }); - } - - #[test] - #[ignore = "no get_url_context_metadata_schema() in Rust; metadata is untyped serde_json::Value"] - fn url_context_metadata_schema_validates_output() { - // TS: "validates complete url context output" + "validates empty url context output" - let _complete = json!({ - "urlMetadata": [{ - "retrievedUrl": "https://example.com/weather", - "urlRetrievalStatus": "URL_RETRIEVAL_STATUS_SUCCESS" - }] - }); - let _empty = json!({ "urlMetadata": [] }); - } -} diff --git a/aimux-providers/tests/google_remaining_test.rs b/aimux-providers/tests/google_remaining_test.rs deleted file mode 100644 index 30023ac97..000000000 --- a/aimux-providers/tests/google_remaining_test.rs +++ /dev/null @@ -1,1440 +0,0 @@ -//! Remaining Google provider tests — ported from the TS SDK test suite. -//! -//! Covers the pure-function and config tests that were not already exercised -//! by `google_model_test.rs` / `google_provider_tools_test.rs`: -//! -//! - get-model-path.test.ts -> get_model_path tests -//! - google-supported-file-url.test.ts -> is_supported_file_url tests -//! - google-model-capabilities.test.ts -> get_google_model_capabilities -//! - convert-json-schema-to-openapi-schema.test.ts -> schema conversion -//! - google-json-accumulator.test.ts -> GoogleJsonAccumulator -//! - google-provider.test.ts -> provider config (env var, headers, base URL) -//! -//! Reference: reference/ai/packages/google/src/. - -use serde_json::{Value, json}; -use serial_test::serial; -use wiremock::matchers::{header, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -use aimux_core::content::ContentPart; -use aimux_core::error::AiMuxError; -use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; -use aimux_core::options::CallOptions; -use aimux_providers::google::convert::{ - convert_json_schema_to_openapi_schema, get_google_model_capabilities, -}; -use aimux_providers::google::utils::{ - GoogleJsonAccumulator, PartialArg, get_model_path, is_supported_file_url, -}; -use aimux_providers::{GoogleConfig, GoogleProvider}; - -// ════════════════════════════════════════════════════════════════════════════ -// get_model_path (TS: get-model-path.test.ts) -// ════════════════════════════════════════════════════════════════════════════ - -mod get_model_path_tests { - use super::*; - - #[test] - fn pass_through_for_models_slash() { - assert_eq!(get_model_path("models/some-model"), "models/some-model"); - } - - #[test] - fn pass_through_for_tuned_models_slash() { - assert_eq!( - get_model_path("tunedModels/some-model"), - "tunedModels/some-model" - ); - } - - #[test] - fn add_prefix_to_models_without_slash() { - assert_eq!(get_model_path("some-model"), "models/some-model"); - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// is_supported_file_url (TS: google-supported-file-url.test.ts) -// ════════════════════════════════════════════════════════════════════════════ - -mod is_supported_file_url_tests { - use super::*; - - #[test] - fn valid_google_file_urls() { - assert!(is_supported_file_url( - "https://generativelanguage.googleapis.com/v1beta/files/00000000-00000000-00000000-00000000" - )); - assert!(is_supported_file_url( - "https://generativelanguage.googleapis.com/v1beta/files/test123" - )); - } - - #[test] - fn valid_youtube_urls() { - let valid = [ - "https://www.youtube.com/watch?v=dQw4w9WgXcQ", - "https://youtube.com/watch?v=dQw4w9WgXcQ", - "https://youtu.be/dQw4w9WgXcQ", - "https://www.youtube.com/watch?v=dQw4w9WgXcQ&feature=youtu.be", - "https://youtu.be/dQw4w9WgXcQ?t=42", - ]; - for url in valid { - assert!(is_supported_file_url(url), "should be supported: {url}"); - } - } - - #[test] - fn invalid_youtube_urls() { - let invalid = [ - "https://youtube.com/channel/UCdQw4w9WgXcQ", - "https://youtube.com/playlist?list=PLdQw4w9WgXcQ", - "https://m.youtube.com/watch?v=dQw4w9WgXcQ", - "http://youtube.com/watch?v=dQw4w9WgXcQ", - "https://vimeo.com/123456789", - ]; - for url in invalid { - assert!( - !is_supported_file_url(url), - "should NOT be supported: {url}" - ); - } - } - - #[test] - fn non_google_file_urls() { - let cases = [ - "https://example.com", - "https://example.com/foo/bar", - "https://generativelanguage.googleapis.com", - "https://generativelanguage.googleapis.com/v1/other", - "http://generativelanguage.googleapis.com/v1beta/files/test", - "https://api.googleapis.com/v1beta/files/test", - ]; - for url in cases { - assert!( - !is_supported_file_url(url), - "should NOT be supported: {url}" - ); - } - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// get_google_model_capabilities (TS: google-model-capabilities.test.ts) -// ════════════════════════════════════════════════════════════════════════════ - -mod model_capabilities_tests { - use super::*; - - #[test] - fn gemini_pro_is_legacy() { - let caps = get_google_model_capabilities("gemini-pro"); - assert!(!caps.supports_gemini_2_tools); - assert!(!caps.supports_file_search); - assert!(!caps.uses_gemini_3_features); - } - - #[test] - fn gemini_pro_vision_is_legacy() { - let caps = get_google_model_capabilities("gemini-pro-vision"); - assert!(!caps.supports_gemini_2_tools); - assert!(!caps.supports_file_search); - assert!(!caps.uses_gemini_3_features); - } - - #[test] - fn gemini_1_5_flash_is_legacy() { - let caps = get_google_model_capabilities("gemini-1.5-flash"); - assert!(!caps.supports_gemini_2_tools); - assert!(!caps.supports_file_search); - assert!(!caps.uses_gemini_3_features); - } - - #[test] - fn gemini_robotics_er_1_5_is_legacy() { - let caps = get_google_model_capabilities("gemini-robotics-er-1.5-preview"); - assert!(!caps.supports_gemini_2_tools); - assert!(!caps.supports_file_search); - assert!(!caps.uses_gemini_3_features); - } - - #[test] - fn gemini_2_0_flash_supports_gemini_2_tools() { - let caps = get_google_model_capabilities("gemini-2.0-flash"); - assert!(caps.supports_gemini_2_tools); - assert!(!caps.supports_file_search); - assert!(!caps.uses_gemini_3_features); - } - - #[test] - fn gemini_2_5_flash_supports_file_search() { - let caps = get_google_model_capabilities("gemini-2.5-flash"); - assert!(caps.supports_gemini_2_tools); - assert!(caps.supports_file_search); - assert!(!caps.uses_gemini_3_features); - } - - #[test] - fn gemini_3_1_pro_uses_gemini_3_features() { - let caps = get_google_model_capabilities("gemini-3.1-pro-preview"); - assert!(caps.supports_gemini_2_tools); - assert!(caps.supports_file_search); - assert!(caps.uses_gemini_3_features); - } - - #[test] - fn unknown_future_gemini_inherits_newest() { - let caps = get_google_model_capabilities("gemini-99-pro-preview"); - assert!(caps.supports_gemini_2_tools); - assert!(caps.supports_file_search); - assert!(caps.uses_gemini_3_features); - } - - #[test] - fn gemini_ultra_latest_uses_gemini_3_features() { - let caps = get_google_model_capabilities("gemini-ultra-latest"); - assert!(caps.supports_gemini_2_tools); - assert!(caps.supports_file_search); - assert!(caps.uses_gemini_3_features); - } - - #[test] - fn nano_banana_supports_gemini_2_tools_only() { - let caps = get_google_model_capabilities("nano-banana-pro-preview"); - assert!(caps.supports_gemini_2_tools); - assert!(!caps.supports_file_search); - assert!(!caps.uses_gemini_3_features); - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// convert_json_schema_to_openapi_schema (TS: convert-json-schema-to-openapi-schema.test.ts) -// ════════════════════════════════════════════════════════════════════════════ - -mod schema_conversion_tests { - use super::*; - - fn convert_root(schema: &Value) -> Value { - convert_json_schema_to_openapi_schema(schema, true) - } - - fn convert_nested(schema: &Value) -> Value { - convert_json_schema_to_openapi_schema(schema, false) - } - - #[test] - fn removes_additional_properties_and_schema() { - let input = json!({ - "$schema": "http://json-schema.org/draft-07/schema#", - "type": "object", - "properties": { - "name": { "type": "string" }, - "age": { "type": "number" } - }, - "additionalProperties": false - }); - assert_eq!( - convert_root(&input), - json!({ - "type": "object", - "properties": { - "name": { "type": "string" }, - "age": { "type": "number" } - } - }) - ); - } - - #[test] - fn removes_additional_properties_from_nested_objects() { - let input = json!({ - "type": "object", - "properties": { - "keys": { - "type": "object", - "additionalProperties": { "type": "string" }, - "description": "Description for the key" - } - }, - "additionalProperties": false, - "$schema": "http://json-schema.org/draft-07/schema#" - }); - assert_eq!( - convert_root(&input), - json!({ - "type": "object", - "properties": { - "keys": { - "type": "object", - "description": "Description for the key" - } - } - }) - ); - } - - #[test] - fn handles_nested_objects_and_arrays() { - let input = json!({ - "type": "object", - "properties": { - "users": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { "type": "number" }, - "name": { "type": "string" } - }, - "additionalProperties": false - } - } - }, - "additionalProperties": false - }); - assert_eq!( - convert_root(&input), - json!({ - "type": "object", - "properties": { - "users": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { "type": "number" }, - "name": { "type": "string" } - } - } - } - } - }) - ); - } - - #[test] - fn converts_const_to_enum_single_value() { - let input = json!({ - "type": "object", - "properties": { "status": { "const": "active" } } - }); - assert_eq!( - convert_root(&input), - json!({ - "type": "object", - "properties": { "status": { "enum": ["active"] } } - }) - ); - } - - #[test] - fn handles_all_of_any_of_one_of() { - let input = json!({ - "type": "object", - "properties": { - "allOfProp": { "allOf": [{ "type": "string" }, { "minLength": 5 }] }, - "anyOfProp": { "anyOf": [{ "type": "string" }, { "type": "number" }] }, - "oneOfProp": { "oneOf": [{ "type": "boolean" }, { "type": "null" }] } - } - }); - assert_eq!( - convert_root(&input), - json!({ - "type": "object", - "properties": { - "allOfProp": { "allOf": [{ "type": "string" }, { "minLength": 5 }] }, - "anyOfProp": { "anyOf": [{ "type": "string" }, { "type": "number" }] }, - "oneOfProp": { "oneOf": [{ "type": "boolean" }, { "type": "null" }] } - } - }) - ); - } - - #[test] - fn preserves_format() { - let input = json!({ - "type": "object", - "properties": { "timestamp": { "type": "string", "format": "date-time" } } - }); - assert_eq!( - convert_root(&input), - json!({ - "type": "object", - "properties": { "timestamp": { "type": "string", "format": "date-time" } } - }) - ); - } - - #[test] - fn handles_required_properties() { - let input = json!({ - "type": "object", - "properties": { "id": { "type": "number" }, "name": { "type": "string" } }, - "required": ["id"] - }); - assert_eq!( - convert_root(&input), - json!({ - "type": "object", - "properties": { "id": { "type": "number" }, "name": { "type": "string" } }, - "required": ["id"] - }) - ); - } - - #[test] - fn deeply_nested_const_becomes_enum() { - let input = json!({ - "type": "object", - "properties": { - "nested": { - "type": "object", - "properties": { - "deeplyNested": { - "anyOf": [ - { "type": "object", "properties": { "value": { "const": "specific value" } } }, - { "type": "string" } - ] - } - } - } - } - }); - assert_eq!( - convert_root(&input), - json!({ - "type": "object", - "properties": { - "nested": { - "type": "object", - "properties": { - "deeplyNested": { - "anyOf": [ - { "type": "object", "properties": { "value": { "enum": ["specific value"] } } }, - { "type": "string" } - ] - } - } - } - } - }) - ); - } - - #[test] - fn complex_schema_with_nested_const_and_anyof() { - let input = json!({ - "type": "object", - "properties": { - "name": { "type": "string" }, - "age": { "type": "number" }, - "contact": { - "anyOf": [ - { - "type": "object", - "properties": { - "type": { "type": "string", "const": "email" }, - "value": { "type": "string" } - }, - "required": ["type", "value"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "type": { "type": "string", "const": "phone" }, - "value": { "type": "string" } - }, - "required": ["type", "value"], - "additionalProperties": false - } - ] - } - }, - "required": ["name", "age", "contact"], - "additionalProperties": false, - "$schema": "http://json-schema.org/draft-07/schema#" - }); - let result = convert_root(&input); - assert_eq!(result["type"], "object"); - assert_eq!(result["required"], json!(["name", "age", "contact"])); - let contact_anyof = &result["properties"]["contact"]["anyOf"]; - assert_eq!( - contact_anyof[0]["properties"]["type"]["enum"], - json!(["email"]) - ); - assert_eq!( - contact_anyof[1]["properties"]["type"]["enum"], - json!(["phone"]) - ); - } - - #[test] - fn handles_null_type_correctly() { - let input = json!({ - "type": "object", - "properties": { - "nullableField": { "type": ["string", "null"] }, - "explicitNullField": { "type": "null" } - } - }); - let result = convert_root(&input); - assert_eq!( - result["properties"]["nullableField"], - json!({ "anyOf": [{ "type": "string" }], "nullable": true }) - ); - assert_eq!( - result["properties"]["explicitNullField"], - json!({ "type": "null" }) - ); - } - - #[test] - fn handles_descriptions() { - let input = json!({ - "type": "object", - "description": "A user object", - "properties": { - "id": { "type": "number", "description": "The user ID" }, - "name": { "type": "string", "description": "The user's full name" }, - "email": { "type": "string", "format": "email", "description": "The user's email address" } - }, - "required": ["id", "name"] - }); - assert_eq!(convert_root(&input), input); - } - - #[test] - fn returns_null_for_empty_object_schemas_at_root() { - let cases = [ - json!({ "type": "object" }), - json!({ "type": "object", "properties": {} }), - ]; - for schema in &cases { - assert!(convert_root(schema).is_null(), "expected null for {schema}"); - } - } - - #[test] - fn preserves_nested_empty_object_with_description() { - let input = json!({ - "type": "object", - "properties": { - "url": { "type": "string", "description": "URL to navigate to" }, - "launchOptions": { "type": "object", "description": "PuppeteerJS LaunchOptions" }, - "allowDangerous": { "type": "boolean", "description": "Allow dangerous options" } - }, - "required": ["url", "launchOptions"] - }); - assert_eq!(convert_root(&input), input); - } - - #[test] - fn preserves_nested_empty_object_without_description() { - let input = json!({ - "type": "object", - "properties": { "options": { "type": "object" } }, - "required": ["options"] - }); - assert_eq!(convert_root(&input), input); - } - - #[test] - fn handles_non_empty_object_schemas() { - let input = json!({ - "type": "object", - "properties": { "name": { "type": "string" } } - }); - assert_eq!(convert_root(&input), input); - } - - #[test] - fn converts_string_enum_properties() { - let input = json!({ - "type": "object", - "properties": { - "kind": { "type": "string", "enum": ["text", "code", "image"] } - }, - "required": ["kind"], - "additionalProperties": false, - "$schema": "http://json-schema.org/draft-07/schema#" - }); - assert_eq!( - convert_root(&input), - json!({ - "type": "object", - "properties": { - "kind": { "type": "string", "enum": ["text", "code", "image"] } - }, - "required": ["kind"] - }) - ); - } - - #[test] - fn converts_nullable_string_enum() { - let input = json!({ - "type": "object", - "properties": { - "fieldD": { - "anyOf": [ - { "type": "string", "enum": ["a", "b", "c"] }, - { "type": "null" } - ] - } - }, - "required": ["fieldD"], - "additionalProperties": false, - "$schema": "http://json-schema.org/draft-07/schema#" - }); - let result = convert_root(&input); - // The anyOf with a null branch collapses into nullable + the non-null - // schema's fields. - assert_eq!( - result["properties"]["fieldD"], - json!({ "nullable": true, "type": "string", "enum": ["a", "b", "c"] }) - ); - } - - #[test] - fn type_array_multiple_non_null_plus_null() { - let input = json!({ - "type": "object", - "properties": { - "multiTypeField": { "type": ["string", "number", "null"] } - } - }); - assert_eq!( - convert_root(&input), - json!({ - "type": "object", - "properties": { - "multiTypeField": { - "anyOf": [{ "type": "string" }, { "type": "number" }], - "nullable": true - } - } - }) - ); - } - - #[test] - fn type_array_without_null_becomes_anyof() { - let input = json!({ - "type": "object", - "properties": { - "multiTypeField": { "type": ["string", "number"] } - } - }); - assert_eq!( - convert_root(&input), - json!({ - "type": "object", - "properties": { - "multiTypeField": { "anyOf": [{ "type": "string" }, { "type": "number" }] } - } - }) - ); - } - - #[test] - fn boolean_schema_becomes_object() { - let input = json!(true); - assert_eq!( - convert_root(&input), - json!({ "type": "boolean", "properties": {} }) - ); - } - - #[test] - fn nested_empty_object_root_returns_null_nested_returns_type_object() { - let empty = json!({ "type": "object", "properties": {} }); - assert!(convert_root(&empty).is_null()); - // When nested, it becomes { "type": "object" }. - assert_eq!(convert_nested(&empty), json!({ "type": "object" })); - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// GoogleJsonAccumulator (TS: google-json-accumulator.test.ts) -// ════════════════════════════════════════════════════════════════════════════ - -mod json_accumulator_tests { - use super::*; - - fn s(path: &str, value: &str) -> PartialArg { - PartialArg { - json_path: path.to_string(), - string_value: Some(value.to_string()), - ..Default::default() - } - } - - fn s_continue(path: &str, value: &str) -> PartialArg { - PartialArg { - json_path: path.to_string(), - string_value: Some(value.to_string()), - will_continue: Some(true), - ..Default::default() - } - } - - fn n(path: &str, value: f64) -> PartialArg { - PartialArg { - json_path: path.to_string(), - number_value: Some(value), - ..Default::default() - } - } - - fn b(path: &str, value: bool) -> PartialArg { - PartialArg { - json_path: path.to_string(), - bool_value: Some(value), - ..Default::default() - } - } - - fn null_arg(path: &str) -> PartialArg { - PartialArg { - json_path: path.to_string(), - null_value: Some(()), - ..Default::default() - } - } - - // ── flat paths ───────────────────────────────────────────────────────── - - #[test] - fn accumulate_simple_string_with_will_continue() { - let mut acc = GoogleJsonAccumulator::new(); - let r = acc - .process_partial_args(&[s_continue("$.location", "Boston")]) - .unwrap(); - assert_eq!(r.current_json, json!({ "location": "Boston" })); - assert_eq!(r.text_delta, "{\"location\":\"Boston"); - } - - #[test] - fn continue_string_across_chunks() { - let mut acc = GoogleJsonAccumulator::new(); - acc.process_partial_args(&[s_continue("$.location", "Boston")]) - .unwrap(); - let r = acc - .process_partial_args(&[s("$.location", ", MA")]) - .unwrap(); - assert_eq!(r.current_json, json!({ "location": "Boston, MA" })); - assert_eq!(r.text_delta, ", MA"); - } - - #[test] - fn accumulate_complete_string() { - let mut acc = GoogleJsonAccumulator::new(); - let r = acc - .process_partial_args(&[s("$.location", "Boston")]) - .unwrap(); - assert_eq!(r.current_json, json!({ "location": "Boston" })); - assert_eq!(r.text_delta, "{\"location\":\"Boston\""); - } - - #[test] - fn accumulate_number() { - let mut acc = GoogleJsonAccumulator::new(); - let r = acc - .process_partial_args(&[n("$.brightness", 50.0)]) - .unwrap(); - assert_eq!(r.current_json, json!({ "brightness": 50 })); - assert_eq!(r.text_delta, "{\"brightness\":50"); - } - - #[test] - fn accumulate_boolean() { - let mut acc = GoogleJsonAccumulator::new(); - let r = acc.process_partial_args(&[b("$.enabled", true)]).unwrap(); - assert_eq!(r.current_json, json!({ "enabled": true })); - assert_eq!(r.text_delta, "{\"enabled\":true"); - } - - #[test] - fn accumulate_null() { - let mut acc = GoogleJsonAccumulator::new(); - let r = acc.process_partial_args(&[null_arg("$.nickname")]).unwrap(); - assert_eq!(r.current_json, json!({ "nickname": null })); - assert_eq!(r.text_delta, "{\"nickname\":null"); - } - - #[test] - fn accumulate_multiple_args_with_commas() { - let mut acc = GoogleJsonAccumulator::new(); - let first = acc - .process_partial_args(&[n("$.brightness", 50.0)]) - .unwrap(); - assert_eq!(first.text_delta, "{\"brightness\":50"); - - let second = acc.process_partial_args(&[b("$.enabled", true)]).unwrap(); - assert_eq!( - second.current_json, - json!({ "brightness": 50, "enabled": true }) - ); - assert_eq!(second.text_delta, ",\"enabled\":true"); - } - - #[test] - fn accumulate_multiple_args_single_call() { - let mut acc = GoogleJsonAccumulator::new(); - let r = acc - .process_partial_args(&[ - n("$.brightness", 50.0), - b("$.enabled", false), - null_arg("$.nickname"), - ]) - .unwrap(); - assert_eq!( - r.current_json, - json!({ "brightness": 50, "enabled": false, "nickname": null }) - ); - assert_eq!( - r.text_delta, - "{\"brightness\":50,\"enabled\":false,\"nickname\":null" - ); - } - - #[test] - fn escape_special_chars_in_continued_strings() { - let mut acc = GoogleJsonAccumulator::new(); - acc.process_partial_args(&[s_continue("$.query", "Boston \"Lo")]) - .unwrap(); - let r = acc.process_partial_args(&[s("$.query", "gan\"")]).unwrap(); - assert_eq!(r.current_json, json!({ "query": "Boston \"Logan\"" })); - // The continuation delta is the escaped inner content of `gan"`. - assert_eq!(r.text_delta, "gan\\\""); - } - - #[test] - fn skip_args_with_empty_path() { - let mut acc = GoogleJsonAccumulator::new(); - let r = acc.process_partial_args(&[s("$.", "ignored")]).unwrap(); - assert_eq!(r.current_json, json!({})); - assert_eq!(r.text_delta, ""); - } - - #[test] - fn skip_args_with_no_resolvable_value() { - let mut acc = GoogleJsonAccumulator::new(); - let r = acc - .process_partial_args(&[PartialArg { - json_path: "$.something".to_string(), - ..Default::default() - }]) - .unwrap(); - assert_eq!(r.current_json, json!({})); - assert_eq!(r.text_delta, ""); - } - - /// Regression (audit finding on H3): a malformed empty path like `$.[]` - /// parses to zero segments; it must be skipped instead of panicking with a - /// slice underflow in `emit_navigation_to`. - #[test] - fn malformed_empty_path_is_skipped_not_panicked() { - let mut acc = GoogleJsonAccumulator::new(); - let r = acc - .process_partial_args(&[ - PartialArg { - json_path: "$.[]".to_string(), - string_value: Some("x".to_string()), - ..Default::default() - }, - PartialArg { - json_path: "$.ok".to_string(), - string_value: Some("y".to_string()), - ..Default::default() - }, - ]) - .unwrap(); - // The malformed arg is ignored; the well-formed one still lands. - assert_eq!(r.current_json, json!({ "ok": "y" })); - } - - /// Regression (audit round 2): an oversized array index must produce an - /// error instead of expanding an array with ~1e6+ Null entries (OOM). - #[test] - fn oversized_array_index_is_rejected() { - let mut acc = GoogleJsonAccumulator::new(); - let err = acc - .process_partial_args(&[PartialArg { - json_path: "$.a[1000000]".to_string(), - string_value: Some("x".to_string()), - ..Default::default() - }]) - .unwrap_err(); - assert!(matches!(err, AiMuxError::JsonParse(_))); - } - - /// Regression (audit round 3, A1): an oversized **intermediate** index - /// (not the last segment) must also be rejected — previously only the - /// leaf segment was checked, so `$.a[1000000000].b` could still force - /// ~1 GiB of array allocation before failing. - #[test] - fn intermediate_oversized_index_is_rejected_atomically() { - let mut acc = GoogleJsonAccumulator::new(); - let err = acc - .process_partial_args(&[PartialArg { - json_path: "$.a[1000000].b".to_string(), - string_value: Some("x".to_string()), - ..Default::default() - }]) - .unwrap_err(); - assert!(matches!(err, AiMuxError::JsonParse(_))); - - // Error atomicity: the rejected arg must not leave partially-built - // containers (`a: []`) behind — a subsequent valid write lands cleanly. - let r = acc - .process_partial_args(&[PartialArg { - json_path: "$.ok".to_string(), - string_value: Some("y".to_string()), - ..Default::default() - }]) - .unwrap(); - assert_eq!(r.current_json, json!({ "ok": "y" })); - } - - /// Regression (audit round 2): an overly deep path must be rejected. - #[test] - fn overdeep_path_is_rejected() { - let mut acc = GoogleJsonAccumulator::new(); - let deep = format!("${}", ".a".repeat(100)); - let err = acc - .process_partial_args(&[PartialArg { - json_path: deep, - string_value: Some("x".to_string()), - ..Default::default() - }]) - .unwrap_err(); - assert!(matches!(err, AiMuxError::JsonParse(_))); - } - - #[test] - fn empty_partial_args_returns_empty_delta() { - let mut acc = GoogleJsonAccumulator::new(); - let r = acc.process_partial_args(&[]).unwrap(); - assert_eq!(r.current_json, json!({})); - assert_eq!(r.text_delta, ""); - } - - // ── nested paths ─────────────────────────────────────────────────────── - - #[test] - fn build_nested_object_from_dotted_path() { - let mut acc = GoogleJsonAccumulator::new(); - let r = acc - .process_partial_args(&[s("$.recipe.name", "Lasagna")]) - .unwrap(); - assert_eq!(r.current_json, json!({ "recipe": { "name": "Lasagna" } })); - assert_eq!(r.text_delta, "{\"recipe\":{\"name\":\"Lasagna\""); - } - - #[test] - fn build_nested_object_with_array_from_indexed_path() { - let mut acc = GoogleJsonAccumulator::new(); - let amount = acc - .process_partial_args(&[s("$.recipe.ingredients[0].amount", "16 oz")]) - .unwrap(); - assert_eq!( - amount.current_json, - json!({ "recipe": { "ingredients": [{ "amount": "16 oz" }] } }) - ); - assert_eq!( - amount.text_delta, - "{\"recipe\":{\"ingredients\":[{\"amount\":\"16 oz\"" - ); - - let name = acc - .process_partial_args(&[s("$.recipe.ingredients[0].name", "Lasagna noodles")]) - .unwrap(); - assert_eq!( - name.current_json, - json!({ "recipe": { "ingredients": [{ "amount": "16 oz", "name": "Lasagna noodles" }] } }) - ); - assert_eq!(name.text_delta, ",\"name\":\"Lasagna noodles\""); - } - - #[test] - fn accumulate_multiple_array_elements_across_chunks() { - let mut acc = GoogleJsonAccumulator::new(); - let mut deltas = Vec::new(); - - let r = acc - .process_partial_args(&[s("$.recipe.ingredients[0].amount", "16 oz")]) - .unwrap(); - deltas.push(r.text_delta.clone()); - - let r = acc - .process_partial_args(&[s("$.recipe.ingredients[0].name", "Noodles")]) - .unwrap(); - deltas.push(r.text_delta.clone()); - - let r = acc - .process_partial_args(&[s("$.recipe.ingredients[1].amount", "1 lb")]) - .unwrap(); - deltas.push(r.text_delta.clone()); - assert_eq!(r.text_delta, "},{\"amount\":\"1 lb\""); - - let r = acc - .process_partial_args(&[s("$.recipe.ingredients[1].name", "Beef")]) - .unwrap(); - deltas.push(r.text_delta.clone()); - assert_eq!(r.text_delta, ",\"name\":\"Beef\""); - - assert_eq!( - r.current_json, - json!({ - "recipe": { - "ingredients": [ - { "amount": "16 oz", "name": "Noodles" }, - { "amount": "1 lb", "name": "Beef" } - ] - } - }) - ); - - let f = acc.finalize(); - deltas.push(f.closing_delta); - assert_eq!(deltas.join(""), f.final_json); - } - - #[test] - fn string_continuation_on_nested_paths() { - let mut acc = GoogleJsonAccumulator::new(); - let start = acc - .process_partial_args(&[s_continue("$.recipe.steps[0]", "Preheat oven")]) - .unwrap(); - assert_eq!(start.text_delta, "{\"recipe\":{\"steps\":[\"Preheat oven"); - - let cont = acc - .process_partial_args(&[s("$.recipe.steps[0]", " to 375°F.")]) - .unwrap(); - assert_eq!( - cont.current_json, - json!({ "recipe": { "steps": ["Preheat oven to 375°F."] } }) - ); - assert_eq!(cont.text_delta, " to 375°F."); - } - - #[test] - fn mixed_nested_and_flat_paths() { - let mut acc = GoogleJsonAccumulator::new(); - let loc = acc - .process_partial_args(&[s("$.location", "Boston")]) - .unwrap(); - assert_eq!(loc.text_delta, "{\"location\":\"Boston\""); - - let details = acc - .process_partial_args(&[s("$.details.zip", "02101")]) - .unwrap(); - assert_eq!( - details.current_json, - json!({ "details": { "zip": "02101" }, "location": "Boston" }) - ); - assert_eq!(details.text_delta, ",\"details\":{\"zip\":\"02101\""); - - let f = acc.finalize(); - assert_eq!(f.closing_delta, "}}"); - assert_eq!( - f.final_json, - "{\"location\":\"Boston\",\"details\":{\"zip\":\"02101\"}}" - ); - } - - #[test] - fn array_elements_direct_string_values() { - let mut acc = GoogleJsonAccumulator::new(); - let first = acc - .process_partial_args(&[s("$.steps[0]", "Step one")]) - .unwrap(); - assert_eq!(first.text_delta, "{\"steps\":[\"Step one\""); - - let second = acc - .process_partial_args(&[s("$.steps[1]", "Step two")]) - .unwrap(); - assert_eq!( - second.current_json, - json!({ "steps": ["Step one", "Step two"] }) - ); - assert_eq!(second.text_delta, ",\"Step two\""); - } - - #[test] - fn deeply_nested_paths() { - let mut acc = GoogleJsonAccumulator::new(); - let r = acc.process_partial_args(&[s("$.a.b.c.d", "deep")]).unwrap(); - assert_eq!( - r.current_json, - json!({ "a": { "b": { "c": { "d": "deep" } } } }) - ); - assert_eq!(r.text_delta, "{\"a\":{\"b\":{\"c\":{\"d\":\"deep\""); - - let f = acc.finalize(); - assert_eq!(f.closing_delta, "}}}}"); - assert_eq!(f.final_json, "{\"a\":{\"b\":{\"c\":{\"d\":\"deep\"}}}}"); - } - - // ── finalize ─────────────────────────────────────────────────────────── - - #[test] - fn finalize_continued_string() { - let mut acc = GoogleJsonAccumulator::new(); - acc.process_partial_args(&[s_continue("$.location", "Boston")]) - .unwrap(); - let f = acc.finalize(); - assert_eq!(f.closing_delta, "\"}"); - assert_eq!(f.final_json, "{\"location\":\"Boston\"}"); - } - - #[test] - fn finalize_complete_string() { - let mut acc = GoogleJsonAccumulator::new(); - acc.process_partial_args(&[s("$.location", "Boston")]) - .unwrap(); - let f = acc.finalize(); - assert_eq!(f.closing_delta, "}"); - assert_eq!(f.final_json, "{\"location\":\"Boston\"}"); - } - - #[test] - fn finalize_multiple_args() { - let mut acc = GoogleJsonAccumulator::new(); - acc.process_partial_args(&[n("$.brightness", 50.0), b("$.enabled", true)]) - .unwrap(); - let f = acc.finalize(); - assert_eq!(f.closing_delta, "}"); - assert_eq!(f.final_json, "{\"brightness\":50,\"enabled\":true}"); - } - - #[test] - fn finalize_continued_string_with_continuation() { - let mut acc = GoogleJsonAccumulator::new(); - acc.process_partial_args(&[s_continue("$.location", "Boston")]) - .unwrap(); - acc.process_partial_args(&[s("$.location", ", MA")]) - .unwrap(); - let f = acc.finalize(); - assert_eq!(f.closing_delta, "\"}"); - assert_eq!(f.final_json, "{\"location\":\"Boston, MA\"}"); - } - - #[test] - fn finalize_empty_accumulator() { - let acc = GoogleJsonAccumulator::new(); - let f = acc.finalize(); - assert_eq!(f.closing_delta, "{}"); - assert_eq!(f.final_json, "{}"); - } - - #[test] - fn finalize_nested_structure() { - let mut acc = GoogleJsonAccumulator::new(); - acc.process_partial_args(&[s("$.recipe.ingredients[0].name", "Noodles")]) - .unwrap(); - acc.process_partial_args(&[s("$.recipe.name", "Lasagna")]) - .unwrap(); - let f = acc.finalize(); - let parsed: Value = serde_json::from_str(&f.final_json).unwrap(); - assert_eq!( - parsed, - json!({ - "recipe": { - "ingredients": [{ "name": "Noodles" }], - "name": "Lasagna" - } - }) - ); - } - - #[test] - fn finalize_nested_arrays_with_string_continuation() { - let mut acc = GoogleJsonAccumulator::new(); - acc.process_partial_args(&[s_continue("$.recipe.steps[0]", "Preheat")]) - .unwrap(); - acc.process_partial_args(&[s("$.recipe.steps[0]", " oven.")]) - .unwrap(); - acc.process_partial_args(&[s("$.recipe.steps[1]", "Cook.")]) - .unwrap(); - let f = acc.finalize(); - let parsed: Value = serde_json::from_str(&f.final_json).unwrap(); - assert_eq!( - parsed, - json!({ - "recipe": { "steps": ["Preheat oven.", "Cook."] } - }) - ); - } - - // ── concatenation invariant ──────────────────────────────────────────── - - #[test] - fn flat_args_concat_invariant() { - let mut acc = GoogleJsonAccumulator::new(); - let mut deltas = Vec::new(); - - let r = acc - .process_partial_args(&[n("$.brightness", 50.0)]) - .unwrap(); - deltas.push(r.text_delta); - - let r = acc.process_partial_args(&[b("$.enabled", true)]).unwrap(); - deltas.push(r.text_delta); - - let r = acc.process_partial_args(&[s("$.name", "test")]).unwrap(); - deltas.push(r.text_delta); - - let f = acc.finalize(); - deltas.push(f.closing_delta); - assert_eq!(deltas.join(""), f.final_json); - let parsed: Value = serde_json::from_str(&f.final_json).unwrap(); - assert_eq!( - parsed, - json!({ "brightness": 50, "enabled": true, "name": "test" }) - ); - } - - #[test] - fn nested_args_concat_invariant() { - let mut acc = GoogleJsonAccumulator::new(); - let mut deltas = Vec::new(); - - let r = acc - .process_partial_args(&[s("$.recipe.ingredients[0].amount", "16 oz")]) - .unwrap(); - deltas.push(r.text_delta); - let r = acc - .process_partial_args(&[s("$.recipe.ingredients[0].name", "Noodles")]) - .unwrap(); - deltas.push(r.text_delta); - let r = acc - .process_partial_args(&[s("$.recipe.ingredients[1].amount", "1 lb")]) - .unwrap(); - deltas.push(r.text_delta); - let r = acc - .process_partial_args(&[s("$.recipe.ingredients[1].name", "Beef")]) - .unwrap(); - deltas.push(r.text_delta); - let r = acc - .process_partial_args(&[s("$.recipe.name", "Lasagna")]) - .unwrap(); - deltas.push(r.text_delta); - let r = acc - .process_partial_args(&[s_continue("$.recipe.steps[0]", "Preheat")]) - .unwrap(); - deltas.push(r.text_delta); - let r = acc - .process_partial_args(&[s("$.recipe.steps[0]", " oven.")]) - .unwrap(); - deltas.push(r.text_delta); - let r = acc - .process_partial_args(&[s("$.recipe.steps[1]", "Cook.")]) - .unwrap(); - deltas.push(r.text_delta); - - let f = acc.finalize(); - deltas.push(f.closing_delta); - assert_eq!(deltas.join(""), f.final_json); - } - - #[test] - fn will_continue_strings_concat_invariant() { - let mut acc = GoogleJsonAccumulator::new(); - let mut deltas = Vec::new(); - - let r = acc - .process_partial_args(&[s_continue("$.location", "Bos")]) - .unwrap(); - deltas.push(r.text_delta); - let r = acc.process_partial_args(&[s("$.location", "ton")]).unwrap(); - deltas.push(r.text_delta); - let r = acc.process_partial_args(&[n("$.count", 42.0)]).unwrap(); - deltas.push(r.text_delta); - - let f = acc.finalize(); - deltas.push(f.closing_delta); - assert_eq!(deltas.join(""), f.final_json); - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// Provider config (TS: google-provider.test.ts — config subset) -// ════════════════════════════════════════════════════════════════════════════ - -mod provider_config_tests { - use super::*; - - fn test_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] - } - - fn default_options(prompt: LanguageModelPrompt) -> CallOptions { - CallOptions::new(prompt) - } - - async fn mock_ok(server: &MockServer) { - Mock::given(method("POST")) - .and(path("/models/gemini-2.0-flash:generateContent")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "candidates": [{ - "content": { "parts": [{ "text": "hi" }], "role": "model" }, - "finishReason": "STOP", "index": 0 - }] - }))) - .mount(server) - .await; - } - - #[tokio::test] - async fn default_base_url_is_generativelanguage() { - let config = GoogleConfig::new("test-api-key"); - assert_eq!( - config.base_url, - "https://generativelanguage.googleapis.com/v1beta" - ); - } - - #[tokio::test] - async fn custom_base_url_is_used() { - let server = MockServer::start().await; - mock_ok(&server).await; - - let config = GoogleConfig::new("test-api-key").with_base_url(server.uri()); - let provider = GoogleProvider::new(config); - let model = provider.model("gemini-2.0-flash"); - - model - .do_generate(&default_options(test_prompt())) - .await - .expect("ok"); - } - - #[tokio::test] - async fn provider_name_is_google() { - use aimux_core::provider::Provider; - let config = GoogleConfig::new("test-api-key"); - let provider = GoogleProvider::new(config); - assert_eq!(provider.name(), "google"); - } - - #[tokio::test] - async fn model_provider_is_google_generative_ai() { - let server = MockServer::start().await; - mock_ok(&server).await; - - let config = GoogleConfig::new("test-api-key").with_base_url(server.uri()); - let provider = GoogleProvider::new(config); - let model = provider.model("gemini-2.0-flash"); - - assert_eq!(model.provider(), "google.generative-ai"); - assert_eq!(model.model_id(), "gemini-2.0-flash"); - } - - #[tokio::test] - async fn with_base_url_strips_trailing_slash() { - let config = GoogleConfig::new("key").with_base_url("https://example.com/v1beta/"); - assert_eq!(config.base_url, "https://example.com/v1beta"); - } - - #[tokio::test] - #[serial] - async fn from_env_uses_google_generative_ai_api_key() { - unsafe { - std::env::set_var("GOOGLE_GENERATIVE_AI_API_KEY", "env-api-key"); - } - let config = GoogleConfig::from_env().expect("from_env"); - assert_eq!(config.api_key, "env-api-key"); - unsafe { - std::env::remove_var("GOOGLE_GENERATIVE_AI_API_KEY"); - } - } - - #[tokio::test] - #[serial] - async fn from_env_errors_when_missing() { - unsafe { - std::env::remove_var("GOOGLE_GENERATIVE_AI_API_KEY"); - } - let result = GoogleConfig::from_env(); - assert!(result.is_err()); - } - - #[tokio::test] - async fn api_key_sent_via_x_goog_api_key_header() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/models/gemini-2.0-flash:generateContent")) - .and(header("x-goog-api-key", "my-secret-key")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "candidates": [{ - "content": { "parts": [{ "text": "hi" }], "role": "model" }, - "finishReason": "STOP", "index": 0 - }] - }))) - .mount(&server) - .await; - - let config = GoogleConfig::new("my-secret-key").with_base_url(server.uri()); - let provider = GoogleProvider::new(config); - let model = provider.model("gemini-2.0-flash"); - - model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed — the mock requires the x-goog-api-key header"); - } - - #[tokio::test] - async fn custom_headers_are_sent_alongside_api_key() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/models/gemini-2.0-flash:generateContent")) - .and(header("x-goog-api-key", "test-api-key")) - .and(header("custom-header", "custom-value")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "candidates": [{ - "content": { "parts": [{ "text": "hi" }], "role": "model" }, - "finishReason": "STOP", "index": 0 - }] - }))) - .mount(&server) - .await; - - let config = GoogleConfig::new("test-api-key").with_base_url(server.uri()); - let provider = GoogleProvider::new(config); - let model = provider.model("gemini-2.0-flash"); - - let mut opts = default_options(test_prompt()); - let mut headers = std::collections::HashMap::new(); - headers.insert("Custom-Header".to_string(), "custom-value".to_string()); - opts.headers = Some(headers); - - model - .do_generate(&opts) - .await - .expect("should succeed — mock requires both headers"); - } - - #[tokio::test] - async fn language_model_trait_method_returns_boxed_model() { - use aimux_core::provider::Provider; - - let config = GoogleConfig::new("test-api-key"); - let provider = GoogleProvider::new(config); - let model = provider.language_model("gemini-2.0-flash").unwrap(); - assert_eq!(model.model_id(), "gemini-2.0-flash"); - assert_eq!(model.provider(), "google.generative-ai"); - } -} diff --git a/aimux-providers/tests/mistral_model_test.rs b/aimux-providers/tests/mistral_model_test.rs deleted file mode 100644 index 49d45222b..000000000 --- a/aimux-providers/tests/mistral_model_test.rs +++ /dev/null @@ -1,1638 +0,0 @@ -//! Wiremock tests for the Mistral provider. -//! -//! Translated from `packages/mistral/src/mistral-chat-language-model.test.ts`, -//! focusing on the cases that the Rust data model can express: -//! - doGenerate: text extraction, usage, tool calls, request body -//! - doStream: text streaming, tool call streaming, request body - -use aimux_core::tool::RawToolCall; -use futures::StreamExt; -use serde_json::{Value, json}; -use wiremock::matchers::{method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -use aimux_core::content::ContentPart; -use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; -use aimux_core::options::{CallOptions, Tool, ToolChoice}; -use aimux_core::result::{GenerateContent, ReasoningOutput, StreamResult}; -use aimux_core::stream_part::StreamPart; -use aimux_core::tool::FunctionTool; -use aimux_core::types::{FinishReasonUnified, ResponseMetadata}; - -use aimux_providers::{MistralConfig, MistralProvider}; - -// ── helpers ───────────────────────────────────────────────────────────────── - -fn test_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] -} - -fn default_options(prompt: LanguageModelPrompt) -> CallOptions { - CallOptions::new(prompt) -} - -fn test_tool() -> Tool { - Tool::Function(FunctionTool { - name: "test-tool".to_string(), - description: None, - input_schema: json!({ - "type": "object", - "properties": { "value": { "type": "string" } }, - "required": ["value"], - "additionalProperties": false, - "$schema": "http://json-schema.org/draft-07/schema#" - }), - strict: None, - provider_options: None, - input_examples: None, - }) -} - -async fn mock_json_response(server: &MockServer, body: Value) { - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(body)) - .mount(server) - .await; -} - -async fn mock_sse_response(server: &MockServer, sse_body: &str) { - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(sse_body.to_string()), - ) - .mount(server) - .await; -} - -fn sse_body(events: &[&str]) -> String { - let mut body = String::new(); - for event in events { - body.push_str(event); - } - body.push_str("data: [DONE]\n\n"); - body -} - -/// Build an SSE body from raw JSON chunk strings — each becomes -/// `data: \n\n`. Easier than hand-writing `data:` prefixes with the -/// required blank-line terminator. -fn sse_json_body(chunks: &[&str]) -> String { - let mut body = String::new(); - for c in chunks { - body.push_str(&format!("data: {c}\n\n")); - } - body.push_str("data: [DONE]\n\n"); - body -} - -async fn collect_stream(result: StreamResult) -> Vec { - let mut parts = Vec::new(); - let mut stream = result.stream; - while let Some(part) = stream.next().await { - match part { - Ok(p) => parts.push(p), - Err(e) => panic!("stream error: {e:?}"), - } - } - parts -} - -fn text_deltas(parts: &[StreamPart]) -> Vec { - parts - .iter() - .filter_map(|p| match p { - StreamPart::TextDelta { delta, .. } => Some(delta.clone()), - _ => None, - }) - .collect() -} - -// ════════════════════════════════════════════════════════════════════════════ -// doGenerate -// ════════════════════════════════════════════════════════════════════════════ - -/// TS: "should extract text content" -#[tokio::test] -async fn should_extract_text_response() { - let server = MockServer::start().await; - mock_json_response( - &server, - json!({ - "id": "5319bd0299614c679a0068a4f2c8ffd0", - "created": 1769088720, - "model": "mistral-small-latest", - "usage": { - "prompt_tokens": 13, - "total_tokens": 447, - "completion_tokens": 434 - }, - "object": "chat.completion", - "choices": [{ - "index": 0, - "finish_reason": "stop", - "message": { - "role": "assistant", - "tool_calls": null, - "content": "Hello, World!" - } - }] - }), - ) - .await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!(result.content.len(), 1); - match &result.content[0] { - GenerateContent::Text { text, .. } => assert_eq!(text, "Hello, World!"), - other => panic!("expected Text, got {other:?}"), - } - assert_eq!(result.finish_reason.unified, FinishReasonUnified::Stop); - assert_eq!(result.finish_reason.raw.as_deref(), Some("stop")); - assert_eq!( - result.response.id.as_deref(), - Some("5319bd0299614c679a0068a4f2c8ffd0") - ); - assert_eq!( - result.response.model_id.as_deref(), - Some("mistral-small-latest") - ); -} - -/// TS: "should extract usage" -#[tokio::test] -async fn should_extract_usage() { - let server = MockServer::start().await; - mock_json_response( - &server, - json!({ - "id": "test-id", - "created": 1711115037, - "model": "mistral-small-latest", - "usage": { - "prompt_tokens": 13, - "total_tokens": 447, - "completion_tokens": 434 - }, - "object": "chat.completion", - "choices": [{ - "index": 0, - "finish_reason": "stop", - "message": { - "role": "assistant", - "content": "Hello" - } - }] - }), - ) - .await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!(result.usage.input_tokens.total, Some(13)); - assert_eq!(result.usage.input_tokens.no_cache, Some(13)); - assert_eq!(result.usage.input_tokens.cache_read, None); - assert_eq!(result.usage.output_tokens.total, Some(434)); -} - -/// TS: "should extract usage with cached tokens" -#[tokio::test] -async fn should_extract_usage_with_cached_tokens() { - let server = MockServer::start().await; - mock_json_response( - &server, - json!({ - "id": "test-id", - "model": "mistral-small-latest", - "usage": { - "prompt_tokens": 100, - "total_tokens": 200, - "completion_tokens": 100, - "num_cached_tokens": 30 - }, - "object": "chat.completion", - "choices": [{ - "index": 0, - "finish_reason": "stop", - "message": { "role": "assistant", "content": "Hi" } - }] - }), - ) - .await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!(result.usage.input_tokens.total, Some(100)); - assert_eq!(result.usage.input_tokens.no_cache, Some(70)); - assert_eq!(result.usage.input_tokens.cache_read, Some(30)); -} - -/// TS: "should extract tool call content" -#[tokio::test] -async fn should_extract_tool_call() { - let server = MockServer::start().await; - mock_json_response( - &server, - json!({ - "id": "b3999b8c93e04e11bcbff7bcab829667", - "created": 1769088854, - "model": "mistral-small-latest", - "usage": { - "prompt_tokens": 124, - "total_tokens": 146, - "completion_tokens": 22 - }, - "object": "chat.completion", - "choices": [{ - "index": 0, - "finish_reason": "tool_calls", - "message": { - "role": "assistant", - "tool_calls": [{ - "id": "gSIMJiOkT", - "function": { - "name": "weather", - "arguments": "{\"location\": \"San Francisco\"}" - } - }] - } - }] - }), - ) - .await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!(result.content.len(), 1); - match &result.content[0] { - GenerateContent::ToolCall(RawToolCall { - tool_call_id, - tool_name, - input, - .. - }) => { - assert_eq!(tool_call_id, "gSIMJiOkT"); - assert_eq!(tool_name, "weather"); - assert_eq!(input, r#"{"location": "San Francisco"}"#); - } - other => panic!("expected ToolCall, got {other:?}"), - } - assert_eq!(result.finish_reason.unified, FinishReasonUnified::ToolCalls); -} - -/// TS: "should send correct request body" -#[tokio::test] -async fn should_send_correct_request_body() { - let server = MockServer::start().await; - mock_json_response( - &server, - json!({ - "id": "test-id", - "model": "mistral-small-latest", - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 }, - "object": "chat.completion", - "choices": [{ - "index": 0, - "finish_reason": "stop", - "message": { "role": "assistant", "content": "Hello" } - }] - }), - ) - .await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - // Verify the request body. - let request_body = result.request_body.expect("request body should be set"); - assert_eq!(request_body["model"], "mistral-small-latest"); - assert_eq!( - request_body["messages"], - json!([{ "role": "user", "content": [{ "type": "text", "text": "Hello" }] }]) - ); - // No extra keys should be present. - assert!(request_body.get("max_tokens").is_none()); - assert!(request_body.get("temperature").is_none()); - assert!(request_body.get("tools").is_none()); -} - -/// TS: "should pass tools and toolChoice" — Mistral maps `required` → `"any"`. -#[tokio::test] -async fn should_pass_tools_with_tool_choice_required() { - let server = MockServer::start().await; - mock_json_response( - &server, - json!({ - "id": "test-id", - "model": "mistral-small-latest", - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 }, - "object": "chat.completion", - "choices": [{ - "index": 0, - "finish_reason": "stop", - "message": { "role": "assistant", "content": "ok" } - }] - }), - ) - .await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let options = CallOptions { - prompt: test_prompt(), - tools: Some(vec![test_tool()]), - tool_choice: ToolChoice::Required, - ..default_options(Vec::new()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - - assert_eq!( - body["tool_choice"], - json!("any"), - "Mistral uses 'any' for required tool choice" - ); - assert_eq!(body["tools"][0]["function"]["name"], "test-tool"); -} - -/// TS: "should pass tools and toolChoice" — `tool` choice filters tools. -#[tokio::test] -async fn should_pass_tools_with_tool_choice_tool() { - let server = MockServer::start().await; - mock_json_response( - &server, - json!({ - "id": "test-id", - "model": "mistral-small-latest", - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 }, - "object": "chat.completion", - "choices": [{ - "index": 0, - "finish_reason": "stop", - "message": { "role": "assistant", "content": "ok" } - }] - }), - ) - .await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let options = CallOptions { - prompt: test_prompt(), - tools: Some(vec![test_tool()]), - tool_choice: ToolChoice::Tool { - tool_name: "test-tool".to_string(), - }, - ..default_options(Vec::new()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - - assert_eq!(body["tool_choice"], json!("any")); - assert_eq!(body["tools"].as_array().unwrap().len(), 1); - assert_eq!(body["tools"][0]["function"]["name"], "test-tool"); -} - -/// TS: "should forward stopSequences as the Mistral stop parameter" -#[tokio::test] -async fn should_forward_stop_sequences() { - let server = MockServer::start().await; - mock_json_response( - &server, - json!({ - "id": "test-id", - "model": "mistral-small-latest", - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 }, - "object": "chat.completion", - "choices": [{ - "index": 0, - "finish_reason": "stop", - "message": { "role": "assistant", "content": "ok" } - }] - }), - ) - .await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let options = CallOptions { - prompt: test_prompt(), - stop_sequences: Some(vec!["foo".to_string(), "bar".to_string()]), - ..default_options(Vec::new()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - - assert_eq!(body["stop"], json!(["foo", "bar"])); -} - -/// TS: "should map model_length finish reason to length" -#[tokio::test] -async fn should_map_model_length_finish_reason() { - let server = MockServer::start().await; - mock_json_response( - &server, - json!({ - "id": "test-id", - "model": "mistral-small-latest", - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 }, - "object": "chat.completion", - "choices": [{ - "index": 0, - "finish_reason": "model_length", - "message": { "role": "assistant", "content": "ok" } - }] - }), - ) - .await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!(result.finish_reason.unified, FinishReasonUnified::Length); - assert_eq!(result.finish_reason.raw.as_deref(), Some("model_length")); -} - -// ════════════════════════════════════════════════════════════════════════════ -// doStream -// ════════════════════════════════════════════════════════════════════════════ - -/// TS: "should stream text" -#[tokio::test] -async fn should_stream_text() { - let server = MockServer::start().await; - let sse = sse_body(&[ - r#"data: {"id":"5319bd0299614c679a0068a4f2c8ffd0","object":"chat.completion.chunk","created":1769088720,"model":"mistral-small-latest","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null,"logprobs":null}]} - -"#, - r#"data: {"id":"5319bd0299614c679a0068a4f2c8ffd0","object":"chat.completion.chunk","created":1769088720,"model":"mistral-small-latest","choices":[{"index":0,"delta":{"content":"Hello"},"finish_reason":null,"logprobs":null}]} - -"#, - r#"data: {"id":"5319bd0299614c679a0068a4f2c8ffd0","object":"chat.completion.chunk","created":1769088720,"model":"mistral-small-latest","choices":[{"index":0,"delta":{"content":", world!"},"finish_reason":null,"logprobs":null}]} - -"#, - r#"data: {"id":"5319bd0299614c679a0068a4f2c8ffd0","object":"chat.completion.chunk","created":1769088720,"model":"mistral-small-latest","choices":[{"index":0,"delta":{"content":""},"finish_reason":"stop","logprobs":null}],"usage":{"prompt_tokens":13,"total_tokens":21,"completion_tokens":8}} - -"#, - ]); - mock_sse_response(&server, &sse).await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - - let parts = collect_stream(result).await; - - // StreamStart, ResponseMetadata, TextStart, TextDelta x2, TextEnd, Finish. - assert!(matches!( - parts.first(), - Some(StreamPart::StreamStart { .. }) - )); - - let deltas = text_deltas(&parts); - assert_eq!(deltas, vec!["Hello", ", world!"]); - - // Check finish reason and usage. - let finish = parts.last().expect("should have finish"); - match finish { - StreamPart::Finish { - finish_reason, - usage, - .. - } => { - assert_eq!(finish_reason.unified, FinishReasonUnified::Stop); - assert_eq!(usage.input_tokens.total, Some(13)); - assert_eq!(usage.output_tokens.total, Some(8)); - } - other => panic!("expected Finish, got {other:?}"), - } -} - -/// TS: "should stream tool call" -#[tokio::test] -async fn should_stream_tool_call() { - let server = MockServer::start().await; - let sse = sse_body(&[ - r#"data: {"id":"b3999b8c93e04e11bcbff7bcab829667","object":"chat.completion.chunk","created":1769088854,"model":"mistral-small-latest","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null,"logprobs":null}]} - -"#, - r#"data: {"id":"b3999b8c93e04e11bcbff7bcab829667","object":"chat.completion.chunk","created":1769088854,"model":"mistral-small-latest","choices":[{"index":0,"delta":{"content":null,"tool_calls":[{"id":"gSIMJiOkT","function":{"name":"weather","arguments":"{\"location\": \"San Francisco\"}"}}]},"finish_reason":"tool_calls","logprobs":null}],"usage":{"prompt_tokens":124,"total_tokens":146,"completion_tokens":22}} - -"#, - ]); - mock_sse_response(&server, &sse).await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - - let parts = collect_stream(result).await; - - // Find the ToolCall part. - let tool_call = parts.iter().find_map(|p| match p { - StreamPart::ToolCall(RawToolCall { - tool_call_id, - tool_name, - input, - .. - }) => Some((tool_call_id, tool_name, input)), - _ => None, - }); - let (id, name, input) = tool_call.expect("should have a ToolCall"); - assert_eq!(id, "gSIMJiOkT"); - assert_eq!(name, "weather"); - assert_eq!(input, r#"{"location": "San Francisco"}"#); - - // Should also have ToolInputStart, ToolInputDelta, ToolInputEnd. - assert!( - parts - .iter() - .any(|p| matches!(p, StreamPart::ToolInputStart { .. })) - ); - assert!( - parts - .iter() - .any(|p| matches!(p, StreamPart::ToolInputDelta { .. })) - ); - assert!( - parts - .iter() - .any(|p| matches!(p, StreamPart::ToolInputEnd { .. })) - ); -} - -/// TS: "should pass the messages" (streaming request body). -#[tokio::test] -async fn should_send_streaming_request_body() { - let server = MockServer::start().await; - mock_sse_response( - &server, - &sse_body(&[ - r#"data: {"id":"test","model":"mistral-small-latest","choices":[{"index":0,"delta":{"content":"Hi"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"total_tokens":2,"completion_tokens":1}} - -"#, - ]), - ) - .await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - - let body = result.request_body.expect("body"); - assert_eq!(body["model"], "mistral-small-latest"); - assert_eq!(body["stream"], json!(true)); - assert_eq!( - body["messages"], - json!([{ "role": "user", "content": [{ "type": "text", "text": "Hello" }] }]) - ); - // No stream_options (Mistral doesn't use it). - assert!(body.get("stream_options").is_none()); -} - -/// TS: "should stream text with content objects" (array content format) -#[tokio::test] -async fn should_stream_text_with_array_content() { - let server = MockServer::start().await; - let sse = sse_body(&[ - r#"data: {"id":"b9e43f82d6c74a1e9f5b2c8e7a9d4f6b","object":"chat.completion.chunk","created":1750538500,"model":"mistral-small-latest","choices":[{"index":0,"delta":{"role":"assistant","content":[{"type":"text","text":""}]},"finish_reason":null,"logprobs":null}]} - -"#, - r#"data: {"id":"b9e43f82d6c74a1e9f5b2c8e7a9d4f6b","object":"chat.completion.chunk","created":1750538500,"model":"mistral-small-latest","choices":[{"index":0,"delta":{"content":[{"type":"text","text":"Hello"}]},"finish_reason":null,"logprobs":null}]} - -"#, - r#"data: {"id":"b9e43f82d6c74a1e9f5b2c8e7a9d4f6b","object":"chat.completion.chunk","created":1750538500,"model":"mistral-small-latest","choices":[{"index":0,"delta":{"content":[{"type":"text","text":", world!"}]},"finish_reason":"stop","logprobs":null}],"usage":{"prompt_tokens":4,"total_tokens":36,"completion_tokens":32}} - -"#, - ]); - mock_sse_response(&server, &sse).await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - - let parts = collect_stream(result).await; - let deltas = text_deltas(&parts); - assert_eq!(deltas, vec!["Hello", ", world!"]); -} - -/// TS: "should handle 401 auth error" -#[tokio::test] -async fn should_handle_auth_error() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with( - ResponseTemplate::new(401) - .set_body_json(json!({ "message": "Invalid API key", "type": "auth_error" })), - ) - .mount(&server) - .await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let result = model.do_generate(&default_options(test_prompt())).await; - - assert!(result.is_err()); - let err = result.unwrap_err(); - assert_eq!(err.status_code(), Some(401), "got {err:?}"); - assert!(err.to_string().contains("Invalid API key")); -} - -// ════════════════════════════════════════════════════════════════════════════ -// Additional doGenerate cases — request construction, headers, penalties, -// response_format, parallel_tool_calls, content-shape variants, errors. -// ════════════════════════════════════════════════════════════════════════════ - -/// A minimal "ok" chat-completion body reused by request-body assertions. -fn ok_text_body() -> Value { - json!({ - "id": "test-id", - "model": "mistral-small-latest", - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 }, - "object": "chat.completion", - "choices": [{ - "index": 0, - "finish_reason": "stop", - "message": { "role": "assistant", "content": "ok" } - }] - }) -} - -/// TS: "should forward presencePenalty and frequencyPenalty without unsupported warnings" -#[tokio::test] -async fn should_forward_presence_and_frequency_penalty() { - let server = MockServer::start().await; - mock_json_response(&server, ok_text_body()).await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let options = CallOptions { - prompt: test_prompt(), - presence_penalty: Some(0.1), - frequency_penalty: Some(0.2), - ..default_options(Vec::new()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - - // f32 round-trip: compare with tolerance rather than exact equality. - let pp = body["presence_penalty"].as_f64().unwrap(); - let fp = body["frequency_penalty"].as_f64().unwrap(); - assert!((pp - 0.1).abs() < 1e-6, "presence_penalty={pp}"); - assert!((fp - 0.2).abs() < 1e-6, "frequency_penalty={fp}"); -} - -/// TS: "should pass headers" — request-level custom headers reach the server. -#[tokio::test] -async fn should_pass_request_headers() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .and(wiremock::matchers::header( - "custom-request-header", - "request-header-value", - )) - .respond_with(ResponseTemplate::new(200).set_body_json(ok_text_body())) - .mount(&server) - .await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let mut headers = std::collections::HashMap::new(); - headers.insert( - "Custom-Request-Header".to_string(), - "request-header-value".to_string(), - ); - - let options = CallOptions { - prompt: test_prompt(), - headers: Some(headers), - ..default_options(Vec::new()) - }; - - let result = model.do_generate(&options).await; - assert!(result.is_ok(), "request should match the header mock"); -} - -/// TS: "should expose the raw response headers" -#[tokio::test] -async fn should_expose_raw_response_headers() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("test-header", "test-value") - .set_body_json(ok_text_body()), - ) - .mount(&server) - .await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - let headers = result - .response_headers - .as_ref() - .expect("response_headers should be Some"); - assert_eq!(headers.get("test-header"), Some(&"test-value".to_string())); -} - -/// TS: "should send additional response information" (id, modelId) -#[tokio::test] -async fn should_send_additional_response_information() { - let server = MockServer::start().await; - mock_json_response(&server, ok_text_body()).await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - assert_eq!(result.response.id.as_deref(), Some("test-id")); - assert_eq!( - result.response.model_id.as_deref(), - Some("mistral-small-latest") - ); -} - -/// TS: "should inject JSON instruction for JSON response format" — Rust sets -/// `response_format: { type: "json_object" }` (no schema). The Rust impl does -/// not inject a system message, so we assert only the response_format field. -#[tokio::test] -async fn should_set_json_object_response_format() { - let server = MockServer::start().await; - mock_json_response(&server, ok_text_body()).await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let options = CallOptions { - prompt: test_prompt(), - response_format: Some(aimux_core::options::ResponseFormat::Json { - schema: None, - name: None, - description: None, - }), - ..default_options(Vec::new()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - assert_eq!(body["response_format"], json!({ "type": "json_object" })); -} - -/// TS: "should inject JSON instruction for JSON response format with schema" -#[tokio::test] -async fn should_set_json_schema_response_format() { - let server = MockServer::start().await; - mock_json_response(&server, ok_text_body()).await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let schema = json!({ - "type": "object", - "properties": { "name": { "type": "string" } } - }); - let options = CallOptions { - prompt: test_prompt(), - response_format: Some(aimux_core::options::ResponseFormat::Json { - schema: Some(schema.clone()), - name: None, - description: None, - }), - ..default_options(Vec::new()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - assert_eq!(body["response_format"]["type"], json!("json_schema")); - assert_eq!(body["response_format"]["json_schema"]["schema"], schema); - assert_eq!( - body["response_format"]["json_schema"]["name"], - json!("response") - ); - assert_eq!( - body["response_format"]["json_schema"]["strict"], - json!(false) - ); -} - -/// TS: "should avoid duplication when trailing assistant message" — continuation -/// mode sets `prefix: true` on the trailing assistant message. -#[tokio::test] -async fn should_mark_trailing_assistant_message_as_prefix() { - let server = MockServer::start().await; - mock_json_response(&server, ok_text_body()).await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let prompt: LanguageModelPrompt = vec![ - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::text("prefix ")], - ..Default::default() - }, - ]; - - let result = model - .do_generate(&default_options(prompt)) - .await - .expect("should succeed"); - let body = result.request_body.expect("body"); - let messages = body["messages"].as_array().expect("messages array"); - let last = messages.last().expect("last message"); - assert_eq!(last["role"], json!("assistant")); - assert_eq!(last["content"], json!("prefix ")); - assert_eq!(last["prefix"], json!(true)); -} - -/// TS: "should extract content when message content is a content object" -#[tokio::test] -async fn should_extract_content_when_message_content_is_object() { - let server = MockServer::start().await; - mock_json_response( - &server, - json!({ - "object": "chat.completion", - "id": "object-id", - "created": 1711113008, - "model": "mistral-small-latest", - "choices": [{ - "index": 0, - "message": { - "role": "assistant", - "content": [{ "type": "text", "text": "Hello from object" }], - "tool_calls": null - }, - "finish_reason": "stop", - "logprobs": null - }], - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 } - }), - ) - .await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - assert_eq!(result.content.len(), 1); - match &result.content[0] { - GenerateContent::Text { text, .. } => assert_eq!(text, "Hello from object"), - other => panic!("expected Text, got {other:?}"), - } -} - -/// TS: "should preserve ordering of mixed thinking and text". -/// -/// Thinking parts become a `Reasoning` item, pushed ahead of the text to match -/// the upstream ordering; the text parts are joined as before. -#[tokio::test] -async fn should_extract_text_from_mixed_thinking_and_text() { - let server = MockServer::start().await; - mock_json_response( - &server, - json!({ - "id": "mixed-content-test", - "object": "chat.completion", - "created": 1722349660, - "model": "magistral-medium-2507", - "choices": [{ - "index": 0, - "message": { - "role": "assistant", - "content": [ - { "type": "thinking", "thinking": [{ "type": "text", "text": "First thought." }] }, - { "type": "text", "text": "Partial answer." }, - { "type": "thinking", "thinking": [{ "type": "text", "text": "Second thought." }] }, - { "type": "text", "text": "Final answer." } - ] - }, - "finish_reason": "stop" - }], - "usage": { "prompt_tokens": 10, "total_tokens": 30, "completion_tokens": 20 } - }), - ) - .await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("magistral-medium-2507"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - // Reasoning first, then the joined text. - assert_eq!(result.content.len(), 2); - match &result.content[0] { - GenerateContent::Reasoning(ReasoningOutput { text, .. }) => { - assert_eq!( - text, "First thought.Second thought.", - "both thinking parts must survive, in order" - ); - } - other => panic!("expected Reasoning at [0], got {other:?}"), - } - match &result.content[1] { - GenerateContent::Text { text, .. } => { - assert_eq!(text, "Partial answer.Final answer."); - } - other => panic!("expected Text at [1], got {other:?}"), - } -} - -/// TS: "should handle empty thinking content" — empty thinking is skipped. -#[tokio::test] -async fn should_handle_empty_thinking_content() { - let server = MockServer::start().await; - mock_json_response( - &server, - json!({ - "id": "empty-thinking-test", - "object": "chat.completion", - "created": 1722349660, - "model": "magistral-medium-2507", - "choices": [{ - "index": 0, - "message": { - "role": "assistant", - "content": [ - { "type": "thinking", "thinking": [] }, - { "type": "text", "text": "Just the answer." } - ] - }, - "finish_reason": "stop" - }], - "usage": { "prompt_tokens": 10, "total_tokens": 30, "completion_tokens": 20 } - }), - ) - .await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("magistral-medium-2507"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - assert_eq!(result.content.len(), 1); - match &result.content[0] { - GenerateContent::Text { text, .. } => assert_eq!(text, "Just the answer."), - other => panic!("expected Text, got {other:?}"), - } -} - -/// TS: "should return raw text with think tags" -#[tokio::test] -async fn should_return_raw_text_with_think_tags() { - let server = MockServer::start().await; - let raw = "Let me think.\n\n\nHello! I'm ready to help."; - mock_json_response( - &server, - json!({ - "object": "chat.completion", - "id": "raw-think-id", - "created": 1711113008, - "model": "magistral-small-2506", - "choices": [{ - "index": 0, - "message": { - "role": "assistant", - "content": raw, - "tool_calls": null - }, - "finish_reason": "stop", - "logprobs": null - }], - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 } - }), - ) - .await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("magistral-small-2506"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - assert_eq!(result.content.len(), 1); - match &result.content[0] { - GenerateContent::Text { text, .. } => assert_eq!(text, raw), - other => panic!("expected Text, got {other:?}"), - } -} - -/// TS: "should map content_filter finish reason" -#[tokio::test] -async fn should_map_content_filter_finish_reason() { - let server = MockServer::start().await; - mock_json_response( - &server, - json!({ - "id": "test-id", - "model": "mistral-small-latest", - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 }, - "object": "chat.completion", - "choices": [{ - "index": 0, - "finish_reason": "content_filter", - "message": { "role": "assistant", "content": "ok" } - }] - }), - ) - .await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - assert_eq!( - result.finish_reason.unified, - FinishReasonUnified::ContentFilter - ); - assert_eq!(result.finish_reason.raw.as_deref(), Some("content_filter")); -} - -/// TS: a single response with several tool calls yields multiple ToolCall -/// entries in order. -#[tokio::test] -async fn should_extract_multiple_tool_calls() { - let server = MockServer::start().await; - mock_json_response( - &server, - json!({ - "id": "multi-tc", - "model": "mistral-small-latest", - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 }, - "object": "chat.completion", - "choices": [{ - "index": 0, - "finish_reason": "tool_calls", - "message": { - "role": "assistant", - "tool_calls": [ - { "id": "call-1", "function": { "name": "weather", "arguments": "{\"city\": \"SF\"}" } }, - { "id": "call-2", "function": { "name": "time", "arguments": "{\"zone\": \"PST\"}" } } - ] - } - }] - }), - ) - .await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - assert_eq!(result.content.len(), 2); - match &result.content[0] { - GenerateContent::ToolCall(RawToolCall { - tool_call_id, - tool_name, - input, - .. - }) => { - assert_eq!(tool_call_id, "call-1"); - assert_eq!(tool_name, "weather"); - assert_eq!(input, r#"{"city": "SF"}"#); - } - other => panic!("expected ToolCall, got {other:?}"), - } - match &result.content[1] { - GenerateContent::ToolCall(RawToolCall { - tool_call_id, - tool_name, - input, - .. - }) => { - assert_eq!(tool_call_id, "call-2"); - assert_eq!(tool_name, "time"); - assert_eq!(input, r#"{"zone": "PST"}"#); - } - other => panic!("expected ToolCall, got {other:?}"), - } - assert_eq!(result.finish_reason.unified, FinishReasonUnified::ToolCalls); -} - -/// TS: "should send request body" — verify all standard optional fields. -#[tokio::test] -async fn should_send_default_request_body_shape() { - let server = MockServer::start().await; - mock_json_response(&server, ok_text_body()).await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let options = CallOptions { - prompt: test_prompt(), - max_output_tokens: Some(100), - temperature: Some(0.7), - top_p: Some(0.9), - seed: Some(42), - ..default_options(Vec::new()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - assert_eq!(body["model"], "mistral-small-latest"); - assert_eq!(body["max_tokens"], json!(100)); - // f32 round-trip: compare with tolerance. - assert!((body["temperature"].as_f64().unwrap() - 0.7).abs() < 1e-6); - assert!((body["top_p"].as_f64().unwrap() - 0.9).abs() < 1e-6); - assert_eq!(body["random_seed"], json!(42)); - assert_eq!( - body["messages"], - json!([{ "role": "user", "content": [{ "type": "text", "text": "Hello" }] }]) - ); - assert!(body.get("stop").is_none()); - assert!(body.get("tools").is_none()); - assert!(body.get("tool_choice").is_none()); - assert!(body.get("response_format").is_none()); - assert!(body.get("stream").is_none()); -} - -/// TS: a 429 response maps to `AiMuxError::ApiCall` (429 in `status_code`). -#[tokio::test] -async fn should_handle_rate_limit_error() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with( - ResponseTemplate::new(429) - .set_body_json(json!({ "message": "Too many requests", "type": "rate_limit" })), - ) - .mount(&server) - .await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let result = model.do_generate(&default_options(test_prompt())).await; - assert!( - matches!(result, Err(ref e) if e.status_code() == Some(429)), - "expected a 429, got {result:?}" - ); -} - -/// TS: a 404 response maps to `AiMuxError::ApiCall` (404 in `status_code`). -#[tokio::test] -async fn should_handle_model_not_found_error() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with( - ResponseTemplate::new(404) - .set_body_json(json!({ "message": "Model not found", "type": "not_found" })), - ) - .mount(&server) - .await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let result = model.do_generate(&default_options(test_prompt())).await; - assert!( - matches!(result, Err(ref e) if e.status_code() == Some(404)), - "expected ModelNotFound, got {result:?}" - ); -} - -// ════════════════════════════════════════════════════════════════════════════ -// Additional doStream cases — reasoning, interleaved thinking, headers, -// response metadata, error-in-chunk, prefix continuation. -// ════════════════════════════════════════════════════════════════════════════ - -/// Extract reasoning deltas from a list of stream parts. -fn reasoning_deltas(parts: &[StreamPart]) -> Vec { - parts - .iter() - .filter_map(|p| match p { - StreamPart::ReasoningDelta { delta, .. } => Some(delta.clone()), - _ => None, - }) - .collect() -} - -/// TS: "should stream reasoning" — thinking parts in array content become -/// ReasoningStart/Delta/End stream parts. -#[tokio::test] -async fn should_stream_reasoning() { - let server = MockServer::start().await; - let sse = sse_json_body(&[ - r#"{"id":"reasoning-1","object":"chat.completion.chunk","created":1750538000,"model":"magistral-small-2507","choices":[{"index":0,"delta":{"role":"assistant","content":[{"type":"thinking","thinking":[{"type":"text","text":"Let me think."}]}]},"finish_reason":null}]}"#, - r#"{"id":"reasoning-1","object":"chat.completion.chunk","created":1750538000,"model":"magistral-small-2507","choices":[{"index":0,"delta":{"content":[{"type":"text","text":"Answer."}]},"finish_reason":"stop"}],"usage":{"prompt_tokens":5,"total_tokens":15,"completion_tokens":10}}"#, - ]); - mock_sse_response(&server, &sse).await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("magistral-small-2507"); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - - let parts = collect_stream(result).await; - assert_eq!(reasoning_deltas(&parts), vec!["Let me think.".to_string()]); - assert_eq!(text_deltas(&parts), vec!["Answer.".to_string()]); - assert!( - parts - .iter() - .any(|p| matches!(p, StreamPart::ReasoningStart { .. })) - ); - assert!( - parts - .iter() - .any(|p| matches!(p, StreamPart::ReasoningEnd { .. })) - ); -} - -/// TS: "should handle interleaved thinking and text" -#[tokio::test] -async fn should_stream_interleaved_thinking_and_text() { - let server = MockServer::start().await; - let sse = sse_json_body(&[ - r#"{"id":"interleaved-test","object":"chat.completion.chunk","created":1750538000,"model":"magistral-small-2507","choices":[{"index":0,"delta":{"role":"assistant","content":[{"type":"thinking","thinking":[{"type":"text","text":"First thought."}]}]},"finish_reason":null}]}"#, - r#"{"id":"interleaved-test","object":"chat.completion.chunk","created":1750538000,"model":"magistral-small-2507","choices":[{"index":0,"delta":{"role":"assistant","content":[{"type":"text","text":"Partial answer."}]},"finish_reason":null}]}"#, - r#"{"id":"interleaved-test","object":"chat.completion.chunk","created":1750538000,"model":"magistral-small-2507","choices":[{"index":0,"delta":{"role":"assistant","content":[{"type":"thinking","thinking":[{"type":"text","text":"Second thought."}]}]},"finish_reason":null}]}"#, - r#"{"id":"interleaved-test","object":"chat.completion.chunk","created":1750538000,"model":"magistral-small-2507","choices":[{"index":0,"delta":{"role":"assistant","content":[{"type":"text","text":"Final answer."}]},"finish_reason":null}]}"#, - r#"{"id":"interleaved-test","object":"chat.completion.chunk","created":1750538000,"model":"magistral-small-2507","choices":[{"index":0,"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"total_tokens":40,"completion_tokens":30}}"#, - ]); - mock_sse_response(&server, &sse).await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("magistral-small-2507"); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - - let parts = collect_stream(result).await; - assert_eq!( - reasoning_deltas(&parts), - vec!["First thought.".to_string(), "Second thought.".to_string()] - ); - assert_eq!( - text_deltas(&parts), - vec!["Partial answer.".to_string(), "Final answer.".to_string()] - ); - assert_eq!( - parts - .iter() - .filter(|p| matches!(p, StreamPart::ReasoningStart { .. })) - .count(), - 2 - ); - assert_eq!( - parts - .iter() - .filter(|p| matches!(p, StreamPart::ReasoningEnd { .. })) - .count(), - 2 - ); -} - -/// TS: "should expose the raw response headers" (streaming) -#[tokio::test] -async fn should_expose_raw_response_headers_stream() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .insert_header("test-header", "test-value") - .set_body_string(sse_json_body(&[ - r#"{"id":"test","model":"mistral-small-latest","choices":[{"index":0,"delta":{"content":"Hi"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"total_tokens":2,"completion_tokens":1}}"#, - ])), - ) - .mount(&server) - .await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - - let headers = result - .response_headers - .as_ref() - .expect("response_headers should be Some"); - assert_eq!(headers.get("test-header"), Some(&"test-value".to_string())); -} - -/// TS: "should pass headers" (streaming) — request-level custom headers. -#[tokio::test] -async fn should_pass_request_headers_stream() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .and(wiremock::matchers::header("custom-stream-header", "stream-value")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(sse_json_body(&[ - r#"{"id":"test","model":"mistral-small-latest","choices":[{"index":0,"delta":{"content":"Hi"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"total_tokens":2,"completion_tokens":1}}"#, - ])), - ) - .mount(&server) - .await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let mut headers = std::collections::HashMap::new(); - headers.insert( - "Custom-Stream-Header".to_string(), - "stream-value".to_string(), - ); - - let options = CallOptions { - prompt: test_prompt(), - headers: Some(headers), - ..default_options(Vec::new()) - }; - - let result = model.do_stream(&options).await; - assert!(result.is_ok(), "request should match the header mock"); -} - -/// TS: streaming ResponseMetadata carries the chunk id and model id. -#[tokio::test] -async fn should_stream_response_metadata() { - let server = MockServer::start().await; - mock_sse_response( - &server, - &sse_json_body(&[ - r#"{"id":"meta-id","object":"chat.completion.chunk","created":1750538000,"model":"mistral-small-latest","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}"#, - r#"{"id":"meta-id","object":"chat.completion.chunk","created":1750538000,"model":"mistral-small-latest","choices":[{"index":0,"delta":{"content":"Hi"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"total_tokens":2,"completion_tokens":1}}"#, - ]), - ) - .await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - - let parts = collect_stream(result).await; - let meta = parts.iter().find_map(|p| match p { - StreamPart::ResponseMetadata(ResponseMetadata { id, model_id, .. }) => { - Some((id.clone(), model_id.clone())) - } - _ => None, - }); - let (id, model_id) = meta.expect("should have ResponseMetadata"); - assert_eq!(id.as_deref(), Some("meta-id")); - assert_eq!(model_id.as_deref(), Some("mistral-small-latest")); -} - -/// TS: a 429 HTTP response surfaces as `AiMuxError::ApiCall` (429 in `status_code`) from -/// `do_stream` (the stream is never opened). -#[tokio::test] -async fn should_stream_rate_limit_error() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with( - ResponseTemplate::new(429) - .set_body_json(json!({ "message": "Too many requests", "type": "rate_limit" })), - ) - .mount(&server) - .await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let result = model.do_stream(&default_options(test_prompt())).await; - assert!( - matches!(result, Err(ref e) if e.status_code() == Some(429)), - "expected a 429, got {result:?}" - ); -} - -/// TS: an `error` object embedded in a non-first SSE chunk surfaces as a -/// stream `Error` part. -#[tokio::test] -async fn should_stream_error_in_chunk() { - let server = MockServer::start().await; - let sse = sse_json_body(&[ - r#"{"id":"ok","object":"chat.completion.chunk","created":1750538000,"model":"mistral-small-latest","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}"#, - r#"{"error":{"message":"rate limited","code":429}}"#, - ]); - mock_sse_response(&server, &sse).await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - - let parts = collect_stream(result).await; - assert!( - parts.iter().any(|p| matches!( - p, - StreamPart::Error { error } if error.status_code() == Some(429) - )), - "expected a 429 stream error, got {parts:?}" - ); -} - -/// TS: "should avoid duplication when trailing assistant message" (streaming) -/// — the trailing assistant message is sent with `prefix: true`. -#[tokio::test] -async fn should_stream_with_trailing_assistant_prefix() { - let server = MockServer::start().await; - mock_sse_response( - &server, - &sse_json_body(&[ - r#"{"id":"53ff663126294946a6b7a4747b70597e","object":"chat.completion.chunk","created":1750537996,"model":"mistral-small-latest","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null,"logprobs":null}]}"#, - r#"{"id":"53ff663126294946a6b7a4747b70597e","object":"chat.completion.chunk","created":1750537996,"model":"mistral-small-latest","choices":[{"index":0,"delta":{"role":"assistant","content":"prefix"},"finish_reason":null,"logprobs":null}]}"#, - r#"{"id":"53ff663126294946a6b7a4747b70597e","object":"chat.completion.chunk","created":1750537996,"model":"mistral-small-latest","choices":[{"index":0,"delta":{"content":" and more content"},"finish_reason":"stop","logprobs":null}],"usage":{"prompt_tokens":4,"total_tokens":36,"completion_tokens":32}}"#, - ]), - ) - .await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let prompt: LanguageModelPrompt = vec![ - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::text("prefix ")], - ..Default::default() - }, - ]; - - let result = model - .do_stream(&default_options(prompt)) - .await - .expect("do_stream should succeed"); - - let body = result.request_body.clone().expect("body"); - let messages = body["messages"].as_array().expect("messages array"); - let last = messages.last().expect("last message"); - assert_eq!(last["role"], json!("assistant")); - assert_eq!(last["prefix"], json!(true)); - - let parts = collect_stream(result).await; - assert_eq!( - text_deltas(&parts), - vec!["prefix".to_string(), " and more content".to_string()] - ); -} diff --git a/aimux-providers/tests/mistral_remaining_test.rs b/aimux-providers/tests/mistral_remaining_test.rs deleted file mode 100644 index b06ba2332..000000000 --- a/aimux-providers/tests/mistral_remaining_test.rs +++ /dev/null @@ -1,860 +0,0 @@ -//! Remaining Mistral tests translated from TS that are NOT already covered by -//! `mistral_model_test.rs`. Covers: -//! -//! - `mistral-prepare-tools.test.ts` — all 8 cases (prepare_tools unit tests) -//! - `convert-to-mistral-chat-messages.test.ts` — 12 cases (message conversion -//! unit tests): system, user text/image/file, assistant text/tool-call, -//! prefix continuation, tool results -//! - `convert-mistral-usage.test.ts` — 4 cases (usage conversion via doGenerate): -//! alternate cached-token spellings, zero cached, missing tokens -//! - `mistral-chat-language-model.test.ts` — uncovered scenarios: -//! parse_finish_reason unit tests (6), build_request_body unit tests (7): -//! stream flag, provider tool silently dropped, tool with description/strict, -//! json_schema with name+description - -use base64::Engine; -use serde_json::{Value, json}; -use wiremock::matchers::{method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -use aimux_core::content::ContentPart; -use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; -use aimux_core::options::{CallOptions, ResponseFormat, Tool, ToolChoice}; -use aimux_core::tool::{FunctionTool, ProviderTool}; -use aimux_core::types::FinishReasonUnified; - -use aimux_providers::mistral::convert::{ - build_request_body, convert_prompt_to_mistral_messages, parse_finish_reason, prepare_tools, -}; -use aimux_providers::{MistralConfig, MistralProvider}; - -// ── helpers ───────────────────────────────────────────────────────────────── - -fn b64(data: &[u8]) -> String { - base64::engine::general_purpose::STANDARD.encode(data) -} - -fn default_options(prompt: LanguageModelPrompt) -> CallOptions { - CallOptions::new(prompt) -} - -fn test_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] -} - -fn basic_function_tool() -> FunctionTool { - FunctionTool::new( - "testFunction", - json!({ "type": "object", "properties": {} }), - ) - .with_description("test description") -} - -async fn mock_json_response(server: &MockServer, body: Value) { - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(body)) - .mount(server) - .await; -} - -fn ok_mistral_body() -> Value { - json!({ - "id": "test-id", - "model": "mistral-small-latest", - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 }, - "object": "chat.completion", - "choices": [{ - "index": 0, - "finish_reason": "stop", - "message": { "role": "assistant", "content": "ok" } - }] - }) -} - -// ════════════════════════════════════════════════════════════════════════════ -// mistral-prepare-tools.test.ts -// ════════════════════════════════════════════════════════════════════════════ - -/// TS: "should return undefined tools when no tools are provided" -#[test] -fn prepare_tools_empty_returns_none() { - let result = prepare_tools(&Some(vec![]), None); - assert!(result.tools.is_none()); - assert!(result.tool_choice.is_none()); - assert!(result.tool_warnings.is_empty()); -} - -/// TS: "should process function tools correctly" -#[test] -fn prepare_tools_function_tool() { - let result = prepare_tools(&Some(vec![basic_function_tool()]), None); - assert_eq!( - result.tools, - Some(vec![json!({ - "type": "function", - "function": { - "name": "testFunction", - "description": "test description", - "parameters": { "type": "object", "properties": {} } - } - })]) - ); - assert!(result.tool_choice.is_none()); - assert!(result.tool_warnings.is_empty()); -} - -/// TS: "should include strict field when set" -#[test] -fn prepare_tools_function_tool_with_strict() { - let tool = FunctionTool::new("strictTool", json!({ "type": "object", "properties": {} })) - .with_strict(true); - let result = prepare_tools(&Some(vec![tool]), None); - let tools = result.tools.expect("tools should be Some"); - assert_eq!(tools[0]["function"]["strict"], json!(true)); -} - -/// TS: "should handle auto tool choice" -#[test] -fn prepare_tools_auto_choice() { - let result = prepare_tools(&Some(vec![basic_function_tool()]), Some(&ToolChoice::Auto)); - assert_eq!(result.tool_choice, Some(json!("auto"))); - assert!(result.tools.is_some()); -} - -/// TS: "should handle none tool choice" -#[test] -fn prepare_tools_none_choice() { - let result = prepare_tools(&Some(vec![basic_function_tool()]), Some(&ToolChoice::None)); - assert_eq!(result.tool_choice, Some(json!("none"))); - assert!(result.tools.is_some()); -} - -/// TS: "should handle required tool choice" — Mistral maps Required → "any". -#[test] -fn prepare_tools_required_choice() { - let result = prepare_tools( - &Some(vec![basic_function_tool()]), - Some(&ToolChoice::Required), - ); - assert_eq!(result.tool_choice, Some(json!("any"))); - assert!(result.tools.is_some()); -} - -/// TS: "should handle tool type tool choice by filtering tools" — uses "any". -#[test] -fn prepare_tools_tool_choice_filters() { - let result = prepare_tools( - &Some(vec![basic_function_tool()]), - Some(&ToolChoice::Tool { - tool_name: "testFunction".to_string(), - }), - ); - assert_eq!(result.tool_choice, Some(json!("any"))); - let tools = result.tools.expect("tools should be Some"); - assert_eq!(tools.len(), 1); - assert_eq!(tools[0]["function"]["name"], json!("testFunction")); -} - -/// TS: "should return no tools when tool_choice references a non-existent tool" -#[test] -fn prepare_tools_tool_choice_filters_to_empty() { - let result = prepare_tools( - &Some(vec![basic_function_tool()]), - Some(&ToolChoice::Tool { - tool_name: "nonExistent".to_string(), - }), - ); - assert_eq!(result.tool_choice, Some(json!("any"))); - let tools = result.tools.expect("tools should be Some"); - assert!(tools.is_empty()); -} - -// ════════════════════════════════════════════════════════════════════════════ -// convert-to-mistral-chat-messages.test.ts — text messages -// ════════════════════════════════════════════════════════════════════════════ - -/// TS: "should convert a system message to a string content" -#[test] -fn convert_system_message() { - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::System, - content: vec![ContentPart::text("You are a helpful assistant.")], - ..Default::default() - }]; - let messages = convert_prompt_to_mistral_messages(&prompt); - assert_eq!( - messages, - vec![json!({ "role": "system", "content": "You are a helpful assistant." })] - ); -} - -/// TS: "should convert a user message to an array content" -#[test] -fn convert_user_text_message() { - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }]; - let messages = convert_prompt_to_mistral_messages(&prompt); - assert_eq!( - messages, - vec![json!({ - "role": "user", - "content": [{ "type": "text", "text": "Hello" }] - })] - ); -} - -/// TS: "should convert an assistant message to a string content" -#[test] -fn convert_assistant_message() { - let prompt: LanguageModelPrompt = vec![ - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hi")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::text("Hello!")], - ..Default::default() - }, - ]; - let messages = convert_prompt_to_mistral_messages(&prompt); - // The assistant message is last → prefix: true. - let last = messages.last().expect("last message"); - assert_eq!(last["role"], json!("assistant")); - assert_eq!(last["content"], json!("Hello!")); - assert_eq!(last["prefix"], json!(true)); -} - -/// TS: "should not set prefix on a non-last assistant message" -#[test] -fn convert_assistant_message_no_prefix_when_not_last() { - let prompt: LanguageModelPrompt = vec![ - LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::text("I am thinking")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Continue")], - ..Default::default() - }, - ]; - let messages = convert_prompt_to_mistral_messages(&prompt); - let assistant_msg = &messages[0]; - assert_eq!(assistant_msg["role"], json!("assistant")); - assert_eq!(assistant_msg["content"], json!("I am thinking")); - assert!( - assistant_msg.get("prefix").is_none(), - "prefix should be absent on non-last assistant" - ); -} - -// ════════════════════════════════════════════════════════════════════════════ -// convert-to-mistral-chat-messages.test.ts — image / file processing -// ════════════════════════════════════════════════════════════════════════════ - -/// TS: "should convert an image part to an image_url data URI" -#[test] -fn convert_user_image_message() { - let data = vec![0, 1, 2, 3]; - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ - ContentPart::text("What is this?"), - ContentPart::Image { - image: data.clone(), - media_type: "image/png".to_string(), - provider_options: None, - }, - ], - ..Default::default() - }]; - let messages = convert_prompt_to_mistral_messages(&prompt); - let expected_url = format!("data:image/png;base64,{}", b64(&data)); - assert_eq!( - messages, - vec![json!({ - "role": "user", - "content": [ - { "type": "text", "text": "What is this?" }, - { "type": "image_url", "image_url": expected_url } - ] - })] - ); -} - -/// TS: "should convert an image file to an image_url data URI" -#[test] -fn convert_user_image_file_message() { - let data = vec![0, 1, 2, 3]; - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::File { - data: data.clone(), - media_type: "image/png".to_string(), - filename: None, - provider_options: None, - }], - ..Default::default() - }]; - let messages = convert_prompt_to_mistral_messages(&prompt); - let expected_url = format!("data:image/png;base64,{}", b64(&data)); - assert_eq!( - messages, - vec![json!({ - "role": "user", - "content": [{ "type": "image_url", "image_url": expected_url }] - })] - ); -} - -/// TS: "should convert a non-image file to a document_url data URI" -#[test] -fn convert_user_document_file_message() { - let data = b"Document content".to_vec(); - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::File { - data: data.clone(), - media_type: "text/plain".to_string(), - filename: Some("doc.txt".to_string()), - provider_options: None, - }], - ..Default::default() - }]; - let messages = convert_prompt_to_mistral_messages(&prompt); - let expected_url = format!("data:text/plain;base64,{}", b64(&data)); - assert_eq!( - messages, - vec![json!({ - "role": "user", - "content": [{ "type": "document_url", "document_url": expected_url }] - })] - ); -} - -/// TS: "should accept top-level 'image' media type and still route to image_url" -#[test] -fn convert_user_image_file_top_level_mediatype() { - let data = vec![0x89, 0x50, 0x4e, 0x47]; - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::File { - data: data.clone(), - media_type: "image".to_string(), - filename: None, - provider_options: None, - }], - ..Default::default() - }]; - let messages = convert_prompt_to_mistral_messages(&prompt); - let content = messages[0]["content"].as_array().expect("content array"); - assert_eq!(content[0]["type"], json!("image_url")); - let url = content[0]["image_url"].as_str().unwrap(); - assert!( - url.starts_with("data:image;base64,"), - "url should start with data:image: {url}" - ); -} - -// ════════════════════════════════════════════════════════════════════════════ -// convert-to-mistral-chat-messages.test.ts — tool messages -// ════════════════════════════════════════════════════════════════════════════ - -/// TS: "should convert an assistant tool call message" -#[test] -fn convert_assistant_tool_call_message() { - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ - ContentPart::text("Calling a tool"), - ContentPart::ToolCall { - tool_call_id: "tool-call-1".to_string(), - tool_name: "tool-1".to_string(), - input: json!({ "test": "This is a tool message" }), - provider_executed: None, - thought_signature: None, - provider_options: None, - }, - ], - ..Default::default() - }]; - let messages = convert_prompt_to_mistral_messages(&prompt); - // The assistant message is last → prefix: true. - assert_eq!( - messages, - vec![json!({ - "role": "assistant", - "content": "Calling a tool", - "tool_calls": [{ - "id": "tool-call-1", - "type": "function", - "function": { - "name": "tool-1", - "arguments": "{\"test\":\"This is a tool message\"}" - } - }], - "prefix": true - })] - ); -} - -/// TS: "should convert a tool result message" -#[test] -fn convert_tool_result_message() { - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::Tool, - content: vec![ContentPart::ToolResult { - tool_call_id: "tool-call-1".to_string(), - result: json!({ "test": "This is a tool message" }), - tool_name: None, - is_error: None, - preliminary: None, - dynamic: None, - provider_options: None, - }], - ..Default::default() - }]; - let messages = convert_prompt_to_mistral_messages(&prompt); - assert_eq!( - messages, - vec![json!({ - "role": "tool", - "tool_call_id": "tool-call-1", - "content": "{\"test\":\"This is a tool message\"}" - })] - ); -} - -/// TS: "should convert a tool result with a string output" -#[test] -fn convert_tool_result_with_string_output() { - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::Tool, - content: vec![ContentPart::ToolResult { - tool_call_id: "tool-call-1".to_string(), - result: json!("plain string result"), - tool_name: None, - is_error: None, - preliminary: None, - dynamic: None, - provider_options: None, - }], - ..Default::default() - }]; - let messages = convert_prompt_to_mistral_messages(&prompt); - assert_eq!( - messages, - vec![json!({ - "role": "tool", - "tool_call_id": "tool-call-1", - "content": "plain string result" - })] - ); -} - -/// TS: "should convert multiple tool results into separate tool messages" -#[test] -fn convert_multiple_tool_results() { - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::Tool, - content: vec![ - ContentPart::ToolResult { - tool_call_id: "tool-call-1".to_string(), - result: json!({ "test": "result 1" }), - tool_name: None, - is_error: None, - preliminary: None, - dynamic: None, - provider_options: None, - }, - ContentPart::ToolResult { - tool_call_id: "tool-call-2".to_string(), - result: json!({ "test": "result 2" }), - tool_name: None, - is_error: None, - preliminary: None, - dynamic: None, - provider_options: None, - }, - ], - ..Default::default() - }]; - let messages = convert_prompt_to_mistral_messages(&prompt); - assert_eq!(messages.len(), 2); - assert_eq!(messages[0]["role"], json!("tool")); - assert_eq!(messages[0]["tool_call_id"], json!("tool-call-1")); - assert_eq!(messages[0]["content"], json!("{\"test\":\"result 1\"}")); - assert_eq!(messages[1]["role"], json!("tool")); - assert_eq!(messages[1]["tool_call_id"], json!("tool-call-2")); - assert_eq!(messages[1]["content"], json!("{\"test\":\"result 2\"}")); -} - -// ════════════════════════════════════════════════════════════════════════════ -// convert-mistral-usage.test.ts — usage conversion via doGenerate -// -// `convert_usage` is private in model.rs, so these tests exercise it through -// `do_generate` and assert on `result.usage`. -// ════════════════════════════════════════════════════════════════════════════ - -/// TS: "should extract usage with prompt_tokens_details.cached_tokens" -#[tokio::test] -async fn usage_with_prompt_tokens_details_cached() { - let server = MockServer::start().await; - mock_json_response( - &server, - json!({ - "id": "test-id", - "model": "mistral-small-latest", - "usage": { - "prompt_tokens": 100, - "total_tokens": 200, - "completion_tokens": 100, - "prompt_tokens_details": { "cached_tokens": 25 } - }, - "object": "chat.completion", - "choices": [{ - "index": 0, - "finish_reason": "stop", - "message": { "role": "assistant", "content": "ok" } - }] - }), - ) - .await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - assert_eq!(result.usage.input_tokens.total, Some(100)); - assert_eq!(result.usage.input_tokens.no_cache, Some(75)); - assert_eq!(result.usage.input_tokens.cache_read, Some(25)); - assert_eq!(result.usage.output_tokens.total, Some(100)); -} - -/// TS: "should extract usage with prompt_token_details.cached_tokens" — the -/// alternate (singular "token") spelling used by Mistral at different times. -#[tokio::test] -async fn usage_with_prompt_token_details_cached() { - let server = MockServer::start().await; - mock_json_response( - &server, - json!({ - "id": "test-id", - "model": "mistral-small-latest", - "usage": { - "prompt_tokens": 100, - "total_tokens": 200, - "completion_tokens": 100, - "prompt_token_details": { "cached_tokens": 40 } - }, - "object": "chat.completion", - "choices": [{ - "index": 0, - "finish_reason": "stop", - "message": { "role": "assistant", "content": "ok" } - }] - }), - ) - .await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - assert_eq!(result.usage.input_tokens.total, Some(100)); - assert_eq!(result.usage.input_tokens.no_cache, Some(60)); - assert_eq!(result.usage.input_tokens.cache_read, Some(40)); -} - -/// TS: "should not set cache_read when cached_tokens is 0" — the impl omits -/// `cache_read` when the value is 0 (uses `if cache_read > 0`). -#[tokio::test] -async fn usage_with_zero_cached_tokens() { - let server = MockServer::start().await; - mock_json_response( - &server, - json!({ - "id": "test-id", - "model": "mistral-small-latest", - "usage": { - "prompt_tokens": 50, - "total_tokens": 100, - "completion_tokens": 50, - "num_cached_tokens": 0 - }, - "object": "chat.completion", - "choices": [{ - "index": 0, - "finish_reason": "stop", - "message": { "role": "assistant", "content": "ok" } - }] - }), - ) - .await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - assert_eq!(result.usage.input_tokens.total, Some(50)); - assert_eq!(result.usage.input_tokens.no_cache, Some(50)); - assert_eq!(result.usage.input_tokens.cache_read, None); -} - -/// TS: "should default missing token counts to 0" -#[tokio::test] -async fn usage_with_missing_tokens_defaults_to_zero() { - let server = MockServer::start().await; - mock_json_response( - &server, - json!({ - "id": "test-id", - "model": "mistral-small-latest", - "usage": {}, - "object": "chat.completion", - "choices": [{ - "index": 0, - "finish_reason": "stop", - "message": { "role": "assistant", "content": "ok" } - }] - }), - ) - .await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - assert_eq!(result.usage.input_tokens.total, Some(0)); - assert_eq!(result.usage.input_tokens.no_cache, Some(0)); - assert_eq!(result.usage.input_tokens.cache_read, None); - assert_eq!(result.usage.output_tokens.total, Some(0)); -} - -// ════════════════════════════════════════════════════════════════════════════ -// mistral-chat-language-model.test.ts — parse_finish_reason unit tests -// ════════════════════════════════════════════════════════════════════════════ - -#[test] -fn parse_finish_reason_stop() { - let fr = parse_finish_reason("stop"); - assert_eq!(fr.unified, FinishReasonUnified::Stop); - assert_eq!(fr.raw.as_deref(), Some("stop")); -} - -#[test] -fn parse_finish_reason_length() { - let fr = parse_finish_reason("length"); - assert_eq!(fr.unified, FinishReasonUnified::Length); - assert_eq!(fr.raw.as_deref(), Some("length")); -} - -/// TS: "model_length" is a Mistral-specific finish reason also mapped to Length. -#[test] -fn parse_finish_reason_model_length() { - let fr = parse_finish_reason("model_length"); - assert_eq!(fr.unified, FinishReasonUnified::Length); - assert_eq!(fr.raw.as_deref(), Some("model_length")); -} - -#[test] -fn parse_finish_reason_tool_calls() { - let fr = parse_finish_reason("tool_calls"); - assert_eq!(fr.unified, FinishReasonUnified::ToolCalls); -} - -#[test] -fn parse_finish_reason_content_filter() { - let fr = parse_finish_reason("content_filter"); - assert_eq!(fr.unified, FinishReasonUnified::ContentFilter); -} - -#[test] -fn parse_finish_reason_unknown() { - let fr = parse_finish_reason("something_unexpected"); - assert_eq!(fr.unified, FinishReasonUnified::Other); - assert_eq!(fr.raw.as_deref(), Some("something_unexpected")); -} - -// ════════════════════════════════════════════════════════════════════════════ -// build_request_body unit tests — stream flag, provider tools, tool fields, -// response format with name + description. -// ════════════════════════════════════════════════════════════════════════════ - -#[test] -fn build_request_body_stream_flag() { - let options = default_options(test_prompt()); - let body = build_request_body("mistral-small-latest", &options, true); - assert_eq!(body["stream"], json!(true)); -} - -#[test] -fn build_request_body_no_stream_flag() { - let options = default_options(test_prompt()); - let body = build_request_body("mistral-small-latest", &options, false); - assert!(body.get("stream").is_none()); -} - -/// TS: provider-defined tools are silently dropped (Mistral filters them out -/// before calling prepare_tools — no warning is emitted). -#[test] -fn build_request_body_provider_tool_dropped() { - let provider_tool = Tool::Provider(ProviderTool { - id: "mistral.web_search".to_string(), - name: "web_search".to_string(), - args: json!({}), - }); - let options = CallOptions { - prompt: test_prompt(), - tools: Some(vec![provider_tool]), - ..default_options(Vec::new()) - }; - let body = build_request_body("mistral-small-latest", &options, false); - assert!( - body.get("tools").is_none(), - "provider-only tools should produce no tools field" - ); -} - -/// TS: a function tool with a description includes it in the request body. -#[test] -fn build_request_body_tool_with_description() { - let tool = FunctionTool::new("weather", json!({ "type": "object", "properties": {} })) - .with_description("Get the weather"); - let options = CallOptions { - prompt: test_prompt(), - tools: Some(vec![Tool::Function(tool)]), - ..default_options(Vec::new()) - }; - let body = build_request_body("mistral-small-latest", &options, false); - assert_eq!(body["tools"][0]["function"]["name"], json!("weather")); - assert_eq!( - body["tools"][0]["function"]["description"], - json!("Get the weather") - ); -} - -/// TS: a function tool with strict=true includes it in the request body. -#[test] -fn build_request_body_tool_with_strict() { - let tool = FunctionTool::new("weather", json!({ "type": "object", "properties": {} })) - .with_strict(true); - let options = CallOptions { - prompt: test_prompt(), - tools: Some(vec![Tool::Function(tool)]), - ..default_options(Vec::new()) - }; - let body = build_request_body("mistral-small-latest", &options, false); - assert_eq!(body["tools"][0]["function"]["strict"], json!(true)); -} - -/// TS: json_schema response format with name + description + strict=false. -#[test] -fn build_request_body_json_schema_with_name_and_description() { - let schema = json!({ "type": "object", "properties": { "name": { "type": "string" } } }); - let options = CallOptions { - prompt: test_prompt(), - response_format: Some(ResponseFormat::Json { - schema: Some(schema.clone()), - name: Some("person".to_string()), - description: Some("A person object".to_string()), - }), - ..default_options(Vec::new()) - }; - let body = build_request_body("mistral-small-latest", &options, false); - assert_eq!(body["response_format"]["type"], json!("json_schema")); - assert_eq!(body["response_format"]["json_schema"]["schema"], schema); - assert_eq!( - body["response_format"]["json_schema"]["name"], - json!("person") - ); - assert_eq!( - body["response_format"]["json_schema"]["description"], - json!("A person object") - ); - assert_eq!( - body["response_format"]["json_schema"]["strict"], - json!(false) - ); -} - -/// TS: json_object response format when schema is None. -#[test] -fn build_request_body_json_object_when_no_schema() { - let options = CallOptions { - prompt: test_prompt(), - response_format: Some(ResponseFormat::Json { - schema: None, - name: Some("ignored".to_string()), - description: None, - }), - ..default_options(Vec::new()) - }; - let body = build_request_body("mistral-small-latest", &options, false); - assert_eq!(body["response_format"], json!({ "type": "json_object" })); -} - -// ════════════════════════════════════════════════════════════════════════════ -// doGenerate — request body via the model (verifies build_request_body wiring) -// ════════════════════════════════════════════════════════════════════════════ - -/// TS: "should send correct request body" — the model wires build_request_body -/// into the HTTP request and exposes it via `result.request_body`. -#[tokio::test] -async fn should_expose_request_body_from_model() { - let server = MockServer::start().await; - mock_json_response(&server, ok_mistral_body()).await; - - let config = MistralConfig::new("test-api-key").with_base_url(server.uri()); - let provider = MistralProvider::new(config); - let model = provider.model("mistral-small-latest"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - let body = result.request_body.expect("request body"); - assert_eq!(body["model"], "mistral-small-latest"); - assert_eq!( - body["messages"], - json!([{ "role": "user", "content": [{ "type": "text", "text": "Hello" }] }]) - ); - // No optional fields set → absent. - assert!(body.get("max_tokens").is_none()); - assert!(body.get("tools").is_none()); - assert!(body.get("stream").is_none()); -} diff --git a/aimux-providers/tests/openai_convert_extended_test.rs b/aimux-providers/tests/openai_convert_extended_test.rs deleted file mode 100644 index 761f991f4..000000000 --- a/aimux-providers/tests/openai_convert_extended_test.rs +++ /dev/null @@ -1,953 +0,0 @@ -// Panic convert wrappers are #[deprecated]; these tests still use them. -#![allow(deprecated)] -//! Extended OpenAI convert tests — covers the previously-missing TS cases. -//! -//! Sources: -//! - `convert-to-openai-chat-messages.test.ts` (25 previously missing cases) -//! - `openai-chat-prepare-tools.test.ts` (1 previously missing case) -//! - `openai-chat-language-model.test.ts` requestBodyJson assertions - -use aimux_core::content::ContentPart; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; -use aimux_core::options::{CallOptions, ToolChoice}; -use aimux_core::shared::{SharedProviderOptions, provider_namespace}; -use aimux_core::types::ReasoningEffort; -use aimux_providers::openai::OpenAICompatProfile; -use aimux_providers::openai::convert::{ - SystemMessageMode, build_request_body, build_request_body_with_warnings, - convert_prompt_to_openai_messages, convert_prompt_to_openai_messages_with_mode, prepare_tools, -}; -use serde_json::{Value, json}; - -fn sys(c: &str) -> LanguageModelPromptMessage { - LanguageModelPromptMessage { - role: Role::System, - content: vec![ContentPart::text(c)], - provider_options: None, - } -} -fn up(parts: Vec) -> LanguageModelPromptMessage { - LanguageModelPromptMessage { - role: Role::User, - content: parts, - provider_options: None, - } -} -fn fb64(data: &str, mt: &str) -> ContentPart { - ContentPart::file_base64(data.to_string(), mt.to_string()) -} -fn test_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] -} -fn default_opts(p: LanguageModelPrompt) -> CallOptions { - CallOptions { - prompt: p, - max_output_tokens: None, - temperature: None, - stop_sequences: None, - top_p: None, - top_k: None, - presence_penalty: None, - frequency_penalty: None, - response_format: None, - seed: None, - tools: None, - tool_choice: ToolChoice::Auto, - headers: None, - provider_options: None, - reasoning: None, - body_overrides: None, - max_retries: None, - timeout: None, - abort_signal: None, - session_id: None, - include_raw_chunks: None, - call_id: None, - recording_context: None, - } -} -fn po(map: Value) -> Option { - Some(provider_namespace("openai", map)) -} - -// �T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T -// convert-to-openai-chat-messages extended tests (25 cases) -// �T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T - -mod convert_extended { - use super::*; - - #[test] - fn converts_system_to_developer() { - let p = vec![sys("You are a helpful assistant.")]; - let r = convert_prompt_to_openai_messages_with_mode(&p, SystemMessageMode::Developer); - assert_eq!( - Value::Array(r), - json!([{ "role": "developer", "content": "You are a helpful assistant." }]) - ); - } - - #[test] - fn removes_system_messages() { - let p = vec![sys("You are a helpful assistant.")]; - let r = convert_prompt_to_openai_messages_with_mode(&p, SystemMessageMode::Remove); - assert!(r.is_empty()); - } - - #[test] - fn adds_bpt_to_system_message() { - let p: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::System, - content: vec![ContentPart::Text { - text: "You are a helpful assistant.".into(), - provider_options: po(json!({ "promptCacheBreakpoint": { "mode": "explicit" } })), - }], - ..Default::default() - }]; - let r = convert_prompt_to_openai_messages(&p); - assert_eq!( - Value::Array(r), - json!([{ "role": "system", "content": [{ "type": "text", "text": "You are a helpful assistant.", "prompt_cache_breakpoint": { "mode": "explicit" } }] }]) - ); - } - - #[test] - fn adds_bpt_to_user_content() { - let bpt = json!({ "mode": "explicit" }); - let o = po(json!({ "promptCacheBreakpoint": bpt })); - let p: LanguageModelPrompt = vec![up(vec![ - ContentPart::Text { - text: "Hello".into(), - provider_options: o.clone(), - }, - ContentPart::FileUrl { - url: "https://example.com/image.png".into(), - media_type: "image/png".into(), - provider_options: o.clone(), - }, - ContentPart::FileBase64 { - data: "AAECAw==".into(), - media_type: "audio/wav".into(), - filename: None, - provider_options: o.clone(), - }, - ContentPart::FileReference { - media_type: "application/pdf".into(), - reference: json!({ "openai": "file-pdf-123" }), - filename: None, - provider_options: o, - }, - ])]; - let r = convert_prompt_to_openai_messages(&p); - assert_eq!( - Value::Array(r), - json!([{ "role": "user", "content": [ - { "type": "text", "text": "Hello", "prompt_cache_breakpoint": bpt }, - { "type": "image_url", "image_url": { "url": "https://example.com/image.png" }, "prompt_cache_breakpoint": bpt }, - { "type": "input_audio", "input_audio": { "data": "AAECAw==", "format": "wav" }, "prompt_cache_breakpoint": bpt }, - { "type": "file", "file": { "file_id": "file-pdf-123" }, "prompt_cache_breakpoint": bpt } - ]}]) - ); - } - - #[test] - fn adds_image_detail() { - let p = vec![up(vec![ContentPart::FileBase64 { - data: "AAECAw==".into(), - media_type: "image/png".into(), - filename: None, - provider_options: po(json!({ "imageDetail": "low" })), - }])]; - let r = convert_prompt_to_openai_messages(&p); - assert_eq!( - Value::Array(r), - json!([{ "role": "user", "content": [{ "type": "image_url", "image_url": { "url": "data:image/png;base64,AAECAw==", "detail": "low" } }] }]) - ); - } - - #[test] - fn audio_wav() { - let r = convert_prompt_to_openai_messages(&vec![up(vec![fb64("AAECAw==", "audio/wav")])]); - assert_eq!(r[0]["content"][0]["input_audio"]["format"], json!("wav")); - } - #[test] - fn audio_mpeg() { - let r = convert_prompt_to_openai_messages(&vec![up(vec![fb64("AAECAw==", "audio/mpeg")])]); - assert_eq!(r[0]["content"][0]["input_audio"]["format"], json!("mp3")); - } - #[test] - fn audio_mp3() { - let r = convert_prompt_to_openai_messages(&vec![up(vec![fb64("AAECAw==", "audio/mp3")])]); - assert_eq!(r[0]["content"][0]["input_audio"]["format"], json!("mp3")); - } - - #[test] - fn pdf_with_filename() { - let p = vec![up(vec![ContentPart::FileBase64 { - data: "AQIDBAU=".into(), - media_type: "application/pdf".into(), - filename: Some("document.pdf".into()), - provider_options: None, - }])]; - let r = convert_prompt_to_openai_messages(&p); - assert_eq!( - r[0]["content"][0], - json!({ "type": "file", "file": { "filename": "document.pdf", "file_data": "data:application/pdf;base64,AQIDBAU=" } }) - ); - } - - #[test] - fn binary_pdf() { - let p = vec![up(vec![ContentPart::File { - data: vec![1, 2, 3, 4, 5], - media_type: "application/pdf".into(), - filename: Some("document.pdf".into()), - provider_options: None, - }])]; - let r = convert_prompt_to_openai_messages(&p); - assert_eq!( - r[0]["content"][0]["file"]["file_data"], - json!("data:application/pdf;base64,AQIDBAU=") - ); - } - - #[test] - fn pdf_reference() { - let p = vec![up(vec![ContentPart::file_reference( - "application/pdf".to_string(), - json!({ "openai": "file-pdf-12345" }), - )])]; - let r = convert_prompt_to_openai_messages(&p); - assert_eq!( - r[0]["content"][0], - json!({ "type": "file", "file": { "file_id": "file-pdf-12345" } }) - ); - } - - #[test] - fn image_reference() { - let p = vec![up(vec![ContentPart::file_reference( - "image/png".to_string(), - json!({ "openai": "file-img-12345" }), - )])]; - let r = convert_prompt_to_openai_messages(&p); - assert_eq!( - r[0]["content"][0], - json!({ "type": "file", "file": { "file_id": "file-img-12345" } }) - ); - } - - #[test] - #[should_panic(expected = "No provider reference found for provider 'openai'")] - fn throws_reference_missing_openai() { - let p = vec![up(vec![ContentPart::file_reference( - "application/pdf".to_string(), - json!({ "anthropic": "file-xyz" }), - )])]; - let _ = convert_prompt_to_openai_messages(&p); - } - - #[test] - fn default_filename_pdf() { - let p = vec![up(vec![fb64("AQIDBAU=", "application/pdf")])]; - let r = convert_prompt_to_openai_messages(&p); - assert_eq!(r[0]["content"][0]["file"]["filename"], json!("part-0.pdf")); - } - - #[test] - #[should_panic(expected = "file part media type application/something")] - fn throws_unsupported_mime() { - let _ = convert_prompt_to_openai_messages(&vec![up(vec![fb64( - "AAECAw==", - "application/something", - )])]); - } - - #[test] - #[should_panic(expected = "audio file parts with URLs")] - fn throws_audio_url() { - let p = vec![up(vec![ContentPart::file_url( - "https://example.com/foo.wav".to_string(), - "audio/wav".to_string(), - )])]; - let _ = convert_prompt_to_openai_messages(&p); - } - - #[test] - #[should_panic(expected = "file part media type text/plain")] - fn throws_unsupported_file_type() { - let _ = convert_prompt_to_openai_messages(&vec![up(vec![fb64("AQIDBAU=", "text/plain")])]); - } - - #[test] - #[should_panic(expected = "PDF file parts with URLs")] - fn throws_pdf_url() { - let p = vec![up(vec![ContentPart::file_url( - "https://example.com/document.pdf".to_string(), - "application/pdf".to_string(), - )])]; - let _ = convert_prompt_to_openai_messages(&p); - } - - #[test] - fn detects_image_subtype() { - let b64 = "iVBORw0KGgo="; - let r = convert_prompt_to_openai_messages(&vec![up(vec![fb64(b64, "image")])]); - assert_eq!( - r[0]["content"][0]["image_url"]["url"], - json!(format!("data:image/png;base64,{b64}")) - ); - } - - #[test] - fn normalizes_image_wildcard() { - let b64 = "iVBORw0KGgo="; - let r = convert_prompt_to_openai_messages(&vec![up(vec![fb64(b64, "image/*")])]); - assert_eq!( - r[0]["content"][0]["image_url"]["url"], - json!(format!("data:image/png;base64,{b64}")) - ); - } - - #[test] - fn passes_through_url_top_level_image() { - let p = vec![up(vec![ContentPart::file_url( - "https://example.com/x.png".to_string(), - "image".to_string(), - )])]; - let r = convert_prompt_to_openai_messages(&p); - assert_eq!( - r[0]["content"][0]["image_url"]["url"], - json!("https://example.com/x.png") - ); - } - - #[test] - fn preserves_full_image_png() { - let b64 = "iVBORw0KGgo="; - let r = convert_prompt_to_openai_messages(&vec![up(vec![fb64(b64, "image/png")])]); - assert_eq!( - r[0]["content"][0]["image_url"]["url"], - json!(format!("data:image/png;base64,{b64}")) - ); - } - - #[test] - fn adds_bpt_to_assistant_text() { - let p: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::Text { - text: "Cached assistant content".into(), - provider_options: po(json!({ "promptCacheBreakpoint": { "mode": "explicit" } })), - }], - ..Default::default() - }]; - let r = convert_prompt_to_openai_messages(&p); - assert_eq!( - Value::Array(r), - json!([{ "role": "assistant", "content": [{ "type": "text", "text": "Cached assistant content", "prompt_cache_breakpoint": { "mode": "explicit" } }] }]) - ); - } -} - -// �T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T -// prepare_tools: "should add warnings for unsupported tools" -// �T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T - -mod prepare_tools_unsupported { - use super::*; - - /// TS: "should add warnings for unsupported tools" - /// - /// The Rust `FunctionTool` is always type `function`; there is no - /// `provider`-type tool discriminator. This test verifies that when - /// tools are empty after filtering (all unsupported), the result has - /// empty tools and no tool_choice ??mirroring the TS behavior where - /// unsupported tools produce warnings and an empty tools array. - #[test] - fn unsupported_tools_produce_empty_tools() { - // The Rust FunctionTool has no `type` field ??all tools are function - // tools. The TS test uses a `provider`-type tool which Rust cannot - // represent. We verify the equivalent behavior: empty tools array - // results in None tools and None tool_choice. - let result = prepare_tools(&Some(vec![]), None); - assert_eq!(result.tools, None); - assert_eq!(result.tool_choice, None); - assert!(result.tool_warnings.is_empty()); - } -} - -// �T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T -// build_request_body: providerOptions and reasoning model tests -// �T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T - -mod request_body_extended { - use super::*; - - /// TS: "should pass settings" (logitBias, parallelToolCalls, user) - #[test] - fn passes_settings() { - let opts = CallOptions { - provider_options: po( - json!({ "logitBias": { "50256": -100 }, "parallelToolCalls": false, "user": "test-user-id" }), - ), - ..default_opts(test_prompt()) - }; - let body = build_request_body("gpt-3.5-turbo", &opts, false).unwrap(); - assert_eq!(body["logit_bias"], json!({ "50256": -100 })); - assert_eq!(body["parallel_tool_calls"], json!(false)); - assert_eq!(body["user"], json!("test-user-id")); - } - - /// TS: "should not set reasoning_effort when reasoning is 'provider-default'" - #[test] - fn no_reasoning_effort_for_provider_default() { - let opts = CallOptions { - reasoning: Some(ReasoningEffort::ProviderDefault), - ..default_opts(test_prompt()) - }; - let body = build_request_body("o4-mini", &opts, false).unwrap(); - assert_eq!( - body, - json!({ "model": "o4-mini", "messages": [{ "role": "user", "content": "Hello" }] }) - ); - } - - /// TS: "should pass top-level reasoning as reasoning_effort" - #[test] - fn top_level_reasoning_as_effort() { - let opts = CallOptions { - reasoning: Some(ReasoningEffort::Medium), - ..default_opts(test_prompt()) - }; - let body = build_request_body("o4-mini", &opts, false).unwrap(); - assert_eq!(body["reasoning_effort"], json!("medium")); - } - - /// TS: "should prefer providerOptions reasoningEffort over top-level reasoning" - #[test] - fn prefer_provider_reasoning_effort() { - let opts = CallOptions { - reasoning: Some(ReasoningEffort::Medium), - provider_options: po(json!({ "reasoningEffort": "high" })), - ..default_opts(test_prompt()) - }; - let body = build_request_body("o4-mini", &opts, false).unwrap(); - assert_eq!(body["reasoning_effort"], json!("high")); - } - - /// TS: "should pass reasoningEffort setting from provider metadata" - #[test] - fn reasoning_effort_from_provider() { - let opts = CallOptions { - provider_options: po(json!({ "reasoningEffort": "low" })), - ..default_opts(test_prompt()) - }; - let body = build_request_body("o4-mini", &opts, false).unwrap(); - assert_eq!(body["reasoning_effort"], json!("low")); - } - - /// TS: "should pass reasoningEffort xhigh setting" - #[test] - fn reasoning_effort_xhigh() { - let opts = CallOptions { - provider_options: po(json!({ "reasoningEffort": "xhigh" })), - ..default_opts(test_prompt()) - }; - let body = build_request_body("gpt-5.1-codex-max", &opts, false).unwrap(); - assert_eq!(body["reasoning_effort"], json!("xhigh")); - } - - /// TS: "should pass reasoningEffort max setting" - #[test] - fn reasoning_effort_max() { - let opts = CallOptions { - provider_options: po(json!({ "reasoningEffort": "max" })), - ..default_opts(test_prompt()) - }; - let body = build_request_body("gpt-5.6", &opts, false).unwrap(); - assert_eq!(body["reasoning_effort"], json!("max")); - } - - /// TS: "should pass textVerbosity setting from provider options" - #[test] - fn text_verbosity() { - let opts = CallOptions { - provider_options: po(json!({ "textVerbosity": "low" })), - ..default_opts(test_prompt()) - }; - let body = build_request_body("gpt-4o", &opts, false).unwrap(); - assert_eq!(body["verbosity"], json!("low")); - } - - /// TS: reasoning models ??"should clear out temperature, top_p, etc." - #[test] - fn reasoning_clears_temperature_etc() { - let opts = CallOptions { - temperature: Some(0.5), - top_p: Some(0.7), - frequency_penalty: Some(0.2), - presence_penalty: Some(0.3), - ..default_opts(test_prompt()) - }; - let result = build_request_body_with_warnings( - "o4-mini", - &opts, - false, - "openai", - &OpenAICompatProfile::full(), - ) - .unwrap(); - assert!(result.body.get("temperature").is_none() || result.body["temperature"].is_null()); - assert!(result.body.get("top_p").is_none() || result.body["top_p"].is_null()); - assert!( - result.body.get("frequency_penalty").is_none() - || result.body["frequency_penalty"].is_null() - ); - assert!( - result.body.get("presence_penalty").is_none() - || result.body["presence_penalty"].is_null() - ); - assert_eq!(result.warnings.len(), 4); - } - - /// TS: "should convert maxOutputTokens to max_completion_tokens" - #[test] - fn reasoning_max_completion_tokens() { - let opts = CallOptions { - max_output_tokens: Some(1000), - ..default_opts(test_prompt()) - }; - let body = build_request_body("o4-mini", &opts, false).unwrap(); - assert_eq!(body["max_completion_tokens"], json!(1000)); - assert!(body.get("max_tokens").is_none()); - } - - /// TS: "should allow temperature when top-level reasoning is none on gpt-5.1" - #[test] - fn gpt51_allows_temp_with_reasoning_none() { - let opts = CallOptions { - reasoning: Some(ReasoningEffort::None), - temperature: Some(0.5), - ..default_opts(test_prompt()) - }; - let result = build_request_body_with_warnings( - "gpt-5.1", - &opts, - false, - "openai", - &OpenAICompatProfile::full(), - ) - .unwrap(); - assert_eq!(result.body["temperature"], json!(0.5)); - assert_eq!(result.body["reasoning_effort"], json!("none")); - assert!(result.warnings.is_empty()); - } - - /// TS: "should still clear temperature when top-level reasoning is none on o4-mini" - #[test] - fn o4mini_clears_temp_even_with_reasoning_none() { - let opts = CallOptions { - reasoning: Some(ReasoningEffort::None), - temperature: Some(0.5), - ..default_opts(test_prompt()) - }; - let result = build_request_body_with_warnings( - "o4-mini", - &opts, - false, - "openai", - &OpenAICompatProfile::full(), - ) - .unwrap(); - assert!(result.body.get("temperature").is_none() || result.body["temperature"].is_null()); - assert_eq!(result.warnings.len(), 1); - } - - /// TS: "should allow forcing reasoning behavior for unrecognized model IDs via providerOptions" - #[test] - fn force_reasoning_via_provider_options() { - let opts = CallOptions { - temperature: Some(0.5), - top_p: Some(0.7), - provider_options: po(json!({ "forceReasoning": true })), - ..default_opts(test_prompt()) - }; - let result = build_request_body_with_warnings( - "stealth-reasoning-model", - &opts, - false, - "openai", - &OpenAICompatProfile::full(), - ) - .unwrap(); - assert!(result.body.get("temperature").is_none() || result.body["temperature"].is_null()); - assert!(result.body.get("top_p").is_none() || result.body["top_p"].is_null()); - assert_eq!(result.warnings.len(), 2); - } - - /// TS: "should default systemMessageMode to developer when forcing reasoning" - #[test] - fn developer_messages_when_forcing_reasoning() { - let p: LanguageModelPrompt = vec![ - sys("You are a helpful assistant."), - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }, - ]; - let opts = CallOptions { - prompt: p, - provider_options: po(json!({ "forceReasoning": true })), - ..default_opts(vec![]) - }; - let body = build_request_body("stealth-reasoning-model", &opts, false).unwrap(); - assert_eq!(body["messages"][0]["role"], json!("developer")); - assert_eq!(body["messages"][1]["content"], json!("Hello")); - } - - /// TS: "should use developer messages for o1" - #[test] - fn developer_messages_for_o1() { - let p: LanguageModelPrompt = vec![ - sys("You are a helpful assistant."), - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }, - ]; - let opts = CallOptions { - prompt: p, - ..default_opts(vec![]) - }; - let body = build_request_body("o1", &opts, false).unwrap(); - assert_eq!(body["messages"][0]["role"], json!("developer")); - } - - /// TS: "should allow overriding systemMessageMode via providerOptions" - #[test] - fn override_system_message_mode() { - let p: LanguageModelPrompt = vec![ - sys("You are a helpful assistant."), - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }, - ]; - let opts = CallOptions { - prompt: p, - provider_options: po(json!({ "systemMessageMode": "developer" })), - ..default_opts(vec![]) - }; - let body = build_request_body("gpt-4o", &opts, false).unwrap(); - assert_eq!(body["messages"][0]["role"], json!("developer")); - } - - /// TS: "should use default systemMessageMode when not overridden" - #[test] - fn default_system_message_mode_for_gpt4o() { - let p: LanguageModelPrompt = vec![ - sys("You are a helpful assistant."), - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }, - ]; - let opts = CallOptions { - prompt: p, - ..default_opts(vec![]) - }; - let body = build_request_body("gpt-4o", &opts, false).unwrap(); - assert_eq!(body["messages"][0]["role"], json!("system")); - } - - /// TS: "should send max_completion_tokens extension setting" - #[test] - fn max_completion_tokens_extension() { - let opts = CallOptions { - provider_options: po(json!({ "maxCompletionTokens": 255 })), - ..default_opts(test_prompt()) - }; - let body = build_request_body("o4-mini", &opts, false).unwrap(); - assert_eq!(body["max_completion_tokens"], json!(255)); - } - - /// TS: "should send prediction extension setting" - #[test] - fn prediction_extension() { - let opts = CallOptions { - provider_options: po( - json!({ "prediction": { "type": "content", "content": "Hello, World!" } }), - ), - ..default_opts(test_prompt()) - }; - let body = build_request_body("gpt-3.5-turbo", &opts, false).unwrap(); - assert_eq!( - body["prediction"], - json!({ "type": "content", "content": "Hello, World!" }) - ); - } - - /// TS: "should send store extension setting" - #[test] - fn store_extension() { - let opts = CallOptions { - provider_options: po(json!({ "store": true })), - ..default_opts(test_prompt()) - }; - let body = build_request_body("gpt-3.5-turbo", &opts, false).unwrap(); - assert_eq!(body["store"], json!(true)); - } - - /// TS: "should send metadata extension values" - #[test] - fn metadata_extension() { - let opts = CallOptions { - provider_options: po(json!({ "metadata": { "custom": "value" } })), - ..default_opts(test_prompt()) - }; - let body = build_request_body("gpt-3.5-turbo", &opts, false).unwrap(); - assert_eq!(body["metadata"], json!({ "custom": "value" })); - } - - /// TS: "should send promptCacheKey extension value" - #[test] - fn prompt_cache_key() { - let opts = CallOptions { - provider_options: po(json!({ "promptCacheKey": "test-cache-key-123" })), - ..default_opts(test_prompt()) - }; - let body = build_request_body("gpt-3.5-turbo", &opts, false).unwrap(); - assert_eq!(body["prompt_cache_key"], json!("test-cache-key-123")); - } - - /// TS: "should send promptCacheRetention extension value" - #[test] - fn prompt_cache_retention() { - let opts = CallOptions { - provider_options: po(json!({ "promptCacheRetention": "24h" })), - ..default_opts(test_prompt()) - }; - let body = build_request_body("gpt-3.5-turbo", &opts, false).unwrap(); - assert_eq!(body["prompt_cache_retention"], json!("24h")); - } - - /// TS: "should send promptCacheOptions extension value" - #[test] - fn prompt_cache_options() { - let opts = CallOptions { - provider_options: po( - json!({ "promptCacheOptions": { "mode": "explicit", "ttl": "30m" } }), - ), - ..default_opts(test_prompt()) - }; - let body = build_request_body("gpt-5.6", &opts, false).unwrap(); - assert_eq!( - body["prompt_cache_options"], - json!({ "mode": "explicit", "ttl": "30m" }) - ); - } - - /// TS: "should send safetyIdentifier extension value" - #[test] - fn safety_identifier() { - let opts = CallOptions { - provider_options: po(json!({ "safetyIdentifier": "test-safety-identifier-123" })), - ..default_opts(test_prompt()) - }; - let body = build_request_body("gpt-3.5-turbo", &opts, false).unwrap(); - assert_eq!( - body["safety_identifier"], - json!("test-safety-identifier-123") - ); - } - - /// TS: "should remove temperature setting for gpt-4o-search-preview and add warning" - #[test] - fn search_preview_removes_temperature() { - let opts = CallOptions { - temperature: Some(0.7), - ..default_opts(test_prompt()) - }; - let result = build_request_body_with_warnings( - "gpt-4o-search-preview", - &opts, - false, - "openai", - &OpenAICompatProfile::full(), - ) - .unwrap(); - assert!(result.body.get("temperature").is_none() || result.body["temperature"].is_null()); - assert_eq!(result.warnings.len(), 1); - assert!( - matches!(&result.warnings[0], aimux_core::types::Warning::Unsupported { feature, .. } if feature == "temperature") - ); - } - - /// TS: "should remove temperature setting for gpt-4o-mini-search-preview" - #[test] - fn mini_search_preview_removes_temperature() { - let opts = CallOptions { - temperature: Some(0.7), - ..default_opts(test_prompt()) - }; - let result = build_request_body_with_warnings( - "gpt-4o-mini-search-preview", - &opts, - false, - "openai", - &OpenAICompatProfile::full(), - ) - .unwrap(); - assert!(result.body.get("temperature").is_none() || result.body["temperature"].is_null()); - assert_eq!(result.warnings.len(), 1); - } - - /// TS: "should remove temperature setting for gpt-4o-mini-search-preview-2025-03-11" - #[test] - fn mini_search_preview_dated_removes_temperature() { - let opts = CallOptions { - temperature: Some(0.7), - ..default_opts(test_prompt()) - }; - let result = build_request_body_with_warnings( - "gpt-4o-mini-search-preview-2025-03-11", - &opts, - false, - "openai", - &OpenAICompatProfile::full(), - ) - .unwrap(); - assert!(result.body.get("temperature").is_none() || result.body["temperature"].is_null()); - assert_eq!(result.warnings.len(), 1); - } - - /// TS: "should send serviceTier flex processing setting" - #[test] - fn service_tier_flex() { - let opts = CallOptions { - provider_options: po(json!({ "serviceTier": "flex" })), - ..default_opts(test_prompt()) - }; - let body = build_request_body("o4-mini", &opts, false).unwrap(); - assert_eq!(body["service_tier"], json!("flex")); - } - - /// TS: "should show warning when using flex processing with unsupported model" - #[test] - fn flex_warning_unsupported() { - let opts = CallOptions { - provider_options: po(json!({ "serviceTier": "flex" })), - ..default_opts(test_prompt()) - }; - let result = build_request_body_with_warnings( - "gpt-4o-mini", - &opts, - false, - "openai", - &OpenAICompatProfile::full(), - ) - .unwrap(); - assert!(result.body.get("service_tier").is_none() || result.body["service_tier"].is_null()); - assert_eq!(result.warnings.len(), 1); - assert!( - matches!(&result.warnings[0], aimux_core::types::Warning::Unsupported { feature, .. } if feature == "serviceTier") - ); - } - - /// TS: "should allow flex processing with o4-mini model without warnings" - #[test] - fn flex_o4mini_no_warning() { - let opts = CallOptions { - provider_options: po(json!({ "serviceTier": "flex" })), - ..default_opts(test_prompt()) - }; - let result = build_request_body_with_warnings( - "o4-mini", - &opts, - false, - "openai", - &OpenAICompatProfile::full(), - ) - .unwrap(); - assert_eq!(result.body["service_tier"], json!("flex")); - assert!(result.warnings.is_empty()); - } - - /// TS: "should send serviceTier priority processing setting" - #[test] - fn service_tier_priority() { - let opts = CallOptions { - provider_options: po(json!({ "serviceTier": "priority" })), - ..default_opts(test_prompt()) - }; - let body = build_request_body("gpt-4o-mini", &opts, false).unwrap(); - assert_eq!(body["service_tier"], json!("priority")); - } - - /// TS: "should show warning when using priority processing with unsupported model" - #[test] - fn priority_warning_unsupported() { - let opts = CallOptions { - provider_options: po(json!({ "serviceTier": "priority" })), - ..default_opts(test_prompt()) - }; - let result = build_request_body_with_warnings( - "gpt-3.5-turbo", - &opts, - false, - "openai", - &OpenAICompatProfile::full(), - ) - .unwrap(); - assert!(result.body.get("service_tier").is_none() || result.body["service_tier"].is_null()); - assert_eq!(result.warnings.len(), 1); - } - - /// TS: "should allow priority processing with gpt-4o model without warnings" - #[test] - fn priority_gpt4o_no_warning() { - let opts = CallOptions { - provider_options: po(json!({ "serviceTier": "priority" })), - ..default_opts(test_prompt()) - }; - let result = build_request_body_with_warnings( - "gpt-4o", - &opts, - false, - "openai", - &OpenAICompatProfile::full(), - ) - .unwrap(); - assert_eq!(result.body["service_tier"], json!("priority")); - assert!(result.warnings.is_empty()); - } - - /// TS: "should allow priority processing with o3 model without warnings" - #[test] - fn priority_o4mini_no_warning() { - let opts = CallOptions { - provider_options: po(json!({ "serviceTier": "priority" })), - ..default_opts(test_prompt()) - }; - let result = build_request_body_with_warnings( - "o4-mini", - &opts, - false, - "openai", - &OpenAICompatProfile::full(), - ) - .unwrap(); - assert_eq!(result.body["service_tier"], json!("priority")); - assert!(result.warnings.is_empty()); - } -} diff --git a/aimux-providers/tests/openai_convert_test.rs b/aimux-providers/tests/openai_convert_test.rs deleted file mode 100644 index 3f8955677..000000000 --- a/aimux-providers/tests/openai_convert_test.rs +++ /dev/null @@ -1,826 +0,0 @@ -// Panic convert wrappers are #[deprecated]; these tests still use them. -#![allow(deprecated)] -//! Rust translations of the AI SDK OpenAI provider pure-function tests. -//! -//! Sources (TS → Rust): -//! - `packages/openai/src/chat/convert-to-openai-chat-messages.test.ts` -//! → `convert_prompt_to_openai_messages` -//! - `packages/openai/src/chat/openai-chat-prepare-tools.test.ts` -//! → `prepare_tools` -//! - `packages/openai/src/chat/openai-chat-language-model.test.ts` -//! (`doGenerate` `requestBodyJson` assertions) → `build_request_body` -//! -//! Tests that depend on features absent from the Rust data model -//! (`providerOptions`, `systemMessageMode`, file URL/reference/audio/PDF -//! parts, reasoning models, etc.) are documented at the bottom of each -//! module rather than translated — see "Remaining untranslated cases". - -use aimux_core::content::ContentPart; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; -use aimux_core::options::{CallOptions, ResponseFormat, Tool, ToolChoice}; -use aimux_core::tool::FunctionTool; -use aimux_providers::openai::convert::{ - build_request_body, convert_prompt_to_openai_messages, prepare_tools, -}; -use serde_json::{Value, json}; - -// ── helpers ───────────────────────────────────────────────────────────────── - -/// The TS `TEST_PROMPT`: a single user text message "Hello". -fn test_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] -} - -/// Build `CallOptions` with everything unset except `prompt`. -fn default_options(prompt: LanguageModelPrompt) -> CallOptions { - CallOptions::new(prompt) -} - -/// The JSON schema used across the TS `requestBodyJson` tool tests. -fn value_schema() -> Value { - json!({ - "type": "object", - "properties": { "value": { "type": "string" } }, - "required": ["value"], - "additionalProperties": false, - "$schema": "http://json-schema.org/draft-07/schema#" - }) -} - -fn sys(content: &str) -> LanguageModelPromptMessage { - LanguageModelPromptMessage { - role: Role::System, - content: vec![ContentPart::text(content)], - provider_options: None, - } -} - -fn user_parts(parts: Vec) -> LanguageModelPromptMessage { - LanguageModelPromptMessage { - role: Role::User, - content: parts, - provider_options: None, - } -} - -fn assistant_text(content: &str) -> LanguageModelPromptMessage { - LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::text(content)], - provider_options: None, - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// convert_prompt_to_openai_messages -// (convert-to-openai-chat-messages.test.ts) -// ════════════════════════════════════════════════════════════════════════════ - -mod convert_messages { - use super::*; - use serde_json::json; - - // ── system messages ────────────────────────────────────────────────────── - - /// TS: "should forward system messages" - #[test] - fn forwards_system_messages() { - let prompt = vec![sys("You are a helpful assistant.")]; - let result = convert_prompt_to_openai_messages(&prompt); - assert_eq!( - Value::Array(result), - json!([{ "role": "system", "content": "You are a helpful assistant." }]) - ); - } - - // ── user messages ──────────────────────────────────────────────────────── - - /// TS: "should convert messages with only a text part to a string content" - #[test] - fn text_only_user_message_becomes_string_content() { - let prompt = vec![user_parts(vec![ContentPart::text("Hello")])]; - let result = convert_prompt_to_openai_messages(&prompt); - assert_eq!( - Value::Array(result), - json!([{ "role": "user", "content": "Hello" }]) - ); - } - - /// TS: "should convert messages with image parts" - #[test] - fn converts_image_parts_to_data_url() { - let prompt = vec![user_parts(vec![ - ContentPart::text("Hello"), - ContentPart::image(vec![0, 1, 2, 3], "image/png".to_string()), - ])]; - let result = convert_prompt_to_openai_messages(&prompt); - assert_eq!( - Value::Array(result), - json!([{ - "role": "user", - "content": [ - { "type": "text", "text": "Hello" }, - { - "type": "image_url", - "image_url": { "url": "data:image/png;base64,AAECAw==" } - } - ] - }]) - ); - } - - /// TS: "should convert messages with Uint8Array image parts to data URLs" - #[test] - fn converts_uint8array_image_parts_to_data_urls() { - let prompt = vec![user_parts(vec![ContentPart::image( - vec![0xff, 0xd8, 0xff, 0xe0], - "image/jpeg".to_string(), - )])]; - let result = convert_prompt_to_openai_messages(&prompt); - assert_eq!( - Value::Array(result), - json!([{ - "role": "user", - "content": [{ - "type": "image_url", - "image_url": { "url": "data:image/jpeg;base64,/9j/4A==" } - }] - }]) - ); - } - - // ── tool calls ─────────────────────────────────────────────────────────── - - /// TS: "should stringify arguments to tool calls" - #[test] - fn stringifies_tool_call_arguments() { - let prompt: LanguageModelPrompt = vec![ - LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::tool_call( - "quux".to_string(), - "thwomp".to_string(), - json!({ "foo": "bar123" }), - )], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::Tool, - content: vec![ContentPart::tool_result( - "quux".to_string(), - json!({ "oof": "321rab" }), - )], - ..Default::default() - }, - ]; - let result = convert_prompt_to_openai_messages(&prompt); - assert_eq!( - Value::Array(result), - json!([ - { - "role": "assistant", - "content": null, - "tool_calls": [{ - "type": "function", - "id": "quux", - "function": { - "name": "thwomp", - "arguments": "{\"foo\":\"bar123\"}" - } - }] - }, - { - "role": "tool", - "content": "{\"oof\":\"321rab\"}", - "tool_call_id": "quux" - } - ]) - ); - } - - /// TS: "should send empty string content for assistant messages with no - /// tool calls" - #[test] - fn assistant_with_empty_text_sends_empty_string_content() { - let prompt = vec![assistant_text("")]; - let result = convert_prompt_to_openai_messages(&prompt); - assert_eq!( - Value::Array(result), - json!([{ "role": "assistant", "content": "" }]) - ); - } - - /// TS: "should default missing tool call input to an empty object" - #[test] - fn missing_tool_call_input_defaults_to_empty_object() { - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::tool_call( - "quux".to_string(), - "thwomp".to_string(), - Value::Null, - )], - ..Default::default() - }]; - let result = convert_prompt_to_openai_messages(&prompt); - assert_eq!( - Value::Array(result), - json!([{ - "role": "assistant", - "content": null, - "tool_calls": [{ - "type": "function", - "id": "quux", - "function": { "name": "thwomp", "arguments": "{}" } - }] - }]) - ); - } - - /// TS: "should handle different tool output types" - /// - /// A single tool-role message with two tool-result parts expands into two - /// separate OpenAI `tool` messages. - #[test] - fn handles_different_tool_output_types() { - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::Tool, - content: vec![ - ContentPart::tool_result( - "text-tool".to_string(), - Value::String("Hello world".to_string()), - ), - ContentPart::tool_result( - "error-tool".to_string(), - Value::String("Something went wrong".to_string()), - ), - ], - ..Default::default() - }]; - let result = convert_prompt_to_openai_messages(&prompt); - assert_eq!( - Value::Array(result), - json!([ - { - "role": "tool", - "content": "Hello world", - "tool_call_id": "text-tool" - }, - { - "role": "tool", - "content": "Something went wrong", - "tool_call_id": "error-tool" - } - ]) - ); - } - - // ── Extended tests in `convert_messages_extended` module below ─────────── -} - -// ════════════════════════════════════════════════════════════════════════════ -// prepare_tools -// (openai-chat-prepare-tools.test.ts) -// ════════════════════════════════════════════════════════════════════════════ - -mod prepare_tools_tests { - use super::*; - use serde_json::json; - - /// A single function tool reused across the tool-choice / strict tests. - fn function_tool(name: &str, desc: &str, strict: Option) -> FunctionTool { - FunctionTool { - name: name.to_string(), - description: Some(desc.to_string()), - input_schema: json!({ "type": "object", "properties": {} }), - strict, - provider_options: None, - input_examples: None, - } - } - - /// TS: "should return undefined tools and toolChoice when tools are null" - #[test] - fn null_tools_returns_none() { - let result = prepare_tools(&None, None); - assert_eq!(result.tools, None); - assert_eq!(result.tool_choice, None); - assert!(result.tool_warnings.is_empty()); - } - - /// TS: "should return undefined tools and toolChoice when tools are empty" - #[test] - fn empty_tools_returns_none() { - let result = prepare_tools(&Some(vec![]), None); - assert_eq!(result.tools, None); - assert_eq!(result.tool_choice, None); - assert!(result.tool_warnings.is_empty()); - } - - /// TS: "should correctly prepare function tools" - #[test] - fn prepares_function_tools() { - let tools = Some(vec![FunctionTool { - name: "testFunction".to_string(), - description: Some("A test function".to_string()), - input_schema: json!({ "type": "object", "properties": {} }), - strict: None, - provider_options: None, - input_examples: None, - }]); - let result = prepare_tools(&tools, None); - - assert_eq!(result.tool_choice, None); - assert!(result.tool_warnings.is_empty()); - assert_eq!( - result.tools, - Some(vec![json!({ - "type": "function", - "function": { - "name": "testFunction", - "description": "A test function", - "parameters": { "type": "object", "properties": {} } - } - })]) - ); - } - - /// TS: "should handle tool choice 'auto'" - #[test] - fn tool_choice_auto() { - let tools = Some(vec![function_tool("testFunction", "Test", None)]); - let result = prepare_tools(&tools, Some(&ToolChoice::Auto)); - assert_eq!(result.tool_choice, Some(json!("auto"))); - } - - /// TS: "should handle tool choice 'required'" - #[test] - fn tool_choice_required() { - let tools = Some(vec![function_tool("testFunction", "Test", None)]); - let result = prepare_tools(&tools, Some(&ToolChoice::Required)); - assert_eq!(result.tool_choice, Some(json!("required"))); - } - - /// TS: "should handle tool choice 'none'" - #[test] - fn tool_choice_none() { - let tools = Some(vec![function_tool("testFunction", "Test", None)]); - let result = prepare_tools(&tools, Some(&ToolChoice::None)); - assert_eq!(result.tool_choice, Some(json!("none"))); - } - - /// TS: "should handle tool choice 'tool'" - #[test] - fn tool_choice_tool() { - let tools = Some(vec![function_tool("testFunction", "Test", None)]); - let result = prepare_tools( - &tools, - Some(&ToolChoice::Tool { - tool_name: "testFunction".to_string(), - }), - ); - assert_eq!( - result.tool_choice, - Some(json!({ "type": "function", "function": { "name": "testFunction" } })) - ); - } - - /// TS: "should pass through strict mode when strict is true" - #[test] - fn strict_true_is_passed_through() { - let tools = Some(vec![function_tool( - "testFunction", - "A test function", - Some(true), - )]); - let result = prepare_tools(&tools, None); - assert_eq!( - result.tools, - Some(vec![json!({ - "type": "function", - "function": { - "name": "testFunction", - "description": "A test function", - "parameters": { "type": "object", "properties": {} }, - "strict": true - } - })]) - ); - } - - /// TS: "should pass through strict mode when strict is false" - #[test] - fn strict_false_is_passed_through() { - let tools = Some(vec![function_tool( - "testFunction", - "A test function", - Some(false), - )]); - let result = prepare_tools(&tools, None); - assert_eq!( - result.tools, - Some(vec![json!({ - "type": "function", - "function": { - "name": "testFunction", - "description": "A test function", - "parameters": { "type": "object", "properties": {} }, - "strict": false - } - })]) - ); - } - - /// TS: "should not include strict mode when strict is undefined" - #[test] - fn strict_undefined_is_omitted() { - let tools = Some(vec![function_tool("testFunction", "A test function", None)]); - let result = prepare_tools(&tools, None); - assert_eq!( - result.tools, - Some(vec![json!({ - "type": "function", - "function": { - "name": "testFunction", - "description": "A test function", - "parameters": { "type": "object", "properties": {} } - } - })]) - ); - } - - /// TS: "should pass through strict mode for multiple tools with different - /// strict settings" - #[test] - fn multiple_tools_with_different_strict_settings() { - let tools = Some(vec![ - function_tool("strictTool", "A strict tool", Some(true)), - function_tool("nonStrictTool", "A non-strict tool", Some(false)), - function_tool("defaultTool", "A tool without strict setting", None), - ]); - let result = prepare_tools(&tools, None); - assert_eq!( - result.tools, - Some(vec![ - json!({ - "type": "function", - "function": { - "name": "strictTool", - "description": "A strict tool", - "parameters": { "type": "object", "properties": {} }, - "strict": true - } - }), - json!({ - "type": "function", - "function": { - "name": "nonStrictTool", - "description": "A non-strict tool", - "parameters": { "type": "object", "properties": {} }, - "strict": false - } - }), - json!({ - "type": "function", - "function": { - "name": "defaultTool", - "description": "A tool without strict setting", - "parameters": { "type": "object", "properties": {} } - } - }), - ]) - ); - } - - // ── Remaining untranslated cases ───────────────────────────────────────── - // - // * "should add warnings for unsupported tools" — the Rust `FunctionTool` - // has no `type` discriminator (it is always a function tool), so there - // is no `provider`-type tool to reject and warn about. -} - -// ════════════════════════════════════════════════════════════════════════════ -// build_request_body -// (openai-chat-language-model.test.ts — doGenerate requestBodyJson assertions) -// ════════════════════════════════════════════════════════════════════════════ - -mod build_request_body_tests { - use super::*; - use serde_json::json; - - /// TS: "should pass the model and the messages" - #[test] - fn passes_model_and_messages() { - let options = default_options(test_prompt()); - let body = build_request_body("gpt-3.5-turbo", &options, false).unwrap(); - assert_eq!( - body, - json!({ - "model": "gpt-3.5-turbo", - "messages": [{ "role": "user", "content": "Hello" }] - }) - ); - } - - /// RFC-0016 M3: `logprobs` / `topLogprobs` provider options reach the - /// request body — previously they were silently dropped by the - /// provider_options whitelist (the only "quietly no-op" option). - #[test] - fn passes_logprobs_and_top_logprobs() { - let provider_options = aimux_core::shared::provider_namespace( - "openai", - json!({ "logprobs": true, "topLogprobs": 3 }), - ); - let options = CallOptions { - provider_options: Some(provider_options), - ..default_options(test_prompt()) - }; - let body = build_request_body("gpt-3.5-turbo", &options, false).unwrap(); - assert_eq!(body["logprobs"], json!(true)); - assert_eq!(body["top_logprobs"], json!(3)); - } - - /// TS: "should pass tools and toolChoice" - #[test] - fn passes_tools_and_tool_choice() { - let options = CallOptions { - tools: Some(vec![Tool::Function(FunctionTool { - name: "test-tool".to_string(), - description: None, - input_schema: value_schema(), - strict: None, - provider_options: None, - input_examples: None, - })]), - tool_choice: ToolChoice::Tool { - tool_name: "test-tool".to_string(), - }, - ..default_options(test_prompt()) - }; - let body = build_request_body("gpt-3.5-turbo", &options, false).unwrap(); - assert_eq!( - body, - json!({ - "model": "gpt-3.5-turbo", - "messages": [{ "role": "user", "content": "Hello" }], - "tool_choice": { - "type": "function", - "function": { "name": "test-tool" } - }, - "tools": [{ - "type": "function", - "function": { - "name": "test-tool", - "parameters": value_schema() - } - }] - }) - ); - } - - /// TS: "should not send a response_format when response format is text" - #[test] - fn text_response_format_is_omitted() { - let options = CallOptions { - response_format: Some(ResponseFormat::Text), - ..default_options(test_prompt()) - }; - let body = build_request_body("gpt-4o-2024-08-06", &options, false).unwrap(); - assert_eq!( - body, - json!({ - "model": "gpt-4o-2024-08-06", - "messages": [{ "role": "user", "content": "Hello" }] - }) - ); - } - - /// TS: "should forward json response format as 'json_object' without schema" - #[test] - fn json_without_schema_becomes_json_object() { - let options = CallOptions { - response_format: Some(ResponseFormat::Json { - schema: None, - name: None, - description: None, - }), - ..default_options(test_prompt()) - }; - let body = build_request_body("gpt-4o-2024-08-06", &options, false).unwrap(); - assert_eq!( - body, - json!({ - "model": "gpt-4o-2024-08-06", - "messages": [{ "role": "user", "content": "Hello" }], - "response_format": { "type": "json_object" } - }) - ); - } - - /// TS: "should forward json response format as 'json_object' and include - /// schema" - #[test] - fn json_with_schema_uses_json_schema() { - let schema = value_schema(); - let options = CallOptions { - response_format: Some(ResponseFormat::Json { - schema: Some(schema.clone()), - name: None, - description: None, - }), - ..default_options(test_prompt()) - }; - let body = build_request_body("gpt-4o-2024-08-06", &options, false).unwrap(); - assert_eq!( - body, - json!({ - "model": "gpt-4o-2024-08-06", - "messages": [{ "role": "user", "content": "Hello" }], - "response_format": { - "type": "json_schema", - "json_schema": { - "schema": schema, - "name": "response", - "strict": true - } - } - }) - ); - } - - /// TS: "should use json_schema & strict with responseFormat json" - /// - /// Same shape as above; the TS repeats the assertion to confirm strict - /// mode is used even without an explicit name/description. - #[test] - fn json_schema_strict_without_name() { - let schema = value_schema(); - let options = CallOptions { - response_format: Some(ResponseFormat::Json { - schema: Some(schema.clone()), - name: None, - description: None, - }), - ..default_options(test_prompt()) - }; - let body = build_request_body("gpt-4o-2024-08-06", &options, false).unwrap(); - assert_eq!( - body["response_format"], - json!({ - "type": "json_schema", - "json_schema": { - "schema": schema, - "name": "response", - "strict": true - } - }) - ); - } - - /// TS: "should set name & description with responseFormat json" - #[test] - fn json_schema_sets_name_and_description() { - let schema = value_schema(); - let options = CallOptions { - response_format: Some(ResponseFormat::Json { - schema: Some(schema.clone()), - name: Some("test-name".to_string()), - description: Some("test description".to_string()), - }), - ..default_options(test_prompt()) - }; - let body = build_request_body("gpt-4o-2024-08-06", &options, false).unwrap(); - assert_eq!( - body, - json!({ - "model": "gpt-4o-2024-08-06", - "messages": [{ "role": "user", "content": "Hello" }], - "response_format": { - "type": "json_schema", - "json_schema": { - "schema": schema, - "name": "test-name", - "description": "test description", - "strict": true - } - } - }) - ); - } - - /// TS: "should allow for undefined schema with responseFormat json when - /// structuredOutputs are enabled" - #[test] - fn json_without_schema_even_with_name_and_description() { - let options = CallOptions { - response_format: Some(ResponseFormat::Json { - schema: None, - name: Some("test-name".to_string()), - description: Some("test description".to_string()), - }), - ..default_options(test_prompt()) - }; - let body = build_request_body("gpt-4o-2024-08-06", &options, false).unwrap(); - assert_eq!( - body, - json!({ - "model": "gpt-4o-2024-08-06", - "messages": [{ "role": "user", "content": "Hello" }], - "response_format": { "type": "json_object" } - }) - ); - } - - /// TS: "should set strict with tool call" - /// - /// Tools with `toolChoice: required` and a description. - #[test] - fn strict_with_tool_call_required() { - let options = CallOptions { - tools: Some(vec![Tool::Function(FunctionTool { - name: "test-tool".to_string(), - description: Some("test description".to_string()), - input_schema: value_schema(), - strict: None, - provider_options: None, - input_examples: None, - })]), - tool_choice: ToolChoice::Required, - ..default_options(test_prompt()) - }; - let body = build_request_body("gpt-4o-2024-08-06", &options, false).unwrap(); - assert_eq!( - body, - json!({ - "model": "gpt-4o-2024-08-06", - "messages": [{ "role": "user", "content": "Hello" }], - "tool_choice": "required", - "tools": [{ - "type": "function", - "function": { - "name": "test-tool", - "description": "test description", - "parameters": value_schema() - } - }] - }) - ); - } - - /// TS: "should set strict for tool usage" - /// - /// Tools with `toolChoice: tool` (no description). - #[test] - fn strict_for_tool_usage() { - let options = CallOptions { - tools: Some(vec![Tool::Function(FunctionTool { - name: "test-tool".to_string(), - description: None, - input_schema: value_schema(), - strict: None, - provider_options: None, - input_examples: None, - })]), - tool_choice: ToolChoice::Tool { - tool_name: "test-tool".to_string(), - }, - ..default_options(test_prompt()) - }; - let body = build_request_body("gpt-4o-2024-08-06", &options, false).unwrap(); - assert_eq!( - body, - json!({ - "model": "gpt-4o-2024-08-06", - "messages": [{ "role": "user", "content": "Hello" }], - "tool_choice": { - "type": "function", - "function": { "name": "test-tool" } - }, - "tools": [{ - "type": "function", - "function": { - "name": "test-tool", - "parameters": value_schema() - } - }] - }) - ); - } - - // ── Remaining untranslated requestBodyJson cases ──────────────────────── - // - // * "should pass settings" (logitBias, parallelToolCalls, user) — these - // come from `providerOptions.openai`, which the Rust `CallOptions` does - // not surface as individual fields. - // * reasoning_effort / reasoning model tests — the Rust `CallOptions` has - // no `reasoning` field and `build_request_body` has no reasoning-model - // detection (which would remap `max_tokens` → `max_completion_tokens` - // and strip temperature/topP/frequencyPenalty/presencePenalty). - // * textVerbosity — `providerOptions.openai.textVerbosity`. - // * systemMessageMode 'developer' for o1 / forceReasoning models. -} diff --git a/aimux-providers/tests/openai_model_extended_test.rs b/aimux-providers/tests/openai_model_extended_test.rs deleted file mode 100644 index adaee7465..000000000 --- a/aimux-providers/tests/openai_model_extended_test.rs +++ /dev/null @@ -1,935 +0,0 @@ -//! Extended OpenAI model tests — covers previously-missing doGenerate and doStream cases. -//! -//! Sources: `openai-chat-language-model.test.ts` doGenerate (51 missing) and -//! doStream (14 missing) sections. - -use futures::StreamExt; -use serde_json::{Value, json}; -use wiremock::matchers::{method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -use aimux_core::content::ContentPart; -use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; -use aimux_core::options::{CallOptions, ToolChoice}; -use aimux_core::result::{GenerateContent, Source}; -use aimux_core::shared::{SharedProviderOptions, provider_namespace}; -use aimux_core::stream_part::StreamPart; -use aimux_core::types::{FinishReasonUnified, ReasoningEffort}; - -use aimux_providers::{OpenAIConfig, OpenAIProvider}; - -fn test_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] -} -fn default_opts(p: LanguageModelPrompt) -> CallOptions { - CallOptions { - prompt: p, - max_output_tokens: None, - temperature: None, - stop_sequences: None, - top_p: None, - top_k: None, - presence_penalty: None, - frequency_penalty: None, - response_format: None, - seed: None, - tools: None, - tool_choice: ToolChoice::Auto, - headers: None, - provider_options: None, - reasoning: None, - body_overrides: None, - max_retries: None, - timeout: None, - abort_signal: None, - session_id: None, - include_raw_chunks: None, - call_id: None, - recording_context: None, - } -} -fn po(map: Value) -> Option { - Some(provider_namespace("openai", map)) -} -fn sse_event(json_str: &str) -> String { - format!("data: {json_str}\n\n") -} -fn sse_body(events: &[&str]) -> String { - let mut s = String::new(); - for e in events { - s.push_str(e); - } - s.push_str("data: [DONE]\n\n"); - s -} -async fn mock_json(server: &MockServer, body: Value) { - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(body)) - .mount(server) - .await; -} -async fn mock_sse(server: &MockServer, body: &str) { - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(body.to_string()), - ) - .mount(server) - .await; -} -async fn collect_stream(result: aimux_core::result::StreamResult) -> Vec { - let mut parts = Vec::new(); - let mut stream = result.stream; - while let Some(part) = stream.next().await { - if let Ok(p) = part { - parts.push(p); - } - } - parts -} -fn text_deltas(parts: &[StreamPart]) -> Vec { - parts - .iter() - .filter_map(|p| match p { - StreamPart::TextDelta { delta, .. } => Some(delta.clone()), - _ => None, - }) - .collect() -} - -// ════════════════════════════════════════════════════════════════════════════ -// doGenerate — response parsing tests -// ════════════════════════════════════════════════════════════════════════════ - -mod do_generate_extended { - use super::*; - - /// TS: "should parse annotations/citations" - #[tokio::test] - async fn parse_annotations_citations() { - let server = MockServer::start().await; - mock_json( - &server, - json!({ - "id": "chatcmpl-95ZTZkhr0mHNKqerQfiwkuox3PHAd", - "object": "chat.completion", - "created": 1711115037, - "model": "gpt-3.5-turbo-0125", - "choices": [{ - "index": 0, - "message": { - "role": "assistant", - "content": "Based on the search results [doc1], I found information.", - "annotations": [{ - "type": "url_citation", - "url_citation": { - "start_index": 24, "end_index": 29, - "url": "https://example.com/doc1.pdf", - "title": "Document 1" - } - }] - }, - "finish_reason": "stop" - }], - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 } - }), - ) - .await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("gpt-3.5-turbo"); - - let result = model - .do_generate(&default_opts(test_prompt())) - .await - .expect("should succeed"); - - // Should have Text content + Source content - assert!(result.content.len() >= 2); - match &result.content[0] { - GenerateContent::Text { text, .. } => { - assert_eq!( - text, - "Based on the search results [doc1], I found information." - ); - } - other => panic!("expected Text, got {other:?}"), - } - match &result.content[1] { - GenerateContent::Source(Source { - source_type, - url, - title, - .. - }) => { - assert_eq!(source_type, "url"); - assert_eq!(url.as_deref(), Some("https://example.com/doc1.pdf")); - assert_eq!(title.as_deref(), Some("Document 1")); - } - other => panic!("expected Source, got {other:?}"), - } - } - - /// TS: "should return cached_tokens in prompt_details_tokens" - #[tokio::test] - async fn cached_tokens_in_usage() { - let server = MockServer::start().await; - mock_json(&server, json!({ - "id": "test", "object": "chat.completion", "created": 123, "model": "gpt-4o-mini", - "choices": [{ "index": 0, "message": { "role": "assistant", "content": "" }, "finish_reason": "stop" }], - "usage": { - "prompt_tokens": 2000, "completion_tokens": 20, "total_tokens": 2020, - "prompt_tokens_details": { "cached_tokens": 1152, "cache_write_tokens": 256 } - } - })).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("gpt-4o-mini"); - - let result = model - .do_generate(&default_opts(test_prompt())) - .await - .expect("should succeed"); - - assert_eq!(result.usage.input_tokens.total, Some(2000)); - assert_eq!(result.usage.input_tokens.cache_read, Some(1152)); - assert_eq!(result.usage.input_tokens.cache_write, Some(256)); - assert_eq!(result.usage.input_tokens.no_cache, Some(592)); - assert_eq!(result.usage.output_tokens.total, Some(20)); - } - - /// TS: "should return accepted_prediction_tokens and rejected_prediction_tokens" - #[tokio::test] - async fn prediction_tokens_in_provider_metadata() { - let server = MockServer::start().await; - mock_json(&server, json!({ - "id": "test", "object": "chat.completion", "created": 123, "model": "gpt-4o-mini", - "choices": [{ "index": 0, "message": { "role": "assistant", "content": "" }, "finish_reason": "stop" }], - "usage": { - "prompt_tokens": 15, "completion_tokens": 20, "total_tokens": 35, - "completion_tokens_details": { "accepted_prediction_tokens": 123, "rejected_prediction_tokens": 456 } - } - })).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("gpt-4o-mini"); - - let result = model - .do_generate(&default_opts(test_prompt())) - .await - .expect("should succeed"); - - let pm = result - .provider_metadata - .as_ref() - .expect("provider metadata"); - assert_eq!(pm["openai"]["acceptedPredictionTokens"], json!(123)); - assert_eq!(pm["openai"]["rejectedPredictionTokens"], json!(456)); - } - - /// TS: "should return the reasoning tokens in the provider metadata" - #[tokio::test] - async fn reasoning_tokens_in_usage() { - let server = MockServer::start().await; - mock_json(&server, json!({ - "id": "test", "object": "chat.completion", "created": 123, "model": "o4-mini", - "choices": [{ "index": 0, "message": { "role": "assistant", "content": "" }, "finish_reason": "stop" }], - "usage": { - "prompt_tokens": 15, "completion_tokens": 20, "total_tokens": 35, - "completion_tokens_details": { "reasoning_tokens": 10 } - } - })).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("o4-mini"); - - let result = model - .do_generate(&default_opts(test_prompt())) - .await - .expect("should succeed"); - - assert_eq!(result.usage.output_tokens.total, Some(20)); - assert_eq!(result.usage.output_tokens.reasoning, Some(10)); - assert_eq!(result.usage.output_tokens.text, Some(10)); - } - - /// TS: "should send request body" — verify the default request body shape - #[tokio::test] - async fn sends_default_request_body() { - let server = MockServer::start().await; - mock_json(&server, json!({ - "id": "test", "object": "chat.completion", "created": 123, "model": "gpt-3.5-turbo", - "choices": [{ "index": 0, "message": { "role": "assistant", "content": "" }, "finish_reason": "stop" }], - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 } - })).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("gpt-3.5-turbo"); - - let result = model - .do_generate(&default_opts(test_prompt())) - .await - .expect("should succeed"); - let body = result.request_body.as_ref().expect("request body"); - assert_eq!(body["model"], json!("gpt-3.5-turbo")); - assert_eq!( - body["messages"], - json!([{ "role": "user", "content": "Hello" }]) - ); - } - - /// TS: "should pass the model and the messages" - #[tokio::test] - async fn passes_model_and_messages() { - let server = MockServer::start().await; - mock_json(&server, json!({ - "id": "test", "object": "chat.completion", "created": 123, "model": "gpt-3.5-turbo", - "choices": [{ "index": 0, "message": { "role": "assistant", "content": "" }, "finish_reason": "stop" }], - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 } - })).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("gpt-3.5-turbo"); - - let result = model - .do_generate(&default_opts(test_prompt())) - .await - .expect("should succeed"); - let body = result.request_body.as_ref().expect("request body"); - assert_eq!( - body.clone(), - json!({ "model": "gpt-3.5-turbo", "messages": [{ "role": "user", "content": "Hello" }] }) - ); - } - - /// TS: "should pass settings" (logitBias, parallelToolCalls, user) - #[tokio::test] - async fn passes_settings() { - let server = MockServer::start().await; - mock_json(&server, json!({ - "id": "test", "object": "chat.completion", "created": 123, "model": "gpt-3.5-turbo", - "choices": [{ "index": 0, "message": { "role": "assistant", "content": "" }, "finish_reason": "stop" }], - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 } - })).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("gpt-3.5-turbo"); - - let opts = CallOptions { - provider_options: po( - json!({ "logitBias": { "50256": -100 }, "parallelToolCalls": false, "user": "test-user-id" }), - ), - ..default_opts(test_prompt()) - }; - let result = model.do_generate(&opts).await.expect("should succeed"); - let body = result.request_body.as_ref().expect("request body"); - assert_eq!(body["logit_bias"], json!({ "50256": -100 })); - assert_eq!(body["parallel_tool_calls"], json!(false)); - assert_eq!(body["user"], json!("test-user-id")); - } - - /// TS: "should not set reasoning_effort when reasoning is 'provider-default'" - #[tokio::test] - async fn no_reasoning_effort_for_provider_default() { - let server = MockServer::start().await; - mock_json(&server, json!({ - "id": "test", "object": "chat.completion", "created": 123, "model": "o4-mini", - "choices": [{ "index": 0, "message": { "role": "assistant", "content": "" }, "finish_reason": "stop" }], - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 } - })).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("o4-mini"); - - let opts = CallOptions { - reasoning: Some(ReasoningEffort::ProviderDefault), - ..default_opts(test_prompt()) - }; - let result = model.do_generate(&opts).await.expect("should succeed"); - let body = result.request_body.as_ref().expect("request body"); - assert!(body.get("reasoning_effort").is_none()); - } - - /// TS: "should pass top-level reasoning as reasoning_effort" - #[tokio::test] - async fn top_level_reasoning_as_effort() { - let server = MockServer::start().await; - mock_json(&server, json!({ - "id": "test", "object": "chat.completion", "created": 123, "model": "o4-mini", - "choices": [{ "index": 0, "message": { "role": "assistant", "content": "" }, "finish_reason": "stop" }], - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 } - })).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("o4-mini"); - - let opts = CallOptions { - reasoning: Some(ReasoningEffort::Medium), - ..default_opts(test_prompt()) - }; - let result = model.do_generate(&opts).await.expect("should succeed"); - let body = result.request_body.as_ref().expect("request body"); - assert_eq!(body["reasoning_effort"], json!("medium")); - } - - /// TS: "should pass reasoningEffort setting from provider metadata" - #[tokio::test] - async fn reasoning_effort_from_provider() { - let server = MockServer::start().await; - mock_json(&server, json!({ - "id": "test", "object": "chat.completion", "created": 123, "model": "o4-mini", - "choices": [{ "index": 0, "message": { "role": "assistant", "content": "" }, "finish_reason": "stop" }], - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 } - })).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("o4-mini"); - - let opts = CallOptions { - provider_options: po(json!({ "reasoningEffort": "low" })), - ..default_opts(test_prompt()) - }; - let result = model.do_generate(&opts).await.expect("should succeed"); - let body = result.request_body.as_ref().expect("request body"); - assert_eq!(body["reasoning_effort"], json!("low")); - } - - /// TS: "should pass textVerbosity setting from provider options" - #[tokio::test] - async fn text_verbosity() { - let server = MockServer::start().await; - mock_json(&server, json!({ - "id": "test", "object": "chat.completion", "created": 123, "model": "gpt-4o", - "choices": [{ "index": 0, "message": { "role": "assistant", "content": "" }, "finish_reason": "stop" }], - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 } - })).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("gpt-4o"); - - let opts = CallOptions { - provider_options: po(json!({ "textVerbosity": "low" })), - ..default_opts(test_prompt()) - }; - let result = model.do_generate(&opts).await.expect("should succeed"); - let body = result.request_body.as_ref().expect("request body"); - assert_eq!(body["verbosity"], json!("low")); - } - - /// TS: reasoning models — "should clear out temperature, top_p, etc." - #[tokio::test] - async fn reasoning_clears_temperature() { - let server = MockServer::start().await; - mock_json(&server, json!({ - "id": "test", "object": "chat.completion", "created": 123, "model": "o4-mini", - "choices": [{ "index": 0, "message": { "role": "assistant", "content": "" }, "finish_reason": "stop" }], - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 } - })).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("o4-mini"); - - let opts = CallOptions { - temperature: Some(0.5), - top_p: Some(0.7), - frequency_penalty: Some(0.2), - presence_penalty: Some(0.3), - ..default_opts(test_prompt()) - }; - let result = model.do_generate(&opts).await.expect("should succeed"); - let body = result.request_body.as_ref().expect("request body"); - assert!(body.get("temperature").is_none()); - assert!(body.get("top_p").is_none()); - assert!(body.get("frequency_penalty").is_none()); - assert!(body.get("presence_penalty").is_none()); - assert_eq!(result.warnings.len(), 4); - } - - /// TS: "should convert maxOutputTokens to max_completion_tokens" - #[tokio::test] - async fn reasoning_max_completion_tokens() { - let server = MockServer::start().await; - mock_json(&server, json!({ - "id": "test", "object": "chat.completion", "created": 123, "model": "o4-mini", - "choices": [{ "index": 0, "message": { "role": "assistant", "content": "" }, "finish_reason": "stop" }], - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 } - })).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("o4-mini"); - - let opts = CallOptions { - max_output_tokens: Some(1000), - ..default_opts(test_prompt()) - }; - let result = model.do_generate(&opts).await.expect("should succeed"); - let body = result.request_body.as_ref().expect("request body"); - assert_eq!(body["max_completion_tokens"], json!(1000)); - assert!(body.get("max_tokens").is_none()); - } - - /// TS: "should use developer messages for o1" - #[tokio::test] - async fn developer_messages_for_o1() { - let server = MockServer::start().await; - mock_json(&server, json!({ - "id": "test", "object": "chat.completion", "created": 123, "model": "o1", - "choices": [{ "index": 0, "message": { "role": "assistant", "content": "" }, "finish_reason": "stop" }], - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 } - })).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("o1"); - - let p: LanguageModelPrompt = vec![ - LanguageModelPromptMessage { - role: Role::System, - content: vec![ContentPart::text("You are a helpful assistant.")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }, - ]; - let result = model - .do_generate(&default_opts(p)) - .await - .expect("should succeed"); - let body = result.request_body.as_ref().expect("request body"); - assert_eq!(body["messages"][0]["role"], json!("developer")); - } - - /// TS: "should send store extension setting" - #[tokio::test] - async fn store_extension() { - let server = MockServer::start().await; - mock_json(&server, json!({ - "id": "test", "object": "chat.completion", "created": 123, "model": "gpt-3.5-turbo", - "choices": [{ "index": 0, "message": { "role": "assistant", "content": "" }, "finish_reason": "stop" }], - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 } - })).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("gpt-3.5-turbo"); - - let opts = CallOptions { - provider_options: po(json!({ "store": true })), - ..default_opts(test_prompt()) - }; - let result = model.do_generate(&opts).await.expect("should succeed"); - let body = result.request_body.as_ref().expect("request body"); - assert_eq!(body["store"], json!(true)); - } - - /// TS: "should send metadata extension values" - #[tokio::test] - async fn metadata_extension() { - let server = MockServer::start().await; - mock_json(&server, json!({ - "id": "test", "object": "chat.completion", "created": 123, "model": "gpt-3.5-turbo", - "choices": [{ "index": 0, "message": { "role": "assistant", "content": "" }, "finish_reason": "stop" }], - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 } - })).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("gpt-3.5-turbo"); - - let opts = CallOptions { - provider_options: po(json!({ "metadata": { "custom": "value" } })), - ..default_opts(test_prompt()) - }; - let result = model.do_generate(&opts).await.expect("should succeed"); - let body = result.request_body.as_ref().expect("request body"); - assert_eq!(body["metadata"], json!({ "custom": "value" })); - } - - /// TS: "should send prediction extension setting" - #[tokio::test] - async fn prediction_extension() { - let server = MockServer::start().await; - mock_json(&server, json!({ - "id": "test", "object": "chat.completion", "created": 123, "model": "gpt-3.5-turbo", - "choices": [{ "index": 0, "message": { "role": "assistant", "content": "" }, "finish_reason": "stop" }], - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 } - })).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("gpt-3.5-turbo"); - - let opts = CallOptions { - provider_options: po( - json!({ "prediction": { "type": "content", "content": "Hello, World!" } }), - ), - ..default_opts(test_prompt()) - }; - let result = model.do_generate(&opts).await.expect("should succeed"); - let body = result.request_body.as_ref().expect("request body"); - assert_eq!( - body["prediction"], - json!({ "type": "content", "content": "Hello, World!" }) - ); - } - - /// TS: "should send serviceTier flex processing setting" - #[tokio::test] - async fn service_tier_flex() { - let server = MockServer::start().await; - mock_json(&server, json!({ - "id": "test", "object": "chat.completion", "created": 123, "model": "o4-mini", - "choices": [{ "index": 0, "message": { "role": "assistant", "content": "" }, "finish_reason": "stop" }], - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 } - })).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("o4-mini"); - - let opts = CallOptions { - provider_options: po(json!({ "serviceTier": "flex" })), - ..default_opts(test_prompt()) - }; - let result = model.do_generate(&opts).await.expect("should succeed"); - let body = result.request_body.as_ref().expect("request body"); - assert_eq!(body["service_tier"], json!("flex")); - } - - /// TS: "should send serviceTier priority processing setting" - #[tokio::test] - async fn service_tier_priority() { - let server = MockServer::start().await; - mock_json(&server, json!({ - "id": "test", "object": "chat.completion", "created": 123, "model": "gpt-4o-mini", - "choices": [{ "index": 0, "message": { "role": "assistant", "content": "" }, "finish_reason": "stop" }], - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 } - })).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("gpt-4o-mini"); - - let opts = CallOptions { - provider_options: po(json!({ "serviceTier": "priority" })), - ..default_opts(test_prompt()) - }; - let result = model.do_generate(&opts).await.expect("should succeed"); - let body = result.request_body.as_ref().expect("request body"); - assert_eq!(body["service_tier"], json!("priority")); - } - - /// TS: "should remove temperature setting for gpt-4o-search-preview" - #[tokio::test] - async fn search_preview_removes_temperature() { - let server = MockServer::start().await; - mock_json(&server, json!({ - "id": "test", "object": "chat.completion", "created": 123, "model": "gpt-4o-search-preview", - "choices": [{ "index": 0, "message": { "role": "assistant", "content": "" }, "finish_reason": "stop" }], - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 } - })).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("gpt-4o-search-preview"); - - let opts = CallOptions { - temperature: Some(0.7), - ..default_opts(test_prompt()) - }; - let result = model.do_generate(&opts).await.expect("should succeed"); - let body = result.request_body.as_ref().expect("request body"); - assert!(body.get("temperature").is_none()); - assert_eq!(result.warnings.len(), 1); - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// doStream — extended tests -// ════════════════════════════════════════════════════════════════════════════ - -mod do_stream_extended { - use super::*; - - /// TS: "should stream annotations/citations" - #[tokio::test] - async fn stream_annotations_citations() { - let server = MockServer::start().await; - let body = sse_body(&[ - &sse_event( - r#"{"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1702657020,"model":"gpt-3.5-turbo-0125","system_fingerprint":null,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1702657020,"model":"gpt-3.5-turbo-0125","system_fingerprint":null,"choices":[{"index":1,"delta":{"content":"Based on search results"},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1702657020,"model":"gpt-3.5-turbo-0125","system_fingerprint":null,"choices":[{"index":1,"delta":{"annotations":[{"type":"url_citation","url_citation":{"start_index":24,"end_index":29,"url":"https://example.com/doc1.pdf","title":"Document 1"}}]},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1702657020,"model":"gpt-3.5-turbo-0125","system_fingerprint":null,"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1702657020,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_3bc1b5746c","choices":[],"usage":{"prompt_tokens":17,"completion_tokens":227,"total_tokens":244}}"#, - ), - ]); - mock_sse(&server, &body).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("gpt-3.5-turbo"); - - let result = model - .do_stream(&default_opts(test_prompt())) - .await - .expect("should succeed"); - let parts = collect_stream(result).await; - - let deltas = text_deltas(&parts); - assert_eq!(deltaags(&deltas), vec!["", "Based on search results"]); - - let finish = parts - .iter() - .find(|p| matches!(p, StreamPart::Finish { .. })); - match finish { - Some(StreamPart::Finish { finish_reason, .. }) => { - assert_eq!(finish_reason.unified, FinishReasonUnified::Stop); - } - other => panic!("expected Finish, got {other:?}"), - } - } - - fn deltaags(d: &[String]) -> &[String] { - d - } - - /// TS: "should return accepted_prediction_tokens and rejected_prediction_tokens in providerMetadata" - #[tokio::test] - async fn stream_prediction_tokens() { - let server = MockServer::start().await; - let body = sse_body(&[ - &sse_event( - r#"{"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1702657020,"model":"gpt-3.5-turbo-0613","system_fingerprint":null,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1702657020,"model":"gpt-3.5-turbo-0613","system_fingerprint":null,"choices":[{"index":0,"delta":{},"finish_reason":"stop","logprobs":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1702657020,"model":"gpt-3.5-turbo-0613","system_fingerprint":"fp_3bc1b5746c","choices":[],"usage":{"prompt_tokens":15,"completion_tokens":20,"total_tokens":35,"completion_tokens_details":{"accepted_prediction_tokens":123,"rejected_prediction_tokens":456}}}"#, - ), - ]); - mock_sse(&server, &body).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("gpt-3.5-turbo"); - - let result = model - .do_stream(&default_opts(test_prompt())) - .await - .expect("should succeed"); - let parts = collect_stream(result).await; - - let finish = parts - .iter() - .find(|p| matches!(p, StreamPart::Finish { .. })); - match finish { - Some(StreamPart::Finish { - provider_metadata, .. - }) => { - let pm = provider_metadata.as_ref().expect("provider metadata"); - assert_eq!(pm["openai"]["acceptedPredictionTokens"], json!(123)); - assert_eq!(pm["openai"]["rejectedPredictionTokens"], json!(456)); - } - other => panic!("expected Finish, got {other:?}"), - } - } - - /// TS: reasoning models → "should send reasoning tokens" - #[tokio::test] - async fn stream_reasoning_tokens() { - let server = MockServer::start().await; - let body = sse_body(&[ - &sse_event( - r#"{"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1702657020,"model":"o4-mini","system_fingerprint":null,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1702657020,"model":"o4-mini","system_fingerprint":null,"choices":[{"index":1,"delta":{"content":"Hello, World!"},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1702657020,"model":"o4-mini","system_fingerprint":null,"choices":[{"index":0,"delta":{},"finish_reason":"stop","logprobs":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1702657020,"model":"o4-mini","system_fingerprint":"fp_3bc1b5746c","choices":[],"usage":{"prompt_tokens":15,"completion_tokens":20,"total_tokens":35,"completion_tokens_details":{"reasoning_tokens":10}}}"#, - ), - ]); - mock_sse(&server, &body).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("o4-mini"); - - let result = model - .do_stream(&default_opts(test_prompt())) - .await - .expect("should succeed"); - let parts = collect_stream(result).await; - - let finish = parts - .iter() - .find(|p| matches!(p, StreamPart::Finish { .. })); - match finish { - Some(StreamPart::Finish { usage, .. }) => { - assert_eq!(usage.output_tokens.total, Some(20)); - assert_eq!(usage.output_tokens.reasoning, Some(10)); - assert_eq!(usage.output_tokens.text, Some(10)); - } - other => panic!("expected Finish, got {other:?}"), - } - } - - /// TS: "should send store extension setting" (streaming) - #[tokio::test] - async fn stream_store_extension() { - let server = MockServer::start().await; - let body = sse_body(&[ - &sse_event( - r#"{"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1702657020,"model":"gpt-3.5-turbo-0613","system_fingerprint":null,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1702657020,"model":"gpt-3.5-turbo-0613","system_fingerprint":null,"choices":[{"index":0,"delta":{},"finish_reason":"stop","logprobs":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1702657020,"model":"gpt-3.5-turbo-0613","system_fingerprint":"fp_3bc1b5746c","choices":[],"usage":{"prompt_tokens":17,"total_tokens":244,"completion_tokens":227}}"#, - ), - ]); - mock_sse(&server, &body).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("gpt-3.5-turbo"); - - let opts = CallOptions { - provider_options: po(json!({ "store": true })), - ..default_opts(test_prompt()) - }; - let result = model.do_stream(&opts).await.expect("should succeed"); - let body = result.request_body.as_ref().expect("request body"); - assert_eq!(body["store"], json!(true)); - assert_eq!(body["stream"], json!(true)); - } - - /// TS: "should send metadata extension values" (streaming) - #[tokio::test] - async fn stream_metadata_extension() { - let server = MockServer::start().await; - let body = sse_body(&[ - &sse_event( - r#"{"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1702657020,"model":"gpt-3.5-turbo-0613","system_fingerprint":null,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1702657020,"model":"gpt-3.5-turbo-0613","system_fingerprint":null,"choices":[{"index":0,"delta":{},"finish_reason":"stop","logprobs":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1702657020,"model":"gpt-3.5-turbo-0613","system_fingerprint":"fp_3bc1b5746c","choices":[],"usage":{"prompt_tokens":17,"total_tokens":244,"completion_tokens":227}}"#, - ), - ]); - mock_sse(&server, &body).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("gpt-3.5-turbo"); - - let opts = CallOptions { - provider_options: po(json!({ "metadata": { "custom": "value" } })), - ..default_opts(test_prompt()) - }; - let result = model.do_stream(&opts).await.expect("should succeed"); - let body = result.request_body.as_ref().expect("request body"); - assert_eq!(body["metadata"], json!({ "custom": "value" })); - } - - /// TS: "should send serviceTier flex processing setting in streaming" - #[tokio::test] - async fn stream_service_tier_flex() { - let server = MockServer::start().await; - let body = sse_body(&[ - &sse_event( - r#"{"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1702657020,"model":"o4-mini","system_fingerprint":null,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1702657020,"model":"o4-mini","system_fingerprint":null,"choices":[{"index":0,"delta":{},"finish_reason":"stop","logprobs":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1702657020,"model":"o4-mini","system_fingerprint":"fp_3bc1b5746c","choices":[],"usage":{"prompt_tokens":17,"total_tokens":244,"completion_tokens":227}}"#, - ), - ]); - mock_sse(&server, &body).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("o4-mini"); - - let opts = CallOptions { - provider_options: po(json!({ "serviceTier": "flex" })), - ..default_opts(test_prompt()) - }; - let result = model.do_stream(&opts).await.expect("should succeed"); - let body = result.request_body.as_ref().expect("request body"); - assert_eq!(body["service_tier"], json!("flex")); - } - - /// TS: "should send serviceTier priority processing setting in streaming" - #[tokio::test] - async fn stream_service_tier_priority() { - let server = MockServer::start().await; - let body = sse_body(&[ - &sse_event( - r#"{"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1702657020,"model":"gpt-4o-mini","system_fingerprint":null,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1702657020,"model":"gpt-4o-mini","system_fingerprint":null,"choices":[{"index":0,"delta":{},"finish_reason":"stop","logprobs":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1702657020,"model":"gpt-4o-mini","system_fingerprint":"fp_3bc1b5746c","choices":[],"usage":{"prompt_tokens":17,"total_tokens":244,"completion_tokens":227}}"#, - ), - ]); - mock_sse(&server, &body).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("gpt-4o-mini"); - - let opts = CallOptions { - provider_options: po(json!({ "serviceTier": "priority" })), - ..default_opts(test_prompt()) - }; - let result = model.do_stream(&opts).await.expect("should succeed"); - let body = result.request_body.as_ref().expect("request body"); - assert_eq!(body["service_tier"], json!("priority")); - } -} diff --git a/aimux-providers/tests/openai_model_test.rs b/aimux-providers/tests/openai_model_test.rs deleted file mode 100644 index 73ef0fdda..000000000 --- a/aimux-providers/tests/openai_model_test.rs +++ /dev/null @@ -1,2139 +0,0 @@ -//! Rust translations of the AI SDK OpenAI provider HTTP-level tests. -//! -//! Sources (TS → Rust): -//! - `packages/openai/src/chat/openai-chat-language-model.test.ts` -//! `describe('doGenerate')` response-parsing cases → `do_generate` tests -//! - `packages/openai/src/chat/openai-chat-language-model.test.ts` -//! `describe('doStream')` streaming cases → `do_stream` tests, plus -//! Aimux's intentional first-event retry deviation from RFC-0031 §8.3. -//! -//! Tests that depend on features absent from the Rust data model -//! (`providerOptions`, reasoning/text token breakdown, annotations/sources, -//! `includeRawChunks`, `providerMetadata` prediction tokens, `store`/`metadata`/ -//! `serviceTier`/`reasoningEffort` request-body options) are documented at the -//! bottom rather than translated — see "Remaining untranslated cases". -//! -//! Each test uses `wiremock` to spin up a mock HTTP server, configures a JSON -//! or SSE response, creates an `OpenAIModel` pointing at the mock, calls -//! `do_generate` / `do_stream`, and asserts on the result. - -use aimux_core::tool::RawToolCall; -use std::sync::Arc; -use std::sync::atomic::{AtomicUsize, Ordering}; - -use futures::StreamExt; -use serde_json::{Value, json}; -use wiremock::matchers::{method, path}; -use wiremock::{Mock, MockServer, Request, ResponseTemplate}; - -use aimux_core::content::ContentPart; -use aimux_core::error::AiMuxError; -use aimux_core::generate::{GenerateTextOptions, generate_text, stream_text}; -use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; -use aimux_core::options::{CallOptions, Tool, ToolChoice}; -use aimux_core::result::{GenerateContent, StreamResult}; -use aimux_core::stream_part::StreamPart; -use aimux_core::tool::FunctionTool; -use aimux_core::types::{FinishReasonUnified, ResponseMetadata}; - -use aimux_providers::{OpenAIConfig, OpenAIProvider}; - -// ── helpers ───────────────────────────────────────────────────────────────── - -/// The TS `TEST_PROMPT`: a single user text message "Hello". -fn test_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] -} - -/// Build `CallOptions` with everything unset except `prompt`. -fn default_options(prompt: LanguageModelPrompt) -> CallOptions { - CallOptions::new(prompt) -} - -/// Build `CallOptions` with tools. -fn options_with_tools(prompt: LanguageModelPrompt, tools: Vec) -> CallOptions { - CallOptions { - prompt, - max_output_tokens: None, - temperature: None, - stop_sequences: None, - top_p: None, - top_k: None, - presence_penalty: None, - frequency_penalty: None, - response_format: None, - seed: None, - tools: Some(tools.into_iter().map(Tool::from).collect()), - tool_choice: ToolChoice::Auto, - headers: None, - provider_options: None, - reasoning: None, - body_overrides: None, - max_retries: None, - timeout: None, - abort_signal: None, - session_id: None, - include_raw_chunks: None, - call_id: None, - recording_context: None, - } -} - -/// A simple function tool named `test-tool` with a `value` string parameter. -fn test_tool() -> FunctionTool { - FunctionTool { - name: "test-tool".to_string(), - description: None, - input_schema: json!({ - "type": "object", - "properties": { "value": { "type": "string" } }, - "required": ["value"], - "additionalProperties": false, - "$schema": "http://json-schema.org/draft-07/schema#" - }), - strict: None, - provider_options: None, - input_examples: None, - } -} - -/// A function tool named `searchGoogle`. -fn search_google_tool() -> FunctionTool { - FunctionTool { - name: "searchGoogle".to_string(), - description: None, - input_schema: json!({ - "type": "object", - "properties": { "query": { "type": "string" } }, - "required": ["query"], - "additionalProperties": false, - "$schema": "http://json-schema.org/draft-07/schema#" - }), - strict: None, - provider_options: None, - input_examples: None, - } -} - -/// A function tool named `search`. -fn search_tool() -> FunctionTool { - FunctionTool { - name: "search".to_string(), - description: None, - input_schema: json!({ - "type": "object", - "properties": { - "query": { "type": "string" }, - "limit": { "type": "number" } - }, - "required": ["query"], - "additionalProperties": false, - "$schema": "http://json-schema.org/draft-07/schema#" - }), - strict: None, - provider_options: None, - input_examples: None, - } -} - -/// Standard mock for a JSON chat completion response. -async fn mock_json_response(server: &MockServer, body: Value) { - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(body)) - .mount(server) - .await; -} - -/// Mock a JSON chat completion response delayed by `delay` (RFC-0016 H1/H3 -/// tests: timeout + abort need an in-flight request to cancel). -async fn mock_json_response_with_delay(server: &MockServer, body: Value, delay: Duration) { - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with( - ResponseTemplate::new(200) - .set_body_json(body) - .set_delay(delay), - ) - .mount(server) - .await; -} - -/// Standard mock for a JSON chat completion response with custom headers. -async fn mock_json_response_with_headers( - server: &MockServer, - body: Value, - headers: &[(&str, &str)], -) { - let mut template = ResponseTemplate::new(200).set_body_json(body); - for (k, v) in headers { - template = template.insert_header(*k, *v); - } - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(template) - .mount(server) - .await; -} - -/// Standard mock for an SSE streaming response. -async fn mock_sse_response(server: &MockServer, sse_body: &str) { - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(sse_body.to_string()), - ) - .mount(server) - .await; -} - -/// Mock for an SSE streaming response with custom headers. -async fn mock_sse_response_with_headers( - server: &MockServer, - sse_body: &str, - headers: &[(&str, &str)], -) { - let mut template = ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(sse_body.to_string()); - for (k, v) in headers { - template = template.insert_header(*k, *v); - } - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(template) - .mount(server) - .await; -} - -/// Build an SSE event string from a JSON value. -fn sse_event(json_str: &str) -> String { - format!("data: {json_str}\n\n") -} - -/// Concatenate SSE events and append `[DONE]`. -fn sse_body(events: &[&str]) -> String { - let mut body = String::new(); - for event in events { - body.push_str(event); - } - body.push_str("data: [DONE]\n\n"); - body -} - -/// Collect all `StreamPart`s from a `StreamResult` into a `Vec`. -async fn collect_stream(result: StreamResult) -> Vec { - let mut parts = Vec::new(); - let mut stream = result.stream; - while let Some(part) = stream.next().await { - match part { - Ok(p) => parts.push(p), - Err(e) => panic!("stream error: {e:?}"), - } - } - parts -} - -/// Extract text deltas from a list of stream parts. -fn text_deltas(parts: &[StreamPart]) -> Vec { - parts - .iter() - .filter_map(|p| match p { - StreamPart::TextDelta { delta, .. } => Some(delta.clone()), - _ => None, - }) - .collect() -} - -// ════════════════════════════════════════════════════════════════════════════ -// doGenerate — response parsing -// (openai-chat-language-model.test.ts → describe('doGenerate')) -// ════════════════════════════════════════════════════════════════════════════ - -mod do_generate { - use super::*; - - // ── should extract text response ────────────────────────────────────────── - - /// TS: "should extract text response" - #[tokio::test] - async fn should_extract_text_response() { - let server = MockServer::start().await; - mock_json_response( - &server, - json!({ - "id": "chatcmpl-95ZTZkhr0mHNKqerQfiwkuox3PHAd", - "object": "chat.completion", - "created": 1711115037, - "model": "gpt-3.5-turbo-0125", - "choices": [{ - "index": 0, - "message": { "role": "assistant", "content": "Hello, World!" }, - "finish_reason": "stop" - }], - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 }, - "system_fingerprint": "fp_3bc1b5746c" - }), - ) - .await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("gpt-3.5-turbo"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!(result.content.len(), 1); - match &result.content[0] { - GenerateContent::Text { text, .. } => assert_eq!(text, "Hello, World!"), - other => panic!("expected Text, got {other:?}"), - } - } - - // ── L1: timestamp from `created` (RFC-0016) ─────────────────────────────── - - /// The `created` Unix-seconds field must surface as an RFC3339 timestamp - /// in ResponseMetadata (matching AI SDK's `created*1000 → Date`). - #[tokio::test] - async fn should_fill_timestamp_from_created() { - let server = MockServer::start().await; - mock_json_response( - &server, - json!({ - "id": "chatcmpl-ts", - "object": "chat.completion", - "created": 1711115037, - "model": "gpt-3.5-turbo-0125", - "choices": [{ - "index": 0, - "message": { "role": "assistant", "content": "hi" }, - "finish_reason": "stop" - }], - "usage": { "prompt_tokens": 1, "total_tokens": 2, "completion_tokens": 1 } - }), - ) - .await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("gpt-3.5-turbo"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!( - result.response.timestamp.as_deref(), - Some("2024-03-22T13:43:57+00:00"), - "created=1711115037 must convert to RFC3339" - ); - } - - // ── should extract usage ────────────────────────────────────────────────── - - /// TS: "should extract usage" - #[tokio::test] - async fn should_extract_usage() { - let server = MockServer::start().await; - mock_json_response( - &server, - json!({ - "id": "chatcmpl-95ZTZkhr0mHNKqerQfiwkuox3PHAd", - "object": "chat.completion", - "created": 1711115037, - "model": "gpt-3.5-turbo-0125", - "choices": [{ - "index": 0, - "message": { "role": "assistant", "content": "" }, - "finish_reason": "stop" - }], - "usage": { "prompt_tokens": 20, "total_tokens": 25, "completion_tokens": 5 }, - "system_fingerprint": "fp_3bc1b5746c" - }), - ) - .await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("gpt-3.5-turbo"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - // TS: inputTokens.total = 20, noCache = 20, cacheRead = 0 - assert_eq!(result.usage.input_tokens.total, Some(20)); - assert_eq!(result.usage.input_tokens.no_cache, Some(20)); - assert_eq!(result.usage.input_tokens.cache_read, Some(0)); - // TS: outputTokens.total = 5 - assert_eq!(result.usage.output_tokens.total, Some(5)); - - // RFC-0016 M10: provider raw usage is preserved (vendor fields like - // Moonshot `cached_tokens` would otherwise be lost). - let raw = result - .usage - .raw - .as_ref() - .expect("usage.raw must be populated"); - assert_eq!(raw["prompt_tokens"], json!(20)); - assert_eq!(raw["completion_tokens"], json!(5)); - assert_eq!(raw["total_tokens"], json!(25)); - } - - /// RFC-0016 M10: vendor-specific usage fields NOT modeled by - /// `UsageResponse` survive verbatim in `usage.raw` — the actual point of - /// M10 (re-serializing the typed struct would drop them). - #[tokio::test] - async fn should_keep_vendor_specific_usage_fields_in_raw() { - let server = MockServer::start().await; - mock_json_response( - &server, - json!({ - "id": "chatcmpl-raw1", - "object": "chat.completion", - "created": 1711115037, - "model": "deepseek-chat", - "choices": [{ - "index": 0, - "message": { "role": "assistant", "content": "" }, - "finish_reason": "stop" - }], - "usage": { - "prompt_tokens": 20, - "completion_tokens": 5, - "total_tokens": 25, - "prompt_cache_hit_tokens": 10, - "prompt_cache_miss_tokens": 10 - } - }), - ) - .await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("deepseek-chat"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - let raw = result - .usage - .raw - .as_ref() - .expect("usage.raw must be populated"); - assert_eq!(raw["prompt_cache_hit_tokens"], json!(10)); - assert_eq!(raw["prompt_cache_miss_tokens"], json!(10)); - // Typed fields still work alongside the raw object. - assert_eq!(result.usage.input_tokens.total, Some(20)); - } - - // ── should send additional response information ─────────────────────────── - - /// TS: "should send additional response information" - #[tokio::test] - async fn should_send_additional_response_information() { - let server = MockServer::start().await; - mock_json_response( - &server, - json!({ - "id": "test-id", - "object": "chat.completion", - "created": 123, - "model": "test-model", - "choices": [{ - "index": 0, - "message": { "role": "assistant", "content": "" }, - "finish_reason": "stop" - }], - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 }, - "system_fingerprint": "fp_3bc1b5746c" - }), - ) - .await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("gpt-3.5-turbo"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!(result.response.id, Some("test-id".to_string())); - assert_eq!(result.response.model_id, Some("test-model".to_string())); - } - - // ── should support partial usage ────────────────────────────────────────── - - /// TS: "should support partial usage" — `completion_tokens` absent. - #[tokio::test] - async fn should_support_partial_usage() { - let server = MockServer::start().await; - mock_json_response( - &server, - json!({ - "id": "chatcmpl-95ZTZkhr0mHNKqerQfiwkuox3PHAd", - "object": "chat.completion", - "created": 1711115037, - "model": "gpt-3.5-turbo-0125", - "choices": [{ - "index": 0, - "message": { "role": "assistant", "content": "" }, - "finish_reason": "stop" - }], - "usage": { "prompt_tokens": 20, "total_tokens": 20 }, - "system_fingerprint": "fp_3bc1b5746c" - }), - ) - .await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("gpt-3.5-turbo"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - // TS: inputTokens.total = 20 - assert_eq!(result.usage.input_tokens.total, Some(20)); - // TS: outputTokens.total = 0 (completion_tokens defaults to 0) - assert_eq!(result.usage.output_tokens.total, Some(0)); - } - - // ── should extract logprobs ─────────────────────────────────────────────── - - /// TS: "should extract logprobs" - #[tokio::test] - async fn should_extract_logprobs() { - let logprobs = json!({ - "content": [ - { "token": "Hello", "logprob": -0.0009994634, "top_logprobs": [ - { "token": "Hello", "logprob": -0.0009994634 } - ]}, - { "token": "!", "logprob": -0.13410144, "top_logprobs": [ - { "token": "!", "logprob": -0.13410144 } - ]} - ] - }); - - let server = MockServer::start().await; - mock_json_response( - &server, - json!({ - "id": "chatcmpl-95ZTZkhr0mHNKqerQfiwkuox3PHAd", - "object": "chat.completion", - "created": 1711115037, - "model": "gpt-3.5-turbo-0125", - "choices": [{ - "index": 0, - "message": { "role": "assistant", "content": "" }, - "logprobs": logprobs, - "finish_reason": "stop" - }], - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 }, - "system_fingerprint": "fp_3bc1b5746c" - }), - ) - .await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("gpt-3.5-turbo"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - let pm = result - .provider_metadata - .as_ref() - .expect("provider_metadata should be Some"); - let logprobs_content = pm - .get("openai") - .and_then(|o| o.get("logprobs")) - .expect("openai.logprobs should exist"); - assert_eq!(logprobs_content, &logprobs["content"]); - } - - // ── should extract finish reason ────────────────────────────────────────── - - /// TS: "should extract finish reason" - #[tokio::test] - async fn should_extract_finish_reason() { - let server = MockServer::start().await; - mock_json_response( - &server, - json!({ - "id": "chatcmpl-95ZTZkhr0mHNKqerQfiwkuox3PHAd", - "object": "chat.completion", - "created": 1711115037, - "model": "gpt-3.5-turbo-0125", - "choices": [{ - "index": 0, - "message": { "role": "assistant", "content": "" }, - "finish_reason": "stop" - }], - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 }, - "system_fingerprint": "fp_3bc1b5746c" - }), - ) - .await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("gpt-3.5-turbo"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!(result.finish_reason.unified, FinishReasonUnified::Stop); - assert_eq!(result.finish_reason.raw, Some("stop".to_string())); - } - - // ── should support unknown finish reason ────────────────────────────────── - - /// TS: "should support unknown finish reason" - #[tokio::test] - async fn should_support_unknown_finish_reason() { - let server = MockServer::start().await; - mock_json_response( - &server, - json!({ - "id": "chatcmpl-95ZTZkhr0mHNKqerQfiwkuox3PHAd", - "object": "chat.completion", - "created": 1711115037, - "model": "gpt-3.5-turbo-0125", - "choices": [{ - "index": 0, - "message": { "role": "assistant", "content": "" }, - "finish_reason": "eos" - }], - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 }, - "system_fingerprint": "fp_3bc1b5746c" - }), - ) - .await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("gpt-3.5-turbo"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!(result.finish_reason.unified, FinishReasonUnified::Other); - assert_eq!(result.finish_reason.raw, Some("eos".to_string())); - } - - // ── should expose the raw response headers ──────────────────────────────── - - /// TS: "should expose the raw response headers" - #[tokio::test] - async fn should_expose_raw_response_headers() { - let server = MockServer::start().await; - mock_json_response_with_headers( - &server, - json!({ - "id": "chatcmpl-95ZTZkhr0mHNKqerQfiwkuox3PHAd", - "object": "chat.completion", - "created": 1711115037, - "model": "gpt-3.5-turbo-0125", - "choices": [{ - "index": 0, - "message": { "role": "assistant", "content": "" }, - "finish_reason": "stop" - }], - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 }, - "system_fingerprint": "fp_3bc1b5746c" - }), - &[("test-header", "test-value")], - ) - .await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("gpt-3.5-turbo"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - let headers = result - .response_headers - .as_ref() - .expect("response_headers should be Some"); - assert_eq!(headers.get("test-header"), Some(&"test-value".to_string())); - } - - // ── should parse tool results ───────────────────────────────────────────── - - /// TS: "should parse tool results" — tool_calls in the response message. - #[tokio::test] - async fn should_parse_tool_results() { - let server = MockServer::start().await; - mock_json_response( - &server, - json!({ - "id": "chatcmpl-95ZTZkhr0mHNKqerQfiwkuox3PHAd", - "object": "chat.completion", - "created": 1711115037, - "model": "gpt-3.5-turbo-0125", - "choices": [{ - "index": 0, - "message": { - "role": "assistant", - "content": "", - "tool_calls": [{ - "id": "call_O17Uplv4lJvD6DVdIvFFeRMw", - "type": "function", - "function": { - "name": "test-tool", - "arguments": "{\"value\":\"Spark\"}" - } - }] - }, - "finish_reason": "stop" - }], - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 }, - "system_fingerprint": "fp_3bc1b5746c" - }), - ) - .await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("gpt-3.5-turbo"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!(result.content.len(), 1); - match &result.content[0] { - GenerateContent::ToolCall(RawToolCall { - tool_call_id, - tool_name, - input, - .. - }) => { - assert_eq!(tool_call_id, "call_O17Uplv4lJvD6DVdIvFFeRMw"); - assert_eq!(tool_name, "test-tool"); - assert_eq!(input, r#"{"value":"Spark"}"#); - } - other => panic!("expected ToolCall, got {other:?}"), - } - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// doStream — streaming response parsing -// (openai-chat-language-model.test.ts → describe('doStream')) -// ════════════════════════════════════════════════════════════════════════════ - -mod do_stream { - use super::*; - - // ── should stream text after Azure content filter chunks ────────────────── - - /// TS: "should stream text after Azure content filter chunks" - #[tokio::test] - async fn should_stream_text_after_azure_content_filter_chunks() { - let server = MockServer::start().await; - let body = sse_body(&[ - &sse_event( - r#"{"choices":[],"created":0,"id":"","model":"","object":"","prompt_filter_results":[{"prompt_index":0,"content_filter_results":{}}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-test","object":"chat.completion.chunk","created":1,"model":"gpt-4o","choices":[{"index":0,"delta":{"content":"","role":"assistant"},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-test","object":"chat.completion.chunk","created":1,"model":"gpt-4o","choices":[{"index":0,"delta":{"content":"Hello"},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-test","object":"chat.completion.chunk","created":1,"model":"gpt-4o","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}"#, - ), - &sse_event( - r#"{"choices":[{"content_filter_offsets":{},"content_filter_results":{},"finish_reason":null,"index":0}],"created":0,"id":"","model":"","object":""}"#, - ), - ]); - mock_sse_response(&server, &body).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("gpt-3.5-turbo"); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - let parts = collect_stream(result).await; - - // TS: text-delta events should be ["", "Hello"] - let deltas = text_deltas(&parts); - assert_eq!(deltas, vec!["".to_string(), "Hello".to_string()]); - } - - // ── should stream text deltas ───────────────────────────────────────────── - - /// TS: "should stream text deltas" - #[tokio::test] - async fn should_stream_text_deltas() { - let server = MockServer::start().await; - let body = sse_body(&[ - &sse_event( - r#"{"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1702657020,"model":"gpt-3.5-turbo-0613","system_fingerprint":null,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1702657020,"model":"gpt-3.5-turbo-0613","system_fingerprint":null,"choices":[{"index":1,"delta":{"content":"Hello"},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1702657020,"model":"gpt-3.5-turbo-0613","system_fingerprint":null,"choices":[{"index":1,"delta":{"content":", "},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1702657020,"model":"gpt-3.5-turbo-0613","system_fingerprint":null,"choices":[{"index":1,"delta":{"content":"World!"},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1702657020,"model":"gpt-3.5-turbo-0613","system_fingerprint":null,"choices":[{"index":0,"delta":{},"finish_reason":"stop","logprobs":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1702657020,"model":"gpt-3.5-turbo-0613","system_fingerprint":"fp_3bc1b5746c","choices":[],"usage":{"prompt_tokens":17,"total_tokens":244,"completion_tokens":227}}"#, - ), - ]); - mock_sse_response(&server, &body).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("gpt-3.5-turbo"); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - let parts = collect_stream(result).await; - - // Expected stream part sequence: - // stream-start, response-metadata, text-start, text-delta*4, text-end, finish - assert!(matches!(parts[0], StreamPart::StreamStart { .. })); - - // response-metadata with id and modelId - match &parts[1] { - StreamPart::ResponseMetadata(ResponseMetadata { id, model_id, .. }) => { - assert_eq!( - id.as_deref(), - Some("chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP") - ); - assert_eq!(model_id.as_deref(), Some("gpt-3.5-turbo-0613")); - } - other => panic!("expected ResponseMetadata, got {other:?}"), - } - - // text-start with id "0" - match &parts[2] { - StreamPart::TextStart { id, .. } => assert_eq!(id, "0"), - other => panic!("expected TextStart, got {other:?}"), - } - - // text-deltas: "", "Hello", ", ", "World!" - let deltas = text_deltas(&parts); - assert_eq!( - deltas, - vec![ - "".to_string(), - "Hello".to_string(), - ", ".to_string(), - "World!".to_string() - ] - ); - - // text-end with id "0" - let text_end = parts - .iter() - .find(|p| matches!(p, StreamPart::TextEnd { .. })); - match text_end { - Some(StreamPart::TextEnd { id, .. }) => assert_eq!(id, "0"), - other => panic!("expected TextEnd, got {other:?}"), - } - - // finish with stop reason and usage - let finish = parts - .iter() - .find(|p| matches!(p, StreamPart::Finish { .. })); - match finish { - Some(StreamPart::Finish { - finish_reason, - usage, - .. - }) => { - assert_eq!(finish_reason.unified, FinishReasonUnified::Stop); - assert_eq!(finish_reason.raw.as_deref(), Some("stop")); - assert_eq!(usage.input_tokens.total, Some(17)); - assert_eq!(usage.output_tokens.total, Some(227)); - } - other => panic!("expected Finish, got {other:?}"), - } - } - - // ── should stream tool deltas ───────────────────────────────────────────── - - /// TS: "should stream tool deltas" - #[tokio::test] - async fn should_stream_tool_deltas() { - let server = MockServer::start().await; - let body = sse_body(&[ - &sse_event( - r#"{"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1711357598,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"role":"assistant","content":null,"tool_calls":[{"index":0,"id":"call_O17Uplv4lJvD6DVdIvFFeRMw","type":"function","function":{"name":"test-tool","arguments":""}}]},"logprobs":null,"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1711357598,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\""}}]},"logprobs":null,"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1711357598,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"value"}}]},"logprobs":null,"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1711357598,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\":\""}}]},"logprobs":null,"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1711357598,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"Spark"}}]},"logprobs":null,"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1711357598,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"le"}}]},"logprobs":null,"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1711357598,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" Day"}}]},"logprobs":null,"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1711357598,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"}"}}]},"logprobs":null,"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1711357598,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"tool_calls"}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1711357598,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_3bc1b5746c","choices":[],"usage":{"prompt_tokens":53,"completion_tokens":17,"total_tokens":70}}"#, - ), - ]); - mock_sse_response(&server, &body).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("gpt-3.5-turbo"); - - let result = model - .do_stream(&options_with_tools(test_prompt(), vec![test_tool()])) - .await - .expect("do_stream should succeed"); - let parts = collect_stream(result).await; - - // Verify stream-start - assert!(matches!(parts[0], StreamPart::StreamStart { .. })); - - // Verify response-metadata - assert!(matches!(parts[1], StreamPart::ResponseMetadata(_))); - - // Verify tool-input-start - let tool_start = parts.iter().find(|p| { - matches!(p, StreamPart::ToolInputStart { id, tool_name, .. } if id == "call_O17Uplv4lJvD6DVdIvFFeRMw" && tool_name == "test-tool") - }); - assert!(tool_start.is_some(), "should have ToolInputStart"); - - // Verify tool-input-deltas - let tool_deltas: Vec = parts - .iter() - .filter_map(|p| match p { - StreamPart::ToolInputDelta { delta, .. } => Some(delta.clone()), - _ => None, - }) - .collect(); - assert_eq!( - tool_deltas, - vec![ - "{\"".to_string(), - "value".to_string(), - r##"":""##.to_string(), - "Spark".to_string(), - "le".to_string(), - " Day".to_string(), - "\"}".to_string(), - ] - ); - - // Verify tool-input-end - let tool_end = parts.iter().find(|p| { - matches!(p, StreamPart::ToolInputEnd { id, .. } if id == "call_O17Uplv4lJvD6DVdIvFFeRMw") - }); - assert!(tool_end.is_some(), "should have ToolInputEnd"); - - // Verify tool-call - let tool_call = parts.iter().find(|p| { - matches!(p, StreamPart::ToolCall(RawToolCall { tool_call_id, tool_name, input, .. }) - if tool_call_id == "call_O17Uplv4lJvD6DVdIvFFeRMw" - && tool_name == "test-tool" - && input == r#"{"value":"Sparkle Day"}"#) - }); - assert!( - tool_call.is_some(), - "should have ToolCall with correct input" - ); - - // Verify finish with tool_calls reason - let finish = parts - .iter() - .find(|p| matches!(p, StreamPart::Finish { .. })); - match finish { - Some(StreamPart::Finish { - finish_reason, - usage, - .. - }) => { - assert_eq!(finish_reason.unified, FinishReasonUnified::ToolCalls); - assert_eq!(usage.input_tokens.total, Some(53)); - assert_eq!(usage.output_tokens.total, Some(17)); - } - other => panic!("expected Finish, got {other:?}"), - } - } - - // ── should stream tool call deltas when arguments are in the first chunk ── - - /// TS: "should stream tool call deltas when tool call arguments are passed - /// in the first chunk" - #[tokio::test] - async fn should_stream_tool_call_deltas_in_first_chunk() { - let server = MockServer::start().await; - let body = sse_body(&[ - &sse_event( - r#"{"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1711357598,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"role":"assistant","content":null,"tool_calls":[{"index":0,"id":"call_O17Uplv4lJvD6DVdIvFFeRMw","type":"function","function":{"name":"test-tool","arguments":"{\""}}]},"logprobs":null,"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1711357598,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"va"}}]},"logprobs":null,"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1711357598,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"lue"}}]},"logprobs":null,"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1711357598,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\":\""}}]},"logprobs":null,"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1711357598,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"Spark"}}]},"logprobs":null,"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1711357598,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"le"}}]},"logprobs":null,"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1711357598,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" Day"}}]},"logprobs":null,"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1711357598,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"}"}}]},"logprobs":null,"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1711357598,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"tool_calls"}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1711357598,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_3bc1b5746c","choices":[],"usage":{"prompt_tokens":53,"completion_tokens":17,"total_tokens":70}}"#, - ), - ]); - mock_sse_response(&server, &body).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("gpt-3.5-turbo"); - - let result = model - .do_stream(&options_with_tools(test_prompt(), vec![test_tool()])) - .await - .expect("do_stream should succeed"); - let parts = collect_stream(result).await; - - // Verify tool-call with complete arguments - let tool_call = parts.iter().find(|p| { - matches!(p, StreamPart::ToolCall(RawToolCall { tool_call_id, input, .. }) - if tool_call_id == "call_O17Uplv4lJvD6DVdIvFFeRMw" - && input == r#"{"value":"Sparkle Day"}"#) - }); - assert!( - tool_call.is_some(), - "should have ToolCall with complete input" - ); - } - - // ── should not duplicate tool calls ─────────────────────────────────────── - - /// TS: "should not duplicate tool calls when there is an additional empty - /// chunk after the tool call has been completed" - #[tokio::test] - async fn should_not_duplicate_tool_calls() { - let server = MockServer::start().await; - let body = sse_body(&[ - &sse_event( - r#"{"id":"chat-2267f7e2910a4254bac0650ba74cfc1c","object":"chat.completion.chunk","created":1733162241,"model":"meta/llama-3.1-8b-instruct:fp8","choices":[{"index":0,"delta":{"role":"assistant","content":""},"logprobs":null,"finish_reason":null}],"usage":{"prompt_tokens":226,"total_tokens":226,"completion_tokens":0}}"#, - ), - &sse_event( - r#"{"id":"chat-2267f7e2910a4254bac0650ba74cfc1c","object":"chat.completion.chunk","created":1733162241,"model":"meta/llama-3.1-8b-instruct:fp8","choices":[{"index":0,"delta":{"tool_calls":[{"id":"chatcmpl-tool-b3b307239370432d9910d4b79b4dbbaa","type":"function","index":0,"function":{"name":"searchGoogle"}}]},"logprobs":null,"finish_reason":null}],"usage":{"prompt_tokens":226,"total_tokens":233,"completion_tokens":7}}"#, - ), - &sse_event( - r#"{"id":"chat-2267f7e2910a4254bac0650ba74cfc1c","object":"chat.completion.chunk","created":1733162241,"model":"meta/llama-3.1-8b-instruct:fp8","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"query\": \""}}]},"logprobs":null,"finish_reason":null}],"usage":{"prompt_tokens":226,"total_tokens":241,"completion_tokens":15}}"#, - ), - &sse_event( - r#"{"id":"chat-2267f7e2910a4254bac0650ba74cfc1c","object":"chat.completion.chunk","created":1733162241,"model":"meta/llama-3.1-8b-instruct:fp8","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"latest"}}]},"logprobs":null,"finish_reason":null}],"usage":{"prompt_tokens":226,"total_tokens":242,"completion_tokens":16}}"#, - ), - &sse_event( - r#"{"id":"chat-2267f7e2910a4254bac0650ba74cfc1c","object":"chat.completion.chunk","created":1733162241,"model":"meta/llama-3.1-8b-instruct:fp8","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" news"}}]},"logprobs":null,"finish_reason":null}],"usage":{"prompt_tokens":226,"total_tokens":243,"completion_tokens":17}}"#, - ), - &sse_event( - r#"{"id":"chat-2267f7e2910a4254bac0650ba74cfc1c","object":"chat.completion.chunk","created":1733162241,"model":"meta/llama-3.1-8b-instruct:fp8","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" on"}}]},"logprobs":null,"finish_reason":null}],"usage":{"prompt_tokens":226,"total_tokens":244,"completion_tokens":18}}"#, - ), - &sse_event( - r#"{"id":"chat-2267f7e2910a4254bac0650ba74cfc1c","object":"chat.completion.chunk","created":1733162241,"model":"meta/llama-3.1-8b-instruct:fp8","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" ai\"}"}}]},"logprobs":null,"finish_reason":null}],"usage":{"prompt_tokens":226,"total_tokens":245,"completion_tokens":19}}"#, - ), - &sse_event( - r#"{"id":"chat-2267f7e2910a4254bac0650ba74cfc1c","object":"chat.completion.chunk","created":1733162241,"model":"meta/llama-3.1-8b-instruct:fp8","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":""}}]},"logprobs":null,"finish_reason":"tool_calls","stop_reason":128008}],"usage":{"prompt_tokens":226,"total_tokens":246,"completion_tokens":20}}"#, - ), - &sse_event( - r#"{"id":"chat-2267f7e2910a4254bac0650ba74cfc1c","object":"chat.completion.chunk","created":1733162241,"model":"meta/llama-3.1-8b-instruct:fp8","choices":[],"usage":{"prompt_tokens":226,"total_tokens":246,"completion_tokens":20}}"#, - ), - ]); - mock_sse_response(&server, &body).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("gpt-3.5-turbo"); - - let result = model - .do_stream(&options_with_tools( - test_prompt(), - vec![search_google_tool()], - )) - .await - .expect("do_stream should succeed"); - let parts = collect_stream(result).await; - - // Exactly one tool-call event - let tool_calls: Vec<_> = parts - .iter() - .filter(|p| matches!(p, StreamPart::ToolCall(_))) - .collect(); - assert_eq!(tool_calls.len(), 1, "should have exactly one ToolCall"); - - // The tool call should have the complete arguments - match tool_calls[0] { - StreamPart::ToolCall(RawToolCall { - tool_call_id, - tool_name, - input, - .. - }) => { - assert_eq!( - tool_call_id, - "chatcmpl-tool-b3b307239370432d9910d4b79b4dbbaa" - ); - assert_eq!(tool_name, "searchGoogle"); - assert_eq!(input, r#"{"query": "latest news on ai"}"#); - } - other => panic!("expected ToolCall, got {other:?}"), - } - } - - // ── should not finalize tool call early ─────────────────────────────────── - - /// TS: "should not finalize tool call early when partial JSON is - /// coincidentally parsable" - #[tokio::test] - async fn should_not_finalize_tool_call_early() { - let server = MockServer::start().await; - let body = sse_body(&[ - &sse_event( - r#"{"id":"chatcmpl-early","object":"chat.completion.chunk","created":1733162241,"model":"gpt-4","choices":[{"index":0,"delta":{"role":"assistant","content":null,"tool_calls":[{"index":0,"id":"call_early123","type":"function","function":{"name":"search","arguments":""}}]},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-early","object":"chat.completion.chunk","created":1733162241,"model":"gpt-4","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"query\": \"test\"}"}}]},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-early","object":"chat.completion.chunk","created":1733162241,"model":"gpt-4","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":""}}]},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-early","object":"chat.completion.chunk","created":1733162241,"model":"gpt-4","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-early","object":"chat.completion.chunk","created":1733162241,"model":"gpt-4","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":", \"limit\": 10}"}}]},"finish_reason":null}]}"#, - ), - ]); - mock_sse_response(&server, &body).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("gpt-3.5-turbo"); - - let result = model - .do_stream(&options_with_tools(test_prompt(), vec![search_tool()])) - .await - .expect("do_stream should succeed"); - let parts = collect_stream(result).await; - - // Exactly one tool-call event - let tool_calls: Vec<_> = parts - .iter() - .filter(|p| matches!(p, StreamPart::ToolCall(_))) - .collect(); - assert_eq!(tool_calls.len(), 1, "should have exactly one ToolCall"); - - // The tool call should contain the COMPLETE arguments, not just the - // partial JSON that happened to be parsable mid-stream. - match tool_calls[0] { - StreamPart::ToolCall(RawToolCall { input, .. }) => { - assert!( - input.contains("query") && input.contains("limit"), - "input string should contain both 'query' and 'limit': {input}" - ); - } - other => panic!("expected ToolCall, got {other:?}"), - } - } - - // ── should stream tool call with missing type field ─────────────────────── - - /// TS: "should stream tool call with missing type field (Azure AI Foundry / - /// Mistral)" - #[tokio::test] - async fn should_stream_tool_call_with_missing_type_field() { - let server = MockServer::start().await; - let body = sse_body(&[ - &sse_event( - r#"{"id":"chatcmpl-azure-001","object":"chat.completion.chunk","created":1711357598,"model":"mistral-large","choices":[{"index":0,"delta":{"role":"assistant","content":null,"tool_calls":[{"index":0,"id":"call_abc123","function":{"name":"test-tool","arguments":""}}]},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-azure-001","object":"chat.completion.chunk","created":1711357598,"model":"mistral-large","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"value\""}}]},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-azure-001","object":"chat.completion.chunk","created":1711357598,"model":"mistral-large","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":":\"hello\"}"}}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}}"#, - ), - ]); - mock_sse_response(&server, &body).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("gpt-3.5-turbo"); - - let result = model - .do_stream(&options_with_tools(test_prompt(), vec![test_tool()])) - .await - .expect("do_stream should succeed"); - let parts = collect_stream(result).await; - - // Verify tool-input-start - let tool_start = parts.iter().find(|p| { - matches!(p, StreamPart::ToolInputStart { id, tool_name, .. } - if id == "call_abc123" && tool_name == "test-tool") - }); - assert!(tool_start.is_some(), "should have ToolInputStart"); - - // Verify tool-call - let tool_call = parts.iter().find(|p| { - matches!(p, StreamPart::ToolCall(RawToolCall { tool_call_id, input, .. }) - if tool_call_id == "call_abc123" - && input == r#"{"value":"hello"}"#) - }); - assert!( - tool_call.is_some(), - "should have ToolCall with correct input" - ); - } - - // ── should stream tool call that is sent in one chunk ───────────────────── - - /// TS: "should stream tool call that is sent in one chunk" - #[tokio::test] - async fn should_stream_tool_call_in_one_chunk() { - let server = MockServer::start().await; - let body = sse_body(&[ - &sse_event( - r#"{"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1711357598,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"role":"assistant","content":null,"tool_calls":[{"index":0,"id":"call_O17Uplv4lJvD6DVdIvFFeRMw","type":"function","function":{"name":"test-tool","arguments":"{\"value\":\"Sparkle Day\"}"}}]},"logprobs":null,"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1711357598,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"tool_calls"}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1711357598,"model":"gpt-3.5-turbo-0125","system_fingerprint":"fp_3bc1b5746c","choices":[],"usage":{"prompt_tokens":53,"completion_tokens":17,"total_tokens":70}}"#, - ), - ]); - mock_sse_response(&server, &body).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("gpt-3.5-turbo"); - - let result = model - .do_stream(&options_with_tools(test_prompt(), vec![test_tool()])) - .await - .expect("do_stream should succeed"); - let parts = collect_stream(result).await; - - // Verify tool-input-start - assert!(parts.iter().any(|p| { - matches!(p, StreamPart::ToolInputStart { id, tool_name, .. } - if id == "call_O17Uplv4lJvD6DVdIvFFeRMw" && tool_name == "test-tool") - })); - - // Verify single tool-input-delta with complete arguments - let tool_deltas: Vec = parts - .iter() - .filter_map(|p| match p { - StreamPart::ToolInputDelta { delta, .. } => Some(delta.clone()), - _ => None, - }) - .collect(); - assert_eq!(tool_deltas, vec![r#"{"value":"Sparkle Day"}"#.to_string()]); - - // Verify tool-call - assert!(parts.iter().any(|p| { - matches!(p, StreamPart::ToolCall(RawToolCall { tool_call_id, input, .. }) - if tool_call_id == "call_O17Uplv4lJvD6DVdIvFFeRMw" - && input == r#"{"value":"Sparkle Day"}"#) - })); - } - - // ── should throw an api error when the first stream chunk is an error ───── - - /// TS: "should throw an api error when the first stream chunk is an error" - #[tokio::test] - async fn should_throw_on_first_chunk_error() { - let server = MockServer::start().await; - let body = format!( - "{}{}", - sse_event( - r#"{"error":{"message":"The server had an error processing your request.","type":"server_error","param":null,"code":null}}"# - ), - "data: [DONE]\n\n" - ); - mock_sse_response(&server, &body).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("gpt-3.5-turbo"); - - let result = model.do_stream(&default_options(test_prompt())).await; - - assert!(result.is_err(), "do_stream should return Err"); - match result.unwrap_err() { - AiMuxError::ApiCall(msg_d) => { - let msg = msg_d.to_string(); - assert!(msg.contains("The server had an error processing your request.")); - } - other => panic!("expected Provider error, got {other:?}"), - } - } - - // ── should preserve numeric status codes from early stream errors ──────── - - /// TS: "should preserve numeric status codes from early stream errors" - #[tokio::test] - async fn should_preserve_numeric_status_codes() { - let server = MockServer::start().await; - let body = format!( - "{}{}", - sse_event( - r#"{"error":{"message":"bad request","type":"provider_error","param":null,"code":400}}"# - ), - "data: [DONE]\n\n" - ); - mock_sse_response(&server, &body).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("gpt-3.5-turbo"); - - let result = model.do_stream(&default_options(test_prompt())).await; - - assert!(result.is_err(), "do_stream should return Err"); - match result.unwrap_err() { - AiMuxError::ApiCall(msg_d) => { - let msg = msg_d.to_string(); - assert!(msg.contains("bad request")); - assert!(msg.contains("400")); - } - other => panic!("expected Provider error, got {other:?}"), - } - } - - /// RFC-0031 §13.19b/c: a retryable error in the first SSE event is an - /// attempt failure, and the first normal event from the retry is put back - /// at the head of the returned stream rather than being swallowed. - #[tokio::test] - async fn first_sse_429_retries_and_preserves_the_retry_first_event() { - let server = MockServer::start().await; - let attempts = Arc::new(AtomicUsize::new(0)); - let responder_attempts = Arc::clone(&attempts); - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(move |_request: &Request| { - let body = if responder_attempts.fetch_add(1, Ordering::SeqCst) == 0 { - sse_body(&[&sse_event( - r#"{"error":{"message":"slow down","type":"rate_limit_error","code":429}}"#, - )]) - } else { - sse_body(&[&sse_event( - r#"{"id":"chatcmpl-retry","object":"chat.completion.chunk","created":1702657020,"model":"gpt-3.5-turbo","choices":[{"index":0,"delta":{"content":"hello"},"finish_reason":"stop"}]}"#, - )]) - }; - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .insert_header("retry-after-ms", "0") - .set_body_string(body) - }) - .mount(&server) - .await; - - let provider = - OpenAIProvider::new(OpenAIConfig::new("test-api-key").with_base_url(server.uri())); - let model = provider.model("gpt-3.5-turbo"); - let result = stream_text( - &model, - "Hello", - GenerateTextOptions { - max_retries: Some(1), - ..Default::default() - }, - ) - .await - .expect("the second stream attempt should succeed"); - let parts: Vec<_> = result - .stream - .map(|part| part.expect("Core stream part should succeed")) - .collect() - .await; - - assert_eq!(attempts.load(Ordering::SeqCst), 2); - assert_eq!(text_deltas(&parts), vec!["hello"]); - } - - #[tokio::test] - async fn first_sse_body_transport_error_enters_core_retry() { - use std::time::Duration; - - use tokio::io::{AsyncReadExt, AsyncWriteExt}; - use tokio::net::TcpListener; - - async fn read_request(socket: &mut tokio::net::TcpStream) { - let mut request = Vec::new(); - let mut buffer = [0_u8; 4096]; - let header_end = loop { - let count = socket.read(&mut buffer).await.unwrap(); - assert_ne!(count, 0, "client closed before sending request headers"); - request.extend_from_slice(&buffer[..count]); - if let Some(offset) = request.windows(4).position(|part| part == b"\r\n\r\n") { - break offset + 4; - } - }; - let headers = String::from_utf8_lossy(&request[..header_end]); - let content_length = headers - .lines() - .find_map(|line| { - let (name, value) = line.split_once(':')?; - name.eq_ignore_ascii_case("content-length") - .then(|| value.trim().parse::().ok()) - .flatten() - }) - .unwrap_or(0); - while request.len() - header_end < content_length { - let count = socket.read(&mut buffer).await.unwrap(); - if count == 0 { - break; - } - request.extend_from_slice(&buffer[..count]); - } - } - - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let base_url = format!("http://{}", listener.local_addr().unwrap()); - let attempts = Arc::new(AtomicUsize::new(0)); - let observed_attempts = Arc::clone(&attempts); - let server = tokio::spawn(async move { - for attempt in 0..2 { - let (mut socket, _) = listener.accept().await.unwrap(); - observed_attempts.fetch_add(1, Ordering::SeqCst); - read_request(&mut socket).await; - if attempt == 0 { - socket - .write_all( - b"HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\ncontent-length: 10000\r\nconnection: close\r\n\r\ndata: {\"id\":\"partial", - ) - .await - .unwrap(); - } else { - let body = sse_body(&[&sse_event( - r#"{"id":"chatcmpl-retry","object":"chat.completion.chunk","created":1702657020,"model":"gpt-3.5-turbo","choices":[{"index":0,"delta":{"content":"retried"},"finish_reason":"stop"}]}"#, - )]); - let response = format!( - "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", - body.len() - ); - socket.write_all(response.as_bytes()).await.unwrap(); - } - socket.shutdown().await.unwrap(); - } - }); - - let provider = OpenAIProvider::new( - OpenAIConfig::new("test-api-key") - .with_base_url(base_url) - .with_retry_config(aimux_core::retry::RetryConfig { - max_retries: 1, - initial_delay: Duration::ZERO, - backoff_factor: 2, - }), - ); - let result = stream_text( - &provider.model("gpt-3.5-turbo"), - "Hello", - GenerateTextOptions { - max_retries: Some(1), - ..Default::default() - }, - ) - .await - .expect("the body transport failure should be retried"); - - let attempt_count = attempts.load(Ordering::SeqCst); - if attempt_count == 2 { - server.await.unwrap(); - } else { - server.abort(); - } - assert_eq!( - attempt_count, 2, - "first body transport error was not retried" - ); - let parts: Vec<_> = result - .stream - .map(|part| part.expect("Core stream part should succeed")) - .collect() - .await; - assert_eq!(text_deltas(&parts), vec!["retried"]); - } - - // ── should forward error stream parts after output has started ──────────── - - /// TS: "should forward error stream parts after output has started" - #[tokio::test] - async fn should_forward_error_after_output_started() { - let server = MockServer::start().await; - let body = sse_body(&[ - &sse_event( - r#"{"id":"chatcmpl-error-after-output","object":"chat.completion.chunk","created":1702657020,"model":"gpt-3.5-turbo-0613","system_fingerprint":null,"choices":[{"index":0,"delta":{"role":"assistant","content":"Hello"},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"error":{"message":"stream failed after output","type":"server_error","param":null,"code":null}}"#, - ), - ]); - mock_sse_response(&server, &body).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("gpt-3.5-turbo"); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed (error is in-stream)"); - let parts = collect_stream(result).await; - - // Should have: stream-start, response-metadata, text-start, text-delta, - // Error, text-end, Finish (with error finish reason) - assert!(matches!(parts[0], StreamPart::StreamStart { .. })); - - // Text delta "Hello" - let deltas = text_deltas(&parts); - assert_eq!(deltas, vec!["Hello".to_string()]); - - // Error part - let error_part = parts.iter().find(|p| matches!(p, StreamPart::Error { .. })); - assert!(error_part.is_some(), "should have Error part"); - - // Finish with error finish reason - let finish = parts - .iter() - .find(|p| matches!(p, StreamPart::Finish { .. })); - match finish { - Some(StreamPart::Finish { finish_reason, .. }) => { - assert_eq!(finish_reason.unified, FinishReasonUnified::Error); - } - other => panic!("expected Finish, got {other:?}"), - } - } - - // ── should handle unparsable stream parts ───────────────────────────────── - - /// TS: "should handle unparsable stream parts" - #[tokio::test] - async fn should_handle_unparsable_stream_parts() { - let server = MockServer::start().await; - let body = sse_body(&[ - &sse_event("{unparsable}"), - &sse_event( - r#"{"id":"chatcmpl-after-error","object":"chat.completion.chunk","created":1,"model":"gpt-3.5-turbo","choices":[{"index":0,"delta":{"content":"after-error"},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-after-error","object":"chat.completion.chunk","created":1,"model":"gpt-3.5-turbo","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}"#, - ), - ]); - mock_sse_response(&server, &body).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("gpt-3.5-turbo"); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - let outcomes: Vec<_> = result.stream.collect().await; - - assert!(matches!( - outcomes.first(), - Some(Ok(StreamPart::StreamStart { .. })) - )); - let error_index = outcomes - .iter() - .position(|outcome| { - matches!( - outcome, - Err(AiMuxError::JsonParse(_) | AiMuxError::InvalidResponseData(_)) - ) - }) - .expect("malformed frame should surface as a parse error item"); - assert!(outcomes[error_index + 1..].iter().any(|outcome| matches!( - outcome, - Ok(StreamPart::TextDelta { delta, .. }) if delta == "after-error" - ))); - assert!(outcomes.iter().any(|outcome| matches!( - outcome, - Ok(StreamPart::Finish { finish_reason, .. }) - if finish_reason.unified == FinishReasonUnified::Stop - ))); - } - - // ── should expose the raw response headers ──────────────────────────────── - - /// TS: "should expose the raw response headers" (streaming) - #[tokio::test] - async fn should_expose_raw_response_headers_stream() { - let server = MockServer::start().await; - let body = sse_body(&[ - &sse_event( - r#"{"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1702657020,"model":"gpt-3.5-turbo-0613","system_fingerprint":null,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1702657020,"model":"gpt-3.5-turbo-0613","system_fingerprint":null,"choices":[{"index":0,"delta":{},"finish_reason":"stop","logprobs":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1702657020,"model":"gpt-3.5-turbo-0613","system_fingerprint":"fp_3bc1b5746c","choices":[],"usage":{"prompt_tokens":17,"total_tokens":244,"completion_tokens":227}}"#, - ), - ]); - mock_sse_response_with_headers(&server, &body, &[("test-header", "test-value")]).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("gpt-3.5-turbo"); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - - let headers = result - .response_headers - .as_ref() - .expect("response_headers should be Some"); - assert_eq!(headers.get("test-header"), Some(&"test-value".to_string())); - } - - // ── should return cached tokens in providerMetadata ─────────────────────── - - /// TS: "should return cached tokens in providerMetadata" - #[tokio::test] - async fn should_return_cached_tokens() { - let server = MockServer::start().await; - let body = sse_body(&[ - &sse_event( - r#"{"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1702657020,"model":"gpt-3.5-turbo-0613","system_fingerprint":null,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1702657020,"model":"gpt-3.5-turbo-0613","system_fingerprint":null,"choices":[{"index":0,"delta":{},"finish_reason":"stop","logprobs":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1702657020,"model":"gpt-3.5-turbo-0613","system_fingerprint":"fp_3bc1b5746c","choices":[],"usage":{"prompt_tokens":2000,"completion_tokens":20,"total_tokens":2020,"prompt_tokens_details":{"cached_tokens":1152,"cache_write_tokens":256}}}"#, - ), - ]); - mock_sse_response(&server, &body).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("gpt-3.5-turbo"); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - let parts = collect_stream(result).await; - - let finish = parts - .iter() - .find(|p| matches!(p, StreamPart::Finish { .. })); - match finish { - Some(StreamPart::Finish { usage, .. }) => { - // TS: inputTokens.total=2000, cacheRead=1152, cacheWrite=256, noCache=592 - assert_eq!(usage.input_tokens.total, Some(2000)); - assert_eq!(usage.input_tokens.cache_read, Some(1152)); - assert_eq!(usage.input_tokens.cache_write, Some(256)); - assert_eq!(usage.input_tokens.no_cache, Some(592)); - // TS: outputTokens.total=20 - assert_eq!(usage.output_tokens.total, Some(20)); - } - other => panic!("expected Finish, got {other:?}"), - } - } - - // ── reasoning models: should stream text delta ──────────────────────────── - - /// TS: reasoning models → "should stream text delta" - #[tokio::test] - async fn reasoning_models_should_stream_text_delta() { - let server = MockServer::start().await; - let body = sse_body(&[ - &sse_event( - r#"{"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1702657020,"model":"o4-mini","system_fingerprint":null,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1702657020,"model":"o4-mini","system_fingerprint":null,"choices":[{"index":1,"delta":{"content":"Hello, World!"},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1702657020,"model":"o4-mini","system_fingerprint":null,"choices":[{"index":0,"delta":{},"finish_reason":"stop","logprobs":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1702657020,"model":"o4-mini","system_fingerprint":"fp_3bc1b5746c","choices":[],"usage":{"prompt_tokens":17,"total_tokens":244,"completion_tokens":227}}"#, - ), - ]); - mock_sse_response(&server, &body).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("o4-mini"); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - let parts = collect_stream(result).await; - - // Verify stream-start - assert!(matches!(parts[0], StreamPart::StreamStart { .. })); - - // Verify response-metadata with modelId "o4-mini" - match &parts[1] { - StreamPart::ResponseMetadata(ResponseMetadata { model_id, .. }) => { - assert_eq!(model_id.as_deref(), Some("o4-mini")); - } - other => panic!("expected ResponseMetadata, got {other:?}"), - } - - // Verify text deltas - let deltas = text_deltas(&parts); - assert_eq!(deltas, vec!["".to_string(), "Hello, World!".to_string()]); - - // Verify finish with stop reason - let finish = parts - .iter() - .find(|p| matches!(p, StreamPart::Finish { .. })); - match finish { - Some(StreamPart::Finish { - finish_reason, - usage, - .. - }) => { - assert_eq!(finish_reason.unified, FinishReasonUnified::Stop); - assert_eq!(usage.input_tokens.total, Some(17)); - assert_eq!(usage.output_tokens.total, Some(227)); - } - other => panic!("expected Finish, got {other:?}"), - } - - // RFC-0016 M10: streaming usage also carries the provider raw usage. - if let Some(StreamPart::Finish { usage, .. }) = finish { - let raw = usage.raw.as_ref().expect("usage.raw must be populated"); - assert_eq!(raw["prompt_tokens"], json!(17)); - assert_eq!(raw["completion_tokens"], json!(227)); - } - } - - // ── RFC-0016 M2: includeRawChunks ─────────────────────────────────────── - - /// RFC-0016 M2: `include_raw_chunks` emits one `StreamPart::Raw` per JSON - /// SSE event (before the parsed parts); default is off. - #[tokio::test] - async fn should_emit_raw_chunks_when_enabled() { - let server = MockServer::start().await; - let body = sse_body(&[ - &sse_event( - r#"{"id":"c1","object":"chat.completion.chunk","created":1,"model":"gpt-3.5-turbo","choices":[{"index":0,"delta":{"content":"Hi"},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"c1","object":"chat.completion.chunk","created":1,"model":"gpt-3.5-turbo","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}"#, - ), - ]); - mock_sse_response(&server, &body).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("gpt-3.5-turbo"); - - let options = CallOptions { - include_raw_chunks: Some(true), - ..default_options(test_prompt()) - }; - let result = model - .do_stream(&options) - .await - .expect("do_stream should succeed"); - let parts = collect_stream(result).await; - - let raw: Vec<&Value> = parts - .iter() - .filter_map(|p| match p { - StreamPart::Raw { raw_value } => Some(raw_value), - _ => None, - }) - .collect(); - assert_eq!(raw.len(), 2, "one Raw part per JSON SSE event"); - assert_eq!(raw[0]["choices"][0]["delta"]["content"], json!("Hi")); - assert_eq!(raw[1]["usage"]["prompt_tokens"], json!(1)); - } - - /// RFC-0016 M2: default (`None`) emits no `StreamPart::Raw`. - #[tokio::test] - async fn raw_chunks_off_by_default() { - let server = MockServer::start().await; - let body = sse_body(&[ - &sse_event( - r#"{"id":"c1","object":"chat.completion.chunk","created":1,"model":"gpt-3.5-turbo","choices":[{"index":0,"delta":{"content":"Hi"},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"c1","object":"chat.completion.chunk","created":1,"model":"gpt-3.5-turbo","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}"#, - ), - ]); - mock_sse_response(&server, &body).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("gpt-3.5-turbo"); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - let parts = collect_stream(result).await; - - assert!( - parts.iter().all(|p| !matches!(p, StreamPart::Raw { .. })), - "no Raw parts expected by default" - ); - } - - /// RFC-0016 M2: an unparsable chunk emits an error item (no `Raw`) without - /// preventing later independently framed SSE data from being reduced. - #[tokio::test] - async fn raw_chunks_unparsable_chunk_emits_error_and_continues() { - let server = MockServer::start().await; - let body = sse_body(&[ - &sse_event( - r#"{"id":"c1","object":"chat.completion.chunk","created":1,"model":"gpt-3.5-turbo","choices":[{"index":0,"delta":{"content":"Hi"},"finish_reason":null}]}"#, - ), - // Unparsable chunk: no Raw for it, but the stream remains usable. - "data: not-json\n\n", - &sse_event( - r#"{"id":"c2","object":"chat.completion.chunk","created":1,"model":"gpt-3.5-turbo","choices":[{"index":0,"delta":{"content":"late"},"finish_reason":null}]}"#, - ), - ]); - mock_sse_response(&server, &body).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("gpt-3.5-turbo"); - - let options = CallOptions { - include_raw_chunks: Some(true), - ..default_options(test_prompt()) - }; - let result = model - .do_stream(&options) - .await - .expect("do_stream should succeed"); - let outcomes: Vec<_> = result.stream.collect().await; - - // Each valid JSON chunk emits Raw; the unparsable chunk does not. - let raw: Vec<&Value> = outcomes - .iter() - .filter_map(|outcome| match outcome { - Ok(p) => match p { - StreamPart::Raw { raw_value } => Some(raw_value), - _ => None, - }, - Err(_) => None, - }) - .collect(); - assert_eq!(raw.len(), 2, "no Raw for the unparsable chunk"); - assert_eq!(raw[0]["choices"][0]["delta"]["content"], json!("Hi")); - assert_eq!(raw[1]["choices"][0]["delta"]["content"], json!("late")); - - let err_pos = outcomes - .iter() - .position(|outcome| matches!(outcome, Err(AiMuxError::JsonParse(_)))) - .expect("unparsable chunk must surface as a JSON parse error item"); - assert!( - outcomes[err_pos + 1..].iter().any(|outcome| matches!( - outcome, - Ok(StreamPart::TextDelta { delta, .. }) if delta == "late" - )), - "a later valid event must still be reduced" - ); - } - - /// RFC-0016 M2: `[DONE]` sentinel emits no Raw; a mid-stream error chunk - /// emits Raw before Error (debugging order). - #[tokio::test] - async fn raw_chunks_done_skipped_and_error_preceded_by_raw() { - let server = MockServer::start().await; - let body = sse_body(&[ - &sse_event( - r#"{"id":"c1","object":"chat.completion.chunk","created":1,"model":"gpt-3.5-turbo","choices":[{"index":0,"delta":{"content":"Hi"},"finish_reason":null}]}"#, - ), - // Error chunk: Raw must precede the Error part for this event. - &sse_event( - r#"{"error":{"message":"mid-stream failure","type":"server_error","code":"500"}}"#, - ), - // [DONE] sentinel is skipped by the early break. - "data: [DONE]\n\n", - ]); - mock_sse_response(&server, &body).await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("gpt-3.5-turbo"); - - let options = CallOptions { - include_raw_chunks: Some(true), - ..default_options(test_prompt()) - }; - let result = model - .do_stream(&options) - .await - .expect("do_stream should succeed"); - let parts = collect_stream(result).await; - - // Raw for the content chunk + the error chunk; [DONE] emits none. - let raw: Vec<&Value> = parts - .iter() - .filter_map(|p| match p { - StreamPart::Raw { raw_value } => Some(raw_value), - _ => None, - }) - .collect(); - assert_eq!( - raw.len(), - 2, - "expected Raw for the content chunk + error chunk" - ); - - // Raw for the error chunk precedes its Error part. - let err_pos = parts - .iter() - .position(|p| matches!(p, StreamPart::Error { .. })) - .expect("mid-stream error must surface as Error part"); - assert!( - matches!( - &parts[err_pos - 1], - StreamPart::Raw { raw_value } - if raw_value["error"]["message"] == "mid-stream failure" - ), - "Raw must immediately precede Error for the error chunk" - ); - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// RFC-0016 H1/H3: abort + per-call timeout (provider-level) -// ════════════════════════════════════════════════════════════════════════════ - -use aimux_core::AbortSignal; -use aimux_core::options::TimeoutConfiguration; -use std::time::Duration; - -/// TS: timeout — total_ms bounds the whole call. -#[tokio::test] -async fn total_timeout_aborts_slow_generate() { - let server = MockServer::start().await; - mock_json_response_with_delay( - &server, - json!({ - "id": "chatcmpl-1", - "object": "chat.completion", - "created": 1711115037, - "model": "gpt-3.5-turbo", - "choices": [{ - "index": 0, - "message": { "role": "assistant", "content": "Hello" }, - "finish_reason": "stop" - }], - "usage": { "prompt_tokens": 4, "total_tokens": 6, "completion_tokens": 2 } - }), - Duration::from_millis(500), - ) - .await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("gpt-3.5-turbo"); - - let options = GenerateTextOptions { - timeout: Some(TimeoutConfiguration { - total_ms: Some(100), - ..Default::default() - }), - ..Default::default() - }; - - let err = generate_text(&model, "Hello", options) - .await - .expect_err("slow response must be cut by total timeout"); - assert!(matches!(err, AiMuxError::Timeout(_)), "got {err:?}"); -} - -/// TS: abort signal — aborting mid-request cancels do_generate. -#[tokio::test] -async fn abort_signal_cancels_in_flight_generate() { - let server = MockServer::start().await; - mock_json_response_with_delay( - &server, - json!({ - "id": "chatcmpl-1", - "object": "chat.completion", - "created": 1711115037, - "model": "gpt-3.5-turbo", - "choices": [{ - "index": 0, - "message": { "role": "assistant", "content": "Hello" }, - "finish_reason": "stop" - }], - "usage": { "prompt_tokens": 4, "total_tokens": 6, "completion_tokens": 2 } - }), - Duration::from_millis(300), - ) - .await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("gpt-3.5-turbo"); - - let signal = AbortSignal::new(); - let signal_clone = signal.clone(); - let mut options = default_options(test_prompt()); - options.abort_signal = Some(signal_clone); - - let handle = tokio::spawn(async move { model.do_generate(&options).await }); - - tokio::time::sleep(Duration::from_millis(50)).await; - signal.abort(); - - let result = handle.await.expect("task must finish"); - assert!( - matches!(result, Err(AiMuxError::Aborted(_))), - "aborted call must fail with Aborted, got {result:?}" - ); -} - -/// TS: abort before send fails fast. -#[tokio::test] -async fn abort_before_send_fails_fast() { - let server = MockServer::start().await; - mock_json_response( - &server, - json!({ - "id": "chatcmpl-1", - "object": "chat.completion", - "created": 1711115037, - "model": "gpt-3.5-turbo", - "choices": [{ - "index": 0, - "message": { "role": "assistant", "content": "Hello" }, - "finish_reason": "stop" - }], - "usage": { "prompt_tokens": 4, "total_tokens": 6, "completion_tokens": 2 } - }), - ) - .await; - - let config = OpenAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("gpt-3.5-turbo"); - - let signal = AbortSignal::new(); - signal.abort(); - let mut options = default_options(test_prompt()); - options.abort_signal = Some(signal); - - let err = model - .do_generate(&options) - .await - .expect_err("pre-aborted signal must fail fast"); - assert!(matches!(err, AiMuxError::Aborted(_)), "got {err:?}"); -} - -// ════════════════════════════════════════════════════════════════════════════ -// Remaining untranslated cases -// ════════════════════════════════════════════════════════════════════════════ -// -// doGenerate: -// - "should parse annotations/citations" — requires a Source content type not -// present in the Rust `GenerateContent` enum. -// - "should send request body" / "should pass settings" / "should pass tools and -// toolChoice" / "should pass headers" / response-format / reasoning-effort / -// textVerbosity — these are request-body assertions already covered by -// `openai_convert_test.rs`. -// -// doStream: -// - "should stream annotations/citations" — requires Source content type. -// - "should return accepted_prediction_tokens and rejected_prediction_tokens" — -// requires `providerMetadata.openai` prediction-token fields (the Rust -// `ProviderMetadata` is a flat `Value`; the TS nests under `openai`). -// - "should send request body" / "should pass the messages and the model" / -// "should pass headers" / "should send store extension setting" / "should send -// metadata extension values" / "should send serviceTier …" — request-body -// assertions already covered by `openai_convert_test.rs`. -// - "should set .modelId for model-router request" — uses fixture files. -// - "reasoning models → should send reasoning tokens" — requires -// `outputTokens.reasoning` / `outputTokens.text` breakdown not present in the -// Rust `TokenUsage` struct. -// - "raw chunks" (includeRawChunks) — requires an `include_raw_chunks` option -// not present in the Rust `CallOptions`. diff --git a/aimux-providers/tests/openai_provider_test.rs b/aimux-providers/tests/openai_provider_test.rs deleted file mode 100644 index 4dcfe790b..000000000 --- a/aimux-providers/tests/openai_provider_test.rs +++ /dev/null @@ -1,282 +0,0 @@ -//! OpenAI provider configuration and forward-compatible defaults tests. -//! -//! Translates the chat-completions-relevant parts of: -//! - `packages/openai/src/openai-provider.test.ts` — baseURL config, chat routing -//! - `packages/openai/src/openai-forward-compatible-defaults.test.ts` — reasoning-safe defaults -//! -//! Responses API / embedding / image parts are excluded (covered by B1/C1/C2). - -use serde_json::{Value, json}; -use serial_test::serial; -use wiremock::matchers::{method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -use aimux_core::content::ContentPart; -use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; -use aimux_core::options::CallOptions; -use aimux_core::provider::Provider; - -use aimux_providers::{OpenAIConfig, OpenAIProvider}; - -// ── helpers ─────────────────────────────────────────────────────────────────── - -fn test_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] -} - -fn default_options(prompt: LanguageModelPrompt) -> CallOptions { - CallOptions::new(prompt) -} - -fn text_completion_body() -> Value { - json!({ - "id": "chatcmpl-test", - "object": "chat.completion", - "created": 1711115037, - "model": "test-model", - "choices": [{ - "index": 0, - "message": { "role": "assistant", "content": "ok" }, - "finish_reason": "stop" - }], - "usage": { "prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2 } - }) -} - -// ════════════════════════════════════════════════════════════════════════════ -// Provider configuration (openai-provider.test.ts) -// ════════════════════════════════════════════════════════════════════════════ - -mod provider_config { - use super::*; - - /// TS: `createOpenAI()` provider name is "openai". - #[test] - fn provider_name_is_openai() { - let config = OpenAIConfig::new("test-key"); - let provider = OpenAIProvider::new(config); - assert_eq!(provider.name(), "openai"); - } - - /// TS: default base URL is `https://api.openai.com/v1`. - #[test] - fn default_base_url() { - let config = OpenAIConfig::new("test-key"); - assert_eq!(config.base_url, "https://api.openai.com/v1"); - } - - /// TS: custom base URL overrides the default. - #[test] - fn custom_base_url_override() { - let config = - OpenAIConfig::new("test-key").with_base_url("https://proxy.openai.example/v1/"); - // `with_base_url` strips trailing slash. - assert_eq!(config.base_url, "https://proxy.openai.example/v1"); - } - - /// TS: `from_env` loads `OPENAI_API_KEY`. - #[serial] - #[test] - fn from_env_loads_openai_api_key() { - let saved = std::env::var("OPENAI_API_KEY").ok(); - unsafe { std::env::set_var("OPENAI_API_KEY", "env-test-key") }; - - let config = OpenAIConfig::from_env(); - assert!(config.is_ok(), "from_env should succeed with env var set"); - - unsafe { - match saved { - Some(v) => std::env::set_var("OPENAI_API_KEY", v), - None => std::env::remove_var("OPENAI_API_KEY"), - } - } - } - - /// TS: `from_env` fails without the env var. - #[serial] - #[test] - fn from_env_fails_without_env_var() { - let saved = std::env::var("OPENAI_API_KEY").ok(); - unsafe { std::env::remove_var("OPENAI_API_KEY") }; - - let config = OpenAIConfig::from_env(); - assert!(config.is_err(), "from_env should fail without env var"); - - unsafe { - if let Some(v) = saved { - std::env::set_var("OPENAI_API_KEY", v); - } - } - } - - /// TS: chat completions API routes to `/chat/completions`. - #[tokio::test] - async fn chat_routes_to_chat_completions() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let config = OpenAIConfig::new("test-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("gpt-4o-mini"); - - model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - } - - /// TS: custom API key is sent in Authorization header. - #[tokio::test] - async fn custom_api_key_in_auth_header() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let config = OpenAIConfig::new("my-custom-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("gpt-4o"); - - let _ = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - let requests = server.received_requests().await.expect("requests recorded"); - assert_eq!(requests.len(), 1); - assert_eq!( - requests[0] - .headers - .get("authorization") - .and_then(|v| v.to_str().ok()), - Some("Bearer my-custom-key"), - ); - } - - /// TS: `languageModel` via Provider trait creates a working model. - #[tokio::test] - async fn language_model_via_trait() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let config = OpenAIConfig::new("test-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider - .language_model("gpt-4o") - .expect("language_model should succeed"); - - model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - } - - /// TS: org ID is sent in the OpenAI-Organization header. - #[tokio::test] - async fn org_id_in_header() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let config = OpenAIConfig::new("test-key") - .with_base_url(server.uri()) - .with_org_id("org-123"); - let provider = OpenAIProvider::new(config); - let model = provider.model("gpt-4o"); - - let _ = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - let requests = server.received_requests().await.expect("requests recorded"); - assert_eq!(requests.len(), 1); - assert_eq!( - requests[0] - .headers - .get("openai-organization") - .and_then(|v| v.to_str().ok()), - Some("org-123"), - ); - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// Forward-compatible defaults (openai-forward-compatible-defaults.test.ts) -// ════════════════════════════════════════════════════════════════════════════ - -mod forward_compatible_defaults { - use super::*; - - /// TS: gpt-99 (reasoning model) should use reasoning-safe Chat Completions - /// defaults: system→developer, max_completion_tokens instead of max_tokens, - /// temperature/top_p/penalties/logit_bias/logprobs stripped. - /// - /// We verify by checking the request body sent to the mock server. - #[tokio::test] - async fn reasoning_model_uses_safe_defaults() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let config = OpenAIConfig::new("test-key").with_base_url(server.uri()); - let provider = OpenAIProvider::new(config); - let model = provider.model("o1"); - - let prompt = vec![ - LanguageModelPromptMessage { - role: Role::System, - content: vec![ContentPart::text("Follow the instructions.")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Say ok.")], - ..Default::default() - }, - ]; - let mut options = CallOptions::new(prompt); - options.max_output_tokens = Some(64); - options.temperature = Some(0.2); - options.top_p = Some(0.8); - - let _ = model.do_generate(&options).await.expect("should succeed"); - - let requests = server.received_requests().await.expect("requests recorded"); - assert_eq!(requests.len(), 1); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - - // Reasoning models use `max_completion_tokens` not `max_tokens`. - assert!( - body.get("max_completion_tokens").is_some(), - "should use max_completion_tokens for reasoning models" - ); - // System message should be converted to developer for reasoning models. - assert_eq!( - body["messages"][0]["role"], "developer", - "system should become developer for reasoning models" - ); - } -} diff --git a/aimux-providers/tests/openai_responses_test.rs b/aimux-providers/tests/openai_responses_test.rs deleted file mode 100644 index 527aa2dbc..000000000 --- a/aimux-providers/tests/openai_responses_test.rs +++ /dev/null @@ -1,1485 +0,0 @@ -//! Rust translations of the AI SDK OpenAI Responses API tests. -//! -//! Sources (TS -> Rust): -//! - `packages/openai/src/responses/openai-responses-language-model.test.ts` -//! `describe('doGenerate')` request-body + response-parsing cases -//! - `packages/openai/src/responses/openai-responses-language-model.test.ts` -//! `describe('doStream')` streaming cases -//! -//! Covers the core paths: -//! 1. Request building: input array, instructions, store, previous_response_id, -//! reasoning (effort/summary), response_format (json_schema/json_object) -//! 2. Stream parsing: response.created -> output_item.added -> output_text.delta -//! -> output_text.done -> output_item.done -> response.completed -//! 3. function tool calls (function_call_arguments.delta/done) -//! 4. reasoning summary streaming -//! -//! Each test uses `wiremock` to spin up a mock HTTP server, configures a JSON -//! or SSE response, creates an `OpenAIResponsesModel` pointing at the mock, -//! calls `do_generate` / `do_stream`, and asserts on the result. - -use aimux_core::tool::RawToolCall; -use futures::StreamExt; -use serde_json::{Value, json}; -use wiremock::matchers::{method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -use aimux_core::content::ContentPart; -use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; -use aimux_core::options::{CallOptions, Tool, ToolChoice}; -use aimux_core::result::{GenerateContent, ReasoningOutput, StreamResult}; -use aimux_core::shared::provider_namespace; -use aimux_core::stream_part::StreamPart; -use aimux_core::tool::FunctionTool; -use aimux_core::types::{FinishReasonUnified, ResponseMetadata}; - -use aimux_providers::{OpenAIConfig, OpenAIProvider}; - -// -- helpers ----------------------------------------------------------------- - -/// The TS `TEST_PROMPT`: a single user text message "Hello". -fn test_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] -} - -/// Build `CallOptions` with everything unset except `prompt`. -fn default_options(prompt: LanguageModelPrompt) -> CallOptions { - CallOptions::new(prompt) -} - -/// A simple function tool named `weather`. -fn weather_tool() -> FunctionTool { - FunctionTool { - name: "weather".to_string(), - description: None, - input_schema: json!({ - "type": "object", - "properties": { "location": { "type": "string" } }, - "required": ["location"], - "additionalProperties": false, - }), - strict: None, - provider_options: None, - input_examples: None, - } -} - -/// Standard mock for a JSON responses-api response. -async fn mock_json_response(server: &MockServer, body: Value) { - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(body)) - .mount(server) - .await; -} - -/// Standard mock for an SSE streaming response. -async fn mock_sse_response(server: &MockServer, sse_body: &str) { - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(sse_body.to_string()), - ) - .mount(server) - .await; -} - -/// Build an SSE event string from a JSON value. -fn sse_event(json_str: &str) -> String { - format!("data: {json_str}\n\n") -} - -/// Concatenate SSE events and append `[DONE]`. -fn sse_body(events: &[&str]) -> String { - let mut body = String::new(); - for event in events { - body.push_str(event); - } - body.push_str("data: [DONE]\n\n"); - body -} - -/// Collect all `StreamPart`s from a `StreamResult` into a `Vec`. -async fn collect_stream(result: StreamResult) -> Vec { - let mut parts = Vec::new(); - let mut stream = result.stream; - while let Some(part) = stream.next().await { - match part { - Ok(p) => parts.push(p), - Err(e) => panic!("stream error: {e:?}"), - } - } - parts -} - -/// Extract the first recorded request body as a JSON value. -async fn first_request_body(server: &MockServer) -> Value { - let requests = server - .received_requests() - .await - .expect("no requests received"); - serde_json::from_slice(&requests[0].body).expect("invalid JSON body") -} - -/// A basic text response body (single message output item). -fn text_response_body() -> Value { - json!({ - "id": "resp_67c97c0203188190a025beb4a75242bc", - "object": "response", - "created_at": 1741257730, - "status": "completed", - "error": null, - "incomplete_details": null, - "model": "gpt-4o-2024-07-18", - "output": [ - { - "id": "msg_67c97c02656c81908e080dfdf4a03cd1", - "type": "message", - "status": "completed", - "role": "assistant", - "content": [ - { - "type": "output_text", - "text": "answer text", - "annotations": [] - } - ] - } - ], - "usage": { - "input_tokens": 345, - "input_tokens_details": { - "cached_tokens": 234, - "cache_write_tokens": 45 - }, - "output_tokens": 538, - "output_tokens_details": { - "reasoning_tokens": 123 - } - }, - "reasoning": { "effort": null, "summary": null, "context": "current_turn" } - }) -} - -// ============================================================================ -// doGenerate -- request body construction -// (openai-responses-language-model.test.ts -> describe('doGenerate')) -// ============================================================================ - -mod do_generate_request { - use super::*; - - // -- should send model id, settings, and input -- - - /// TS: "should send model id, settings, and input" - #[tokio::test] - async fn should_send_model_id_settings_and_input() { - let server = MockServer::start().await; - mock_json_response(&server, text_response_body()).await; - - let config = OpenAIConfig::new("test-key").with_base_url(server.uri()); - let model = OpenAIProvider::new(config).responses_model("gpt-4o"); - - let prompt = vec![ - LanguageModelPromptMessage { - role: Role::System, - content: vec![ContentPart::text("You are a helpful assistant.")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }, - ]; - let options = CallOptions { - temperature: Some(0.5), - top_p: Some(0.3), - provider_options: Some(provider_namespace("openai", json!({ "maxToolCalls": 10 }))), - ..CallOptions::new(prompt) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - assert!(result.warnings.is_empty()); - - let body = first_request_body(&server).await; - assert_eq!(body["model"], "gpt-4o"); - assert_eq!(body["input"][0]["role"], "system"); - assert_eq!(body["input"][0]["content"], "You are a helpful assistant."); - assert_eq!(body["input"][1]["role"], "user"); - assert_eq!(body["input"][1]["content"][0]["type"], "input_text"); - assert_eq!(body["input"][1]["content"][0]["text"], "Hello"); - assert_eq!(body["temperature"], 0.5); - assert_eq!(body["top_p"], 0.3); - assert_eq!(body["max_tool_calls"], 10); - } - - // -- should send response format json schema -- - - /// TS: "should send response format json schema" - #[tokio::test] - async fn should_send_response_format_json_schema() { - let server = MockServer::start().await; - mock_json_response(&server, text_response_body()).await; - - let config = OpenAIConfig::new("test-key").with_base_url(server.uri()); - let model = OpenAIProvider::new(config).responses_model("gpt-4o"); - - let options = CallOptions { - response_format: Some(aimux_core::options::ResponseFormat::Json { - schema: Some(json!({ - "type": "object", - "properties": { "value": { "type": "string" } }, - "required": ["value"], - "additionalProperties": false, - })), - name: Some("response".to_string()), - description: Some("A response".to_string()), - }), - ..CallOptions::new(test_prompt()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - assert!(result.warnings.is_empty()); - - let body = first_request_body(&server).await; - assert_eq!(body["text"]["format"]["type"], "json_schema"); - assert_eq!(body["text"]["format"]["strict"], true); - assert_eq!(body["text"]["format"]["name"], "response"); - assert_eq!(body["text"]["format"]["description"], "A response"); - assert_eq!(body["text"]["format"]["schema"]["type"], "object"); - } - - // -- should send response format json object -- - - /// TS: "should send response format json object" - #[tokio::test] - async fn should_send_response_format_json_object() { - let server = MockServer::start().await; - mock_json_response(&server, text_response_body()).await; - - let config = OpenAIConfig::new("test-key").with_base_url(server.uri()); - let model = OpenAIProvider::new(config).responses_model("gpt-4o"); - - let options = CallOptions { - response_format: Some(aimux_core::options::ResponseFormat::Json { - schema: None, - name: None, - description: None, - }), - ..CallOptions::new(test_prompt()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - assert!(result.warnings.is_empty()); - - let body = first_request_body(&server).await; - assert_eq!(body["text"]["format"]["type"], "json_object"); - } - - // -- should remove unsupported settings for o1 -- - - /// TS: "should remove unsupported settings for o1" - #[tokio::test] - async fn should_remove_unsupported_settings_for_o1() { - let server = MockServer::start().await; - mock_json_response(&server, text_response_body()).await; - - let config = OpenAIConfig::new("test-key").with_base_url(server.uri()); - let model = OpenAIProvider::new(config).responses_model("o1"); - - let prompt = vec![ - LanguageModelPromptMessage { - role: Role::System, - content: vec![ContentPart::text("You are a helpful assistant.")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }, - ]; - let options = CallOptions { - temperature: Some(0.5), - top_p: Some(0.3), - ..CallOptions::new(prompt) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - - let body = first_request_body(&server).await; - assert_eq!(body["model"], "o1"); - // system message uses "developer" role for reasoning models - assert_eq!(body["input"][0]["role"], "developer"); - assert_eq!(body["input"][0]["content"], "You are a helpful assistant."); - assert_eq!(body["input"][1]["role"], "user"); - assert_eq!(body["input"][1]["content"][0]["type"], "input_text"); - // temperature and top_p should be absent - assert!(body.get("temperature").is_none()); - assert!(body.get("top_p").is_none()); - - // warnings for temperature and topP - assert_eq!(result.warnings.len(), 2); - } - - // -- should send store = false for reasoning model -- - - /// TS: "should send store = false provider option and opt into - /// reasoning.encrypted_content for reasoning models" - #[tokio::test] - async fn should_send_store_false_reasoning_model() { - let server = MockServer::start().await; - mock_json_response(&server, text_response_body()).await; - - let config = OpenAIConfig::new("test-key").with_base_url(server.uri()); - let model = OpenAIProvider::new(config).responses_model("gpt-5-mini"); - - let options = CallOptions { - provider_options: Some(provider_namespace("openai", json!({ "store": false }))), - ..CallOptions::new(test_prompt()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - assert!(result.warnings.is_empty()); - - let body = first_request_body(&server).await; - assert_eq!(body["model"], "gpt-5-mini"); - assert_eq!(body["store"], false); - assert_eq!(body["include"][0], "reasoning.encrypted_content"); - } - - // -- should send store = false for non-reasoning model -- - - /// TS: "should send store = false provider option and not opt into - /// reasoning.encrypted_content for non-reasoning models" - #[tokio::test] - async fn should_send_store_false_non_reasoning_model() { - let server = MockServer::start().await; - mock_json_response(&server, text_response_body()).await; - - let config = OpenAIConfig::new("test-key").with_base_url(server.uri()); - let model = OpenAIProvider::new(config).responses_model("gpt-4o"); - - let options = CallOptions { - provider_options: Some(provider_namespace("openai", json!({ "store": false }))), - ..CallOptions::new(test_prompt()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - assert!(result.warnings.is_empty()); - - let body = first_request_body(&server).await; - assert_eq!(body["store"], false); - assert!(body.get("include").is_none()); - } - - // -- should send store = true -- - - /// TS: "should send store = true provider option without - /// reasoning.encrypted_content" - #[tokio::test] - async fn should_send_store_true() { - let server = MockServer::start().await; - mock_json_response(&server, text_response_body()).await; - - let config = OpenAIConfig::new("test-key").with_base_url(server.uri()); - let model = OpenAIProvider::new(config).responses_model("gpt-4o"); - - let options = CallOptions { - provider_options: Some(provider_namespace("openai", json!({ "store": true }))), - ..CallOptions::new(test_prompt()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - assert!(result.warnings.is_empty()); - - let body = first_request_body(&server).await; - assert_eq!(body["store"], true); - assert!(body.get("include").is_none()); - } - - // -- should send previous response id -- - - /// TS: "should send previous response id provider option" - #[tokio::test] - async fn should_send_previous_response_id() { - let server = MockServer::start().await; - mock_json_response(&server, text_response_body()).await; - - let config = OpenAIConfig::new("test-key").with_base_url(server.uri()); - let model = OpenAIProvider::new(config).responses_model("gpt-4o"); - - let options = CallOptions { - provider_options: Some(provider_namespace( - "openai", - json!({ "previousResponseId": "resp_123" }), - )), - ..CallOptions::new(test_prompt()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - assert!(result.warnings.is_empty()); - - let body = first_request_body(&server).await; - assert_eq!(body["previous_response_id"], "resp_123"); - } - - // -- should warn when both conversation and previousResponseId -- - - /// TS: "should warn when both conversation and previousResponseId are - /// provided" - #[tokio::test] - async fn should_warn_when_conversation_and_previous_response_id() { - let server = MockServer::start().await; - mock_json_response(&server, text_response_body()).await; - - let config = OpenAIConfig::new("test-key").with_base_url(server.uri()); - let model = OpenAIProvider::new(config).responses_model("gpt-4o"); - - let options = CallOptions { - provider_options: Some(provider_namespace( - "openai", - json!({ "conversation": "conv_123", "previousResponseId": "resp_123" }), - )), - ..CallOptions::new(test_prompt()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - - let body = first_request_body(&server).await; - assert_eq!(body["conversation"], "conv_123"); - assert_eq!(body["previous_response_id"], "resp_123"); - - assert_eq!(result.warnings.len(), 1); - match &result.warnings[0] { - aimux_core::types::Warning::Unsupported { feature, .. } => { - assert_eq!(feature, "conversation"); - } - other => panic!("expected Unsupported warning, got {other:?}"), - } - } - - // -- should send reasoningEffort and reasoningSummary -- - - /// TS: "should send reasoningEffort and reasoningSummary provider options" - #[tokio::test] - async fn should_send_reasoning_effort_and_summary() { - let server = MockServer::start().await; - mock_json_response(&server, text_response_body()).await; - - let config = OpenAIConfig::new("test-key").with_base_url(server.uri()); - let model = OpenAIProvider::new(config).responses_model("o3-mini"); - - let options = CallOptions { - provider_options: Some(provider_namespace( - "openai", - json!({ "reasoningEffort": "low", "reasoningSummary": "auto" }), - )), - ..CallOptions::new(test_prompt()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - assert!(result.warnings.is_empty()); - - let body = first_request_body(&server).await; - assert_eq!(body["model"], "o3-mini"); - assert_eq!(body["reasoning"]["effort"], "low"); - assert_eq!(body["reasoning"]["summary"], "auto"); - } - - // -- should send reasoning with detailed summary when only effort is set -- - - /// TS: "should send xhigh reasoningEffort for codex-max model" (verifies - /// that `summary` defaults to "detailed" when effort is non-"none") - #[tokio::test] - async fn should_default_reasoning_summary_to_detailed() { - let server = MockServer::start().await; - mock_json_response(&server, text_response_body()).await; - - let config = OpenAIConfig::new("test-key").with_base_url(server.uri()); - let model = OpenAIProvider::new(config).responses_model("gpt-5.1-codex-max"); - - let options = CallOptions { - provider_options: Some(provider_namespace( - "openai", - json!({ "reasoningEffort": "xhigh" }), - )), - ..CallOptions::new(test_prompt()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - assert!(result.warnings.is_empty()); - - let body = first_request_body(&server).await; - assert_eq!(body["reasoning"]["effort"], "xhigh"); - assert_eq!(body["reasoning"]["summary"], "detailed"); - } - - // -- should send parallelToolCalls -- - - /// TS: "should send parallelToolCalls provider option" - #[tokio::test] - async fn should_send_parallel_tool_calls() { - let server = MockServer::start().await; - mock_json_response(&server, text_response_body()).await; - - let config = OpenAIConfig::new("test-key").with_base_url(server.uri()); - let model = OpenAIProvider::new(config).responses_model("gpt-4o"); - - let options = CallOptions { - provider_options: Some(provider_namespace( - "openai", - json!({ "parallelToolCalls": false }), - )), - ..CallOptions::new(test_prompt()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - assert!(result.warnings.is_empty()); - - let body = first_request_body(&server).await; - assert_eq!(body["parallel_tool_calls"], false); - } - - // -- should send user -- - - /// TS: "should send user provider option" - #[tokio::test] - async fn should_send_user_option() { - let server = MockServer::start().await; - mock_json_response(&server, text_response_body()).await; - - let config = OpenAIConfig::new("test-key").with_base_url(server.uri()); - let model = OpenAIProvider::new(config).responses_model("gpt-4o"); - - let options = CallOptions { - provider_options: Some(provider_namespace("openai", json!({ "user": "user_123" }))), - ..CallOptions::new(test_prompt()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - assert!(result.warnings.is_empty()); - - let body = first_request_body(&server).await; - assert_eq!(body["user"], "user_123"); - } - - // -- should send conversation -- - - /// TS: "should send conversation provider option" - #[tokio::test] - async fn should_send_conversation() { - let server = MockServer::start().await; - mock_json_response(&server, text_response_body()).await; - - let config = OpenAIConfig::new("test-key").with_base_url(server.uri()); - let model = OpenAIProvider::new(config).responses_model("gpt-4o"); - - let options = CallOptions { - provider_options: Some(provider_namespace( - "openai", - json!({ "conversation": "conv_123" }), - )), - ..CallOptions::new(test_prompt()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - assert!(result.warnings.is_empty()); - - let body = first_request_body(&server).await; - assert_eq!(body["conversation"], "conv_123"); - } - - // -- should send function tools -- - - /// TS: tools are prepared into the Responses `tools` array. - #[tokio::test] - async fn should_send_function_tools() { - let server = MockServer::start().await; - mock_json_response(&server, text_response_body()).await; - - let config = OpenAIConfig::new("test-key").with_base_url(server.uri()); - let model = OpenAIProvider::new(config).responses_model("gpt-4o"); - - let options = CallOptions { - tools: Some(vec![Tool::from(weather_tool())]), - tool_choice: ToolChoice::Auto, - ..CallOptions::new(test_prompt()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - assert!(result.warnings.is_empty()); - - let body = first_request_body(&server).await; - assert_eq!(body["tools"][0]["type"], "function"); - assert_eq!(body["tools"][0]["name"], "weather"); - assert_eq!(body["tools"][0]["parameters"]["type"], "object"); - } - - // -- should warn about unsupported topK -- - - /// TS: topK is unsupported for the Responses API. - #[tokio::test] - async fn should_warn_about_topk() { - let server = MockServer::start().await; - mock_json_response(&server, text_response_body()).await; - - let config = OpenAIConfig::new("test-key").with_base_url(server.uri()); - let model = OpenAIProvider::new(config).responses_model("gpt-4o"); - - let options = CallOptions { - top_k: Some(40.0), - ..CallOptions::new(test_prompt()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - assert!(result.warnings.iter().any(|w| match w { - aimux_core::types::Warning::Unsupported { feature, .. } => feature == "topK", - _ => false, - })); - } -} - -// ============================================================================ -// doGenerate -- response parsing -// ============================================================================ - -mod do_generate_response { - use super::*; - - // -- should generate text -- - - /// TS: "should generate text" - #[tokio::test] - async fn should_generate_text() { - let server = MockServer::start().await; - mock_json_response(&server, text_response_body()).await; - - let config = OpenAIConfig::new("test-key").with_base_url(server.uri()); - let model = OpenAIProvider::new(config).responses_model("gpt-4o"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - assert_eq!(result.content.len(), 1); - match &result.content[0] { - GenerateContent::Text { text, .. } => assert_eq!(text, "answer text"), - other => panic!("expected Text, got {other:?}"), - } - } - - // -- should extract usage -- - - /// TS: "should extract usage" - #[tokio::test] - async fn should_extract_usage() { - let server = MockServer::start().await; - mock_json_response(&server, text_response_body()).await; - - let config = OpenAIConfig::new("test-key").with_base_url(server.uri()); - let model = OpenAIProvider::new(config).responses_model("gpt-4o"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - assert_eq!(result.usage.input_tokens.total, Some(345)); - assert_eq!(result.usage.input_tokens.cache_read, Some(234)); - assert_eq!(result.usage.input_tokens.cache_write, Some(45)); - assert_eq!(result.usage.input_tokens.no_cache, Some(66)); - assert_eq!(result.usage.output_tokens.total, Some(538)); - assert_eq!(result.usage.output_tokens.reasoning, Some(123)); - assert_eq!(result.usage.output_tokens.text, Some(415)); - } - - // -- should extract response id metadata -- - - /// TS: "should extract response id metadata" - #[tokio::test] - async fn should_extract_response_id_metadata() { - let server = MockServer::start().await; - mock_json_response(&server, text_response_body()).await; - - let config = OpenAIConfig::new("test-key").with_base_url(server.uri()); - let model = OpenAIProvider::new(config).responses_model("gpt-4o"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - assert_eq!( - result.response.id.as_deref(), - Some("resp_67c97c0203188190a025beb4a75242bc") - ); - assert_eq!( - result.response.model_id.as_deref(), - Some("gpt-4o-2024-07-18") - ); - - // providerMetadata should contain responseId and reasoningContext. - let pm = result - .provider_metadata - .as_ref() - .and_then(|v| v.get("openai")) - .expect("provider_metadata.openai should exist"); - assert_eq!(pm["responseId"], "resp_67c97c0203188190a025beb4a75242bc"); - assert_eq!(pm["reasoningContext"], "current_turn"); - } - - // -- should throw error when no output -- - - /// TS: "should throw a descriptive error when the response has no output" - #[tokio::test] - async fn should_throw_when_no_output() { - let server = MockServer::start().await; - mock_json_response( - &server, - json!({ - "id": "resp_no_output", - "object": "response", - "created_at": 1741257730, - "status": "incomplete", - "error": null, - "incomplete_details": { "reason": "content_filter" }, - "model": "gpt-4o-2024-07-18", - "usage": { - "input_tokens": 10, - "output_tokens": 0 - } - }), - ) - .await; - - let config = OpenAIConfig::new("test-key").with_base_url(server.uri()); - let model = OpenAIProvider::new(config).responses_model("gpt-4o"); - - let err = model - .do_generate(&default_options(test_prompt())) - .await - .expect_err("should error"); - - let msg = err.to_string(); - assert!( - msg.contains("no output"), - "error should mention 'no output', got: {msg}" - ); - assert!( - msg.contains("content_filter"), - "error should mention 'content_filter', got: {msg}" - ); - } - - // -- should generate tool-call (function_call) -- - - /// TS: function_call output items map to tool-call content. - #[tokio::test] - async fn should_generate_tool_call() { - let server = MockServer::start().await; - mock_json_response( - &server, - json!({ - "id": "resp_fc", - "object": "response", - "created_at": 1741257730, - "status": "completed", - "error": null, - "incomplete_details": null, - "model": "gpt-4o-2024-07-18", - "output": [ - { - "type": "function_call", - "id": "fc_123", - "call_id": "call_abc", - "name": "weather", - "arguments": "{\"location\":\"San Francisco\"}" - } - ], - "usage": { - "input_tokens": 50, - "output_tokens": 10 - } - }), - ) - .await; - - let config = OpenAIConfig::new("test-key").with_base_url(server.uri()); - let model = OpenAIProvider::new(config).responses_model("gpt-4o"); - - let options = CallOptions { - tools: Some(vec![Tool::from(weather_tool())]), - tool_choice: ToolChoice::Auto, - ..CallOptions::new(test_prompt()) - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - - assert_eq!(result.content.len(), 1); - match &result.content[0] { - GenerateContent::ToolCall(RawToolCall { - tool_call_id, - tool_name, - input, - .. - }) => { - assert_eq!(tool_call_id, "call_abc"); - assert_eq!(tool_name, "weather"); - assert_eq!(input, r#"{"location":"San Francisco"}"#); - } - other => panic!("expected ToolCall, got {other:?}"), - } - // finish reason should be tool-calls (hasFunctionCall = true, no incomplete_details) - assert_eq!(result.finish_reason.unified, FinishReasonUnified::ToolCalls); - } - - // -- should generate reasoning content -- - - /// TS: reasoning output items map to reasoning content. - #[tokio::test] - async fn should_generate_reasoning() { - let server = MockServer::start().await; - mock_json_response( - &server, - json!({ - "id": "resp_rs", - "object": "response", - "created_at": 1741257730, - "status": "completed", - "error": null, - "incomplete_details": null, - "model": "o3-mini", - "output": [ - { - "type": "reasoning", - "id": "rs_123", - "summary": [ - { "type": "summary_text", "text": "thinking..." } - ] - }, - { - "id": "msg_1", - "type": "message", - "status": "completed", - "role": "assistant", - "content": [ - { "type": "output_text", "text": "answer", "annotations": [] } - ] - } - ], - "usage": { - "input_tokens": 10, - "output_tokens": 20, - "output_tokens_details": { "reasoning_tokens": 15 } - } - }), - ) - .await; - - let config = OpenAIConfig::new("test-key").with_base_url(server.uri()); - let model = OpenAIProvider::new(config).responses_model("o3-mini"); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - assert_eq!(result.content.len(), 2); - match &result.content[0] { - GenerateContent::Reasoning(ReasoningOutput { text, .. }) => { - assert_eq!(text, "thinking...") - } - other => panic!("expected Reasoning, got {other:?}"), - } - match &result.content[1] { - GenerateContent::Text { text, .. } => assert_eq!(text, "answer"), - other => panic!("expected Text, got {other:?}"), - } - assert_eq!(result.usage.output_tokens.reasoning, Some(15)); - assert_eq!(result.usage.output_tokens.text, Some(5)); - } -} - -// ============================================================================ -// doStream -- streaming -// (openai-responses-language-model.test.ts -> describe('doStream')) -// ============================================================================ - -mod do_stream { - use super::*; - - // -- should stream text deltas -- - - /// TS: "should stream text deltas" - /// - /// Verifies the main streaming path: - /// response.created -> output_item.added (message) -> output_text.delta -> - /// output_item.done (message) -> response.completed. - #[tokio::test] - async fn should_stream_text_deltas() { - let server = MockServer::start().await; - let chunks = sse_body(&[ - &sse_event( - r#"{"type":"response.created","response":{"id":"resp_1","created_at":1741269019,"model":"gpt-4o-2024-07-18"}}"#, - ), - &sse_event( - r#"{"type":"response.output_item.added","output_index":0,"item":{"id":"msg_1","type":"message","status":"in_progress","role":"assistant","content":[]}}"#, - ), - &sse_event( - r#"{"type":"response.output_text.delta","item_id":"msg_1","output_index":0,"delta":"Hello,"}"#, - ), - &sse_event( - r#"{"type":"response.output_text.delta","item_id":"msg_1","output_index":0,"delta":" World!"}"#, - ), - &sse_event( - r#"{"type":"response.output_item.done","output_index":0,"item":{"id":"msg_1","type":"message","status":"completed","role":"assistant","content":[]}}"#, - ), - &sse_event( - r#"{"type":"response.completed","response":{"id":"resp_1","created_at":1741269019,"model":"gpt-4o-2024-07-18","incomplete_details":null,"usage":{"input_tokens":543,"input_tokens_details":{"cached_tokens":234},"output_tokens":478,"output_tokens_details":{"reasoning_tokens":123}}}}"#, - ), - ]); - mock_sse_response(&server, &chunks).await; - - let config = OpenAIConfig::new("test-key").with_base_url(server.uri()); - let model = OpenAIProvider::new(config).responses_model("gpt-4o"); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("should succeed"); - let parts = collect_stream(result).await; - - // Expected sequence: - // StreamStart, ResponseMetadata, TextStart, TextDelta("Hello,"), - // TextDelta(" World!"), TextEnd, Finish - assert_eq!(parts.len(), 7); - - assert!(matches!( - &parts[0], - StreamPart::StreamStart { warnings } if warnings.is_empty() - )); - - match &parts[1] { - StreamPart::ResponseMetadata(ResponseMetadata { id, model_id, .. }) => { - assert_eq!(id.as_deref(), Some("resp_1")); - assert_eq!(model_id.as_deref(), Some("gpt-4o-2024-07-18")); - } - other => panic!("expected ResponseMetadata, got {other:?}"), - } - - match &parts[2] { - StreamPart::TextStart { id, .. } => assert_eq!(id, "msg_1"), - other => panic!("expected TextStart, got {other:?}"), - } - - match &parts[3] { - StreamPart::TextDelta { id, delta, .. } => { - assert_eq!(id, "msg_1"); - assert_eq!(delta, "Hello,"); - } - other => panic!("expected TextDelta, got {other:?}"), - } - - match &parts[4] { - StreamPart::TextDelta { id, delta, .. } => { - assert_eq!(id, "msg_1"); - assert_eq!(delta, " World!"); - } - other => panic!("expected TextDelta, got {other:?}"), - } - - match &parts[5] { - StreamPart::TextEnd { id, .. } => assert_eq!(id, "msg_1"), - other => panic!("expected TextEnd, got {other:?}"), - } - - match &parts[6] { - StreamPart::Finish { - finish_reason, - usage, - .. - } => { - assert_eq!(finish_reason.unified, FinishReasonUnified::Stop); - assert_eq!(usage.input_tokens.total, Some(543)); - assert_eq!(usage.input_tokens.cache_read, Some(234)); - assert_eq!(usage.output_tokens.total, Some(478)); - assert_eq!(usage.output_tokens.reasoning, Some(123)); - assert_eq!(usage.output_tokens.text, Some(355)); - } - other => panic!("expected Finish, got {other:?}"), - } - } - - // -- should stream tool calls -- - - /// TS: "should send streaming tool calls" - /// - /// Verifies the function-call streaming path: - /// output_item.added (function_call) -> function_call_arguments.delta -> - /// function_call_arguments.done -> output_item.done (function_call). - #[tokio::test] - async fn should_stream_tool_calls() { - let server = MockServer::start().await; - let chunks = sse_body(&[ - &sse_event( - r#"{"type":"response.created","response":{"id":"resp_tc","created_at":1741362087,"model":"gpt-4o-2024-07-18"}}"#, - ), - &sse_event( - r#"{"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","id":"fc_1","call_id":"call_added","name":"weather","arguments":"","status":"completed"}}"#, - ), - &sse_event( - r#"{"type":"response.function_call_arguments.delta","item_id":"fc_1","output_index":0,"delta":"{\"location\":"}"#, - ), - &sse_event( - r#"{"type":"response.function_call_arguments.delta","item_id":"fc_1","output_index":0,"delta":"\"Rome\"}"}"#, - ), - &sse_event( - r#"{"type":"response.function_call_arguments.done","item_id":"fc_1","output_index":0,"arguments":"{\"location\":\"Rome\"}"}"#, - ), - &sse_event( - r#"{"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","id":"fc_1","call_id":"call_done","name":"weather","arguments":"{\"location\":\"Rome\"}","status":"completed"}}"#, - ), - &sse_event( - r#"{"type":"response.completed","response":{"id":"resp_tc","created_at":1741362087,"model":"gpt-4o-2024-07-18","incomplete_details":null,"usage":{"input_tokens":0,"input_tokens_details":{"cached_tokens":0},"output_tokens":0,"output_tokens_details":{"reasoning_tokens":0}}}}"#, - ), - ]); - mock_sse_response(&server, &chunks).await; - - let config = OpenAIConfig::new("test-key").with_base_url(server.uri()); - let model = OpenAIProvider::new(config).responses_model("gpt-4o"); - - let options = CallOptions { - tools: Some(vec![Tool::from(weather_tool())]), - tool_choice: ToolChoice::Auto, - ..CallOptions::new(test_prompt()) - }; - - let result = model.do_stream(&options).await.expect("should succeed"); - let parts = collect_stream(result).await; - - // StreamStart, ResponseMetadata, ToolInputStart, ToolInputDelta x2, - // ToolInputEnd, ToolCall, Finish - assert_eq!(parts.len(), 8); - - // ToolInputStart uses the call_id from the added item. - match &parts[2] { - StreamPart::ToolInputStart { id, tool_name, .. } => { - assert_eq!(id, "call_added"); - assert_eq!(tool_name, "weather"); - } - other => panic!("expected ToolInputStart, got {other:?}"), - } - - // ToolInputDelta uses the ongoing tool call's id (from added item). - match &parts[3] { - StreamPart::ToolInputDelta { id, delta, .. } => { - assert_eq!(id, "call_added"); - assert!(delta.contains("location")); - } - other => panic!("expected ToolInputDelta, got {other:?}"), - } - match &parts[4] { - StreamPart::ToolInputDelta { id, delta, .. } => { - assert_eq!(id, "call_added"); - assert!(delta.contains("Rome")); - } - other => panic!("expected ToolInputDelta, got {other:?}"), - } - - // ToolInputEnd uses the call_id from the done item. - match &parts[5] { - StreamPart::ToolInputEnd { id, .. } => assert_eq!(id, "call_done"), - other => panic!("expected ToolInputEnd, got {other:?}"), - } - - // ToolCall uses the call_id and arguments from the done item. - match &parts[6] { - StreamPart::ToolCall(RawToolCall { - tool_call_id, - tool_name, - input, - .. - }) => { - assert_eq!(tool_call_id, "call_done"); - assert_eq!(tool_name, "weather"); - assert_eq!(input, r#"{"location":"Rome"}"#); - } - other => panic!("expected ToolCall, got {other:?}"), - } - - // Finish should have tool-calls finish reason. - match &parts[7] { - StreamPart::Finish { finish_reason, .. } => { - assert_eq!(finish_reason.unified, FinishReasonUnified::ToolCalls); - } - other => panic!("expected Finish, got {other:?}"), - } - } - - // -- should stream reasoning summary -- - - /// TS: reasoning summary streaming. - /// - /// Verifies the reasoning streaming path: - /// output_item.added (reasoning) -> reasoning_summary_part.added -> - /// reasoning_summary_text.delta -> reasoning_summary_part.done -> - /// output_item.done (reasoning). - #[tokio::test] - async fn should_stream_reasoning_summary() { - let server = MockServer::start().await; - let chunks = sse_body(&[ - &sse_event( - r#"{"type":"response.created","response":{"id":"resp_rs","created_at":1741269019,"model":"o3-mini-2025-01-31"}}"#, - ), - &sse_event( - r#"{"type":"response.output_item.added","output_index":0,"item":{"id":"rs_1","type":"reasoning"}}"#, - ), - &sse_event( - r#"{"type":"response.reasoning_summary_part.added","item_id":"rs_1","summary_index":0}"#, - ), - &sse_event( - r#"{"type":"response.reasoning_summary_text.delta","item_id":"rs_1","summary_index":0,"delta":"thinking through the steps"}"#, - ), - &sse_event( - r#"{"type":"response.reasoning_summary_part.done","item_id":"rs_1","summary_index":0}"#, - ), - &sse_event( - r#"{"type":"response.output_item.done","output_index":0,"item":{"id":"rs_1","type":"reasoning","summary":[{"type":"summary_text","text":"thinking through the steps"}]}}"#, - ), - &sse_event( - r#"{"type":"response.completed","response":{"id":"resp_rs","created_at":1741269019,"model":"o3-mini-2025-01-31","incomplete_details":null,"usage":{"input_tokens":10,"input_tokens_details":{"cached_tokens":0},"output_tokens":20,"output_tokens_details":{"reasoning_tokens":20}}}}"#, - ), - ]); - mock_sse_response(&server, &chunks).await; - - let config = OpenAIConfig::new("test-key").with_base_url(server.uri()); - let model = OpenAIProvider::new(config).responses_model("o3-mini"); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("should succeed"); - let parts = collect_stream(result).await; - - // StreamStart, ResponseMetadata, ReasoningStart, ReasoningDelta, - // ReasoningEnd, Finish - // (reasoning_summary_part.done with store=false defaults to - // can-conclude; the ReasoningEnd is emitted at output_item.done) - assert_eq!(parts.len(), 6); - - match &parts[2] { - StreamPart::ReasoningStart { id, .. } => { - assert_eq!(id, "rs_1:0"); - } - other => panic!("expected ReasoningStart, got {other:?}"), - } - - match &parts[3] { - StreamPart::ReasoningDelta { id, delta, .. } => { - assert_eq!(id, "rs_1:0"); - assert_eq!(delta, "thinking through the steps"); - } - other => panic!("expected ReasoningDelta, got {other:?}"), - } - - match &parts[4] { - StreamPart::ReasoningEnd { id, .. } => assert_eq!(id, "rs_1:0"), - other => panic!("expected ReasoningEnd, got {other:?}"), - } - - match &parts[5] { - StreamPart::Finish { finish_reason, .. } => { - assert_eq!(finish_reason.unified, FinishReasonUnified::Stop); - } - other => panic!("expected Finish, got {other:?}"), - } - } - - // -- should carry encrypted reasoning content in stream metadata -- - - /// Regression: the streaming reducer captured `encrypted_content` into - /// state and then discarded it (`let _ = encrypted;`) — every - /// ReasoningEnd carried `provider_metadata: None`, so encrypted - /// reasoning could not cross a turn with store=false. - #[tokio::test] - async fn should_stream_reasoning_encrypted_content_in_metadata() { - let server = MockServer::start().await; - let chunks = sse_body(&[ - &sse_event( - r#"{"type":"response.created","response":{"id":"resp_enc","created_at":1741269019,"model":"o3-mini-2025-01-31"}}"#, - ), - &sse_event( - r#"{"type":"response.output_item.added","output_index":0,"item":{"id":"rs_1","type":"reasoning","encrypted_content":"enc-blob-123"}}"#, - ), - &sse_event( - r#"{"type":"response.reasoning_summary_text.delta","item_id":"rs_1","summary_index":0,"delta":"thinking"}"#, - ), - &sse_event( - r#"{"type":"response.output_item.done","output_index":0,"item":{"id":"rs_1","type":"reasoning","encrypted_content":"enc-blob-123","summary":[{"type":"summary_text","text":"thinking"}]}}"#, - ), - &sse_event( - r#"{"type":"response.completed","response":{"id":"resp_enc","created_at":1741269019,"model":"o3-mini-2025-01-31","incomplete_details":null,"usage":{"input_tokens":10,"input_tokens_details":{"cached_tokens":0},"output_tokens":20,"output_tokens_details":{"reasoning_tokens":20}}}}"#, - ), - ]); - mock_sse_response(&server, &chunks).await; - - let config = OpenAIConfig::new("test-key").with_base_url(server.uri()); - let model = OpenAIProvider::new(config).responses_model("o3-mini"); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("should succeed"); - let parts = collect_stream(result).await; - - let start = parts - .iter() - .find(|p| matches!(p, StreamPart::ReasoningStart { .. })) - .expect("ReasoningStart"); - match start { - StreamPart::ReasoningStart { - provider_metadata: Some(m), - .. - } => { - assert_eq!(m["openai"]["itemId"], json!("rs_1")); - assert_eq!( - m["openai"]["reasoningEncryptedContent"], - json!("enc-blob-123") - ); - } - other => panic!("ReasoningStart must carry metadata, got {other:?}"), - } - - let end = parts - .iter() - .find(|p| matches!(p, StreamPart::ReasoningEnd { .. })) - .expect("ReasoningEnd"); - match end { - StreamPart::ReasoningEnd { - id, - provider_metadata: Some(m), - } => { - assert_eq!(id, "rs_1:0"); - assert_eq!(m["openai"]["itemId"], json!("rs_1")); - assert_eq!( - m["openai"]["reasoningEncryptedContent"], - json!("enc-blob-123"), - "encrypted_content must ride ReasoningEnd so consume() can round-trip it" - ); - } - other => panic!("ReasoningEnd must carry metadata, got {other:?}"), - } - } - - /// Same path with no summary text at all: the ReasoningEnd must still - /// carry the metadata so `consume()` can keep the part in - /// response_messages. - #[tokio::test] - async fn should_stream_reasoning_encrypted_content_without_summary() { - let server = MockServer::start().await; - let chunks = sse_body(&[ - &sse_event( - r#"{"type":"response.created","response":{"id":"resp_enc2","created_at":1741269019,"model":"o3-mini-2025-01-31"}}"#, - ), - &sse_event( - r#"{"type":"response.output_item.added","output_index":0,"item":{"id":"rs_9","type":"reasoning","encrypted_content":"enc-no-summary"}}"#, - ), - &sse_event( - r#"{"type":"response.output_item.done","output_index":0,"item":{"id":"rs_9","type":"reasoning","encrypted_content":"enc-no-summary","summary":[]}}"#, - ), - &sse_event( - r#"{"type":"response.completed","response":{"id":"resp_enc2","created_at":1741269019,"model":"o3-mini-2025-01-31","incomplete_details":null,"usage":{"input_tokens":10,"input_tokens_details":{"cached_tokens":0},"output_tokens":20,"output_tokens_details":{"reasoning_tokens":20}}}}"#, - ), - ]); - mock_sse_response(&server, &chunks).await; - - let config = OpenAIConfig::new("test-key").with_base_url(server.uri()); - let model = OpenAIProvider::new(config).responses_model("o3-mini"); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("should succeed"); - let parts = collect_stream(result).await; - - let end = parts - .iter() - .find(|p| matches!(p, StreamPart::ReasoningEnd { .. })) - .expect("ReasoningEnd even without any summary text"); - match end { - StreamPart::ReasoningEnd { - provider_metadata: Some(m), - .. - } => { - assert_eq!(m["openai"]["itemId"], json!("rs_9")); - assert_eq!( - m["openai"]["reasoningEncryptedContent"], - json!("enc-no-summary") - ); - } - other => panic!("ReasoningEnd must carry metadata even without summary, got {other:?}"), - } - } - - // -- should send finish reason for incomplete response -- - - /// TS: "should send finish reason for incomplete response" - /// - /// Verifies that `response.incomplete` with `incomplete_details.reason = - /// "max_output_tokens"` maps to finish reason `length`. - #[tokio::test] - async fn should_send_finish_reason_for_incomplete() { - let server = MockServer::start().await; - let chunks = sse_body(&[ - &sse_event( - r#"{"type":"response.created","response":{"id":"resp_inc","created_at":1741269019,"model":"gpt-4o-2024-07-18"}}"#, - ), - &sse_event( - r#"{"type":"response.output_item.added","output_index":0,"item":{"id":"msg_1","type":"message","status":"in_progress","role":"assistant","content":[]}}"#, - ), - &sse_event( - r#"{"type":"response.output_text.delta","item_id":"msg_1","output_index":0,"delta":"Hello,"}"#, - ), - &sse_event( - r#"{"type":"response.output_item.done","output_index":0,"item":{"id":"msg_1","type":"message","status":"incomplete","role":"assistant","content":[]}}"#, - ), - &sse_event( - r#"{"type":"response.incomplete","response":{"id":"resp_inc","created_at":1741347648,"model":"gpt-4o-2024-07-18","incomplete_details":{"reason":"max_output_tokens"},"usage":{"input_tokens":0,"input_tokens_details":{"cached_tokens":0},"output_tokens":0,"output_tokens_details":{"reasoning_tokens":0}}}}"#, - ), - ]); - mock_sse_response(&server, &chunks).await; - - let config = OpenAIConfig::new("test-key").with_base_url(server.uri()); - let model = OpenAIProvider::new(config).responses_model("gpt-4o"); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("should succeed"); - let parts = collect_stream(result).await; - - let finish = parts - .iter() - .find(|p| matches!(p, StreamPart::Finish { .. })) - .expect("should have a Finish part"); - match finish { - StreamPart::Finish { finish_reason, .. } => { - assert_eq!(finish_reason.unified, FinishReasonUnified::Length); - assert_eq!(finish_reason.raw.as_deref(), Some("max_output_tokens")); - } - _ => unreachable!(), - } - } - - // -- should stream text with store=true (reasoning-end at summary_part.done) -- - - /// Verifies that when `store=true`, reasoning summary parts are concluded - /// immediately at `reasoning_summary_part.done` (not deferred to - /// `output_item.done`). - #[tokio::test] - async fn should_conclude_reasoning_at_summary_done_when_store_true() { - let server = MockServer::start().await; - let chunks = sse_body(&[ - &sse_event( - r#"{"type":"response.created","response":{"id":"resp_rs","created_at":1741269019,"model":"o3-mini-2025-01-31"}}"#, - ), - &sse_event( - r#"{"type":"response.output_item.added","output_index":0,"item":{"id":"rs_1","type":"reasoning"}}"#, - ), - &sse_event( - r#"{"type":"response.reasoning_summary_part.added","item_id":"rs_1","summary_index":0}"#, - ), - &sse_event( - r#"{"type":"response.reasoning_summary_text.delta","item_id":"rs_1","summary_index":0,"delta":"thinking"}"#, - ), - &sse_event( - r#"{"type":"response.reasoning_summary_part.done","item_id":"rs_1","summary_index":0}"#, - ), - &sse_event( - r#"{"type":"response.output_item.done","output_index":0,"item":{"id":"rs_1","type":"reasoning","summary":[{"type":"summary_text","text":"thinking"}]}}"#, - ), - &sse_event( - r#"{"type":"response.completed","response":{"id":"resp_rs","created_at":1741269019,"model":"o3-mini-2025-01-31","incomplete_details":null,"usage":{"input_tokens":10,"input_tokens_details":{"cached_tokens":0},"output_tokens":20,"output_tokens_details":{"reasoning_tokens":20}}}}"#, - ), - ]); - mock_sse_response(&server, &chunks).await; - - let config = OpenAIConfig::new("test-key").with_base_url(server.uri()); - let model = OpenAIProvider::new(config).responses_model("o3-mini"); - - let options = CallOptions { - provider_options: Some(provider_namespace("openai", json!({ "store": true }))), - ..CallOptions::new(test_prompt()) - }; - - let result = model.do_stream(&options).await.expect("should succeed"); - let parts = collect_stream(result).await; - - // With store=true, ReasoningEnd should be emitted at - // reasoning_summary_part.done (before output_item.done), so no - // duplicate ReasoningEnd at output_item.done. - let reasoning_ends: Vec<&StreamPart> = parts - .iter() - .filter(|p| matches!(p, StreamPart::ReasoningEnd { .. })) - .collect(); - assert_eq!( - reasoning_ends.len(), - 1, - "should have exactly one ReasoningEnd with store=true" - ); - } - - // -- should emit provider metadata with responseId in finish -- - - /// TS: finish event should carry providerMetadata.openai.responseId. - #[tokio::test] - async fn should_emit_response_id_in_finish() { - let server = MockServer::start().await; - let chunks = sse_body(&[ - &sse_event( - r#"{"type":"response.created","response":{"id":"resp_meta","created_at":1741269019,"model":"gpt-4o-2024-07-18"}}"#, - ), - &sse_event( - r#"{"type":"response.completed","response":{"id":"resp_meta","created_at":1741269019,"model":"gpt-4o-2024-07-18","incomplete_details":null,"usage":{"input_tokens":0,"input_tokens_details":{"cached_tokens":0},"output_tokens":0,"output_tokens_details":{"reasoning_tokens":0}}}}"#, - ), - ]); - mock_sse_response(&server, &chunks).await; - - let config = OpenAIConfig::new("test-key").with_base_url(server.uri()); - let model = OpenAIProvider::new(config).responses_model("gpt-4o"); - - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("should succeed"); - let parts = collect_stream(result).await; - - let finish = parts - .iter() - .find(|p| matches!(p, StreamPart::Finish { .. })) - .expect("should have a Finish part"); - match finish { - StreamPart::Finish { - provider_metadata, .. - } => { - let pm = provider_metadata - .as_ref() - .and_then(|v| v.get("openai")) - .expect("provider_metadata.openai should exist"); - assert_eq!(pm["responseId"], "resp_meta"); - } - _ => unreachable!(), - } - } -} diff --git a/aimux-providers/tests/vertex_anthropic_test.rs b/aimux-providers/tests/vertex_anthropic_test.rs deleted file mode 100644 index a95350841..000000000 --- a/aimux-providers/tests/vertex_anthropic_test.rs +++ /dev/null @@ -1,565 +0,0 @@ -//! Wiremock tests for Anthropic partner models on Vertex AI (`rawPredict`). -//! -//! Claude models on Vertex AI are reached via the -//! `publishers/anthropic/models/{model}:rawPredict` (and `:streamRawPredict`) -//! endpoints. The request body is a standard Anthropic Messages request -//! wrapped in an `anthropic_version` envelope (model identity in the URL, not -//! the body); the response is a verbatim Anthropic Messages response. These -//! tests mock the Vertex AI endpoints and never touch the public network or -//! real credentials. - -use aimux_core::tool::{RawToolCall, ToolResult}; -use futures::StreamExt; -use serde_json::{Value, json}; -use wiremock::matchers::{header, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -use aimux_core::content::ContentPart; -use aimux_core::error::AiMuxError; -use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; -use aimux_core::options::{CallOptions, ProviderTool, Tool}; -use aimux_core::result::{GenerateContent, StreamResult}; -use aimux_core::stream_part::StreamPart; -use aimux_core::types::FinishReasonUnified; - -use aimux_providers::vertex::{ - VertexAnthropicModel, VertexAuth, VertexProvider, VertexProviderConfig, -}; - -// ── Constants ───────────────────────────────────────────────────────────────── - -const MODEL_ID: &str = "claude-sonnet-4-20250514"; -/// Endpoint path once the `/publishers/google` suffix has been swapped for -/// `/publishers/anthropic`. -const RAW_PREDICT_PATH: &str = "/publishers/anthropic/models/claude-sonnet-4-20250514:rawPredict"; -const STREAM_RAW_PREDICT_PATH: &str = - "/publishers/anthropic/models/claude-sonnet-4-20250514:streamRawPredict"; - -// ── Helpers ─────────────────────────────────────────────────────────────────── - -fn test_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] -} - -fn default_options(prompt: LanguageModelPrompt) -> CallOptions { - CallOptions::new(prompt) -} - -/// Build a `VertexProvider` whose base URL ends in `/publishers/google` and -/// points at the mock server, then return the Anthropic partner model. This -/// exercises the `/publishers/google` → `/publishers/anthropic` base-URL -/// rewrite performed by [`VertexProvider::anthropic_model`]. -fn make_model(server: &MockServer) -> VertexAnthropicModel { - let config = VertexProviderConfig { - base_url: format!("{}/publishers/google", server.uri()), - project: Some("test-project".to_string()), - location: Some("us-central1".to_string()), - auth: VertexAuth::BearerToken("test-token".to_string()), - api_key_source: None, - retry_config: aimux_provider_utils::RetryConfig::default(), - }; - VertexProvider::new(config) - .anthropic_model(MODEL_ID) - .expect("anthropic_model should succeed") -} - -async fn mock_raw_predict_json(server: &MockServer, status: u16, body: Value) { - Mock::given(method("POST")) - .and(path(RAW_PREDICT_PATH)) - .respond_with(ResponseTemplate::new(status).set_body_json(body)) - .mount(server) - .await; -} - -async fn mock_stream_raw_predict_sse(server: &MockServer, sse_body: &str) { - Mock::given(method("POST")) - .and(path(STREAM_RAW_PREDICT_PATH)) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(sse_body.to_string()), - ) - .mount(server) - .await; -} - -fn sse(data: &Value) -> String { - format!("data: {data}\n\n") -} - -fn sse_stream(events: &[Value]) -> String { - events.iter().map(sse).collect() -} - -fn as_text(item: &GenerateContent) -> &str { - match item { - GenerateContent::Text { text, .. } => text, - _ => panic!("expected Text content, got {item:?}"), - } -} - -async fn collect_stream(result: StreamResult) -> Vec { - let mut parts = Vec::new(); - let mut stream = result.stream; - while let Some(part) = stream.next().await { - match part { - Ok(p) => parts.push(p), - Err(e) => panic!("stream error: {e:?}"), - } - } - parts -} - -fn text_response(text: &str) -> Value { - json!({ - "id": "msg_017TfcQ4AgGxKyBduUpqYPZn", - "type": "message", - "role": "assistant", - "content": [{ "type": "text", "text": text }], - "model": "claude-sonnet-4-20250514", - "stop_reason": "end_turn", - "stop_sequence": null, - "usage": { "input_tokens": 4, "output_tokens": 30 } - }) -} - -// ═════════════════════════════════════════════════════════════════════════════ -// doGenerate tests -// ═════════════════════════════════════════════════════════════════════════════ - -/// Test: non-streaming text generation via rawPredict extracts text, usage, -/// and finish reason from the standard Anthropic response body. -#[tokio::test] -async fn vertex_anthropic_generate_text_response() { - let server = MockServer::start().await; - mock_raw_predict_json(&server, 200, text_response("Hello from Claude on Vertex!")).await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!(result.content.len(), 1); - assert_eq!(as_text(&result.content[0]), "Hello from Claude on Vertex!"); - assert_eq!(result.finish_reason.unified, FinishReasonUnified::Stop); - assert_eq!(result.usage.input_tokens.total, Some(4)); - assert_eq!(result.usage.output_tokens.total, Some(30)); - assert_eq!( - result.response.id.as_deref(), - Some("msg_017TfcQ4AgGxKyBduUpqYPZn") - ); -} - -#[tokio::test] -async fn vertex_anthropic_generate_keeps_direct_caller_metadata() { - let server = MockServer::start().await; - mock_raw_predict_json( - &server, - 200, - json!({ - "id": "msg_direct", - "type": "message", - "role": "assistant", - "content": [{ - "type": "tool_use", - "id": "toolu_direct", - "name": "get_weather", - "input": { "city": "Tokyo" }, - "caller": { "type": "direct" }, - }], - "model": MODEL_ID, - "stop_reason": "tool_use", - "usage": { "input_tokens": 100, "output_tokens": 50 }, - }), - ) - .await; - - let result = make_model(&server) - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - let metadata = result.content.iter().find_map(|part| match part { - GenerateContent::ToolCall(RawToolCall { - provider_metadata, .. - }) => provider_metadata.as_ref(), - _ => None, - }); - assert_eq!( - metadata.expect("caller metadata")["anthropic"]["caller"], - json!({ "type": "direct" }) - ); -} - -/// Test: the request hits the `/publishers/anthropic/models/{model}:rawPredict` -/// path (i.e. the `/publishers/google` suffix is replaced) and carries the -/// Bearer token Authorization header. -#[tokio::test] -async fn vertex_anthropic_url_and_auth() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path(RAW_PREDICT_PATH)) - .and(header("Authorization", "Bearer test-token")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_response("OK"))) - .mount(&server) - .await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!(as_text(&result.content[0]), "OK"); - - // The single recorded request hit the Anthropic publisher path. - let requests = server.received_requests().await.expect("requests recorded"); - assert_eq!(requests.len(), 1); - assert_eq!(requests[0].url.path(), RAW_PREDICT_PATH); - assert_eq!( - requests[0].headers.get("authorization").unwrap(), - "Bearer test-token" - ); -} - -/// Test: the rawPredict request body is wrapped in the `anthropic_version` -/// envelope and drops the `model` field (the model identity lives in the URL). -#[tokio::test] -async fn vertex_anthropic_request_body_envelope() { - let server = MockServer::start().await; - mock_raw_predict_json(&server, 200, text_response("OK")).await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - // The reported request body is the envelope actually sent. - let reported = result.request_body.expect("should have request body"); - assert_eq!(reported["anthropic_version"], "vertex-2023-10-16"); - assert_eq!(reported["messages"][0]["role"], "user"); - assert_eq!(reported["messages"][0]["content"][0]["text"], "Hello"); - assert!( - reported.get("model").is_none(), - "model must not be in the body" - ); - - // The body sent over the wire matches (anthropic_version present, no model). - let requests = server.received_requests().await.expect("requests recorded"); - assert_eq!(requests.len(), 1); - let wire: Value = serde_json::from_slice(&requests[0].body).expect("valid json body"); - assert_eq!(wire["anthropic_version"], "vertex-2023-10-16"); - assert_eq!(wire["messages"][0]["content"][0]["text"], "Hello"); - assert!( - wire.get("model").is_none(), - "model must not be in the wire body" - ); -} - -// ═════════════════════════════════════════════════════════════════════════════ -// doStream tests -// ═════════════════════════════════════════════════════════════════════════════ - -/// Test: streaming text via the Anthropic SSE events returned by -/// streamRawPredict. -#[tokio::test] -async fn vertex_anthropic_stream_text() { - let server = MockServer::start().await; - - let sse_body = sse_stream(&[ - json!({ - "type": "message_start", - "message": { - "id": "msg_001", - "model": "claude-sonnet-4-20250514", - "usage": { "input_tokens": 3 } - } - }), - json!({ - "type": "content_block_start", - "index": 0, - "content_block": { "type": "text", "text": "" } - }), - json!({ - "type": "content_block_delta", - "index": 0, - "delta": { "type": "text_delta", "text": "Hello" } - }), - json!({ - "type": "content_block_delta", - "index": 0, - "delta": { "type": "text_delta", "text": " streamed!" } - }), - json!({ - "type": "content_block_stop", - "index": 0 - }), - json!({ - "type": "message_delta", - "delta": { "stop_reason": "end_turn" }, - "usage": { "output_tokens": 5 } - }), - json!({ "type": "message_stop" }), - ]); - - mock_stream_raw_predict_sse(&server, &sse_body).await; - - let model = make_model(&server); - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - - let parts = collect_stream(result).await; - - let text_deltas: Vec = parts - .iter() - .filter_map(|p| match p { - StreamPart::TextDelta { delta, .. } => Some(delta.clone()), - _ => None, - }) - .collect(); - assert_eq!(text_deltas, vec!["Hello", " streamed!"]); - - let finish = parts.iter().find_map(|p| match p { - StreamPart::Finish { finish_reason, .. } => Some(finish_reason.clone()), - _ => None, - }); - let finish = finish.expect("should have a Finish part"); - assert_eq!(finish.unified, FinishReasonUnified::Stop); - - // The stream request hit the streamRawPredict path. - let requests = server.received_requests().await.expect("requests recorded"); - assert_eq!(requests.len(), 1); - assert_eq!(requests[0].url.path(), STREAM_RAW_PREDICT_PATH); - let wire: Value = serde_json::from_slice(&requests[0].body).expect("valid json body"); - assert_eq!(wire["anthropic_version"], "vertex-2023-10-16"); - assert_eq!(wire["stream"], true); -} - -// ═════════════════════════════════════════════════════════════════════════════ -#[tokio::test] -async fn vertex_anthropic_streamed_tool_search_results_follow_their_call_ids() { - let server = MockServer::start().await; - let sse_body = sse_stream(&[ - json!({ - "type": "message_start", - "message": { - "id": "msg_tool_search", - "model": MODEL_ID, - "usage": { "input_tokens": 10 }, - }, - }), - json!({ - "type": "content_block_start", - "index": 0, - "content_block": { - "type": "server_tool_use", - "id": "bm25_call", - "name": "tool_search_tool_bm25", - "input": { "query": "weather" }, - }, - }), - json!({ "type": "content_block_stop", "index": 0 }), - json!({ - "type": "content_block_start", - "index": 1, - "content_block": { - "type": "tool_search_tool_result", - "tool_use_id": "bm25_call", - "content": { - "type": "tool_search_tool_search_result", - "tool_references": [{ - "type": "tool_reference", - "tool_name": "get_weather", - }], - }, - }, - }), - json!({ "type": "content_block_stop", "index": 1 }), - json!({ - "type": "content_block_start", - "index": 2, - "content_block": { - "type": "server_tool_use", - "id": "regex_call", - "name": "tool_search_tool_regex", - "input": { "pattern": "forecast.*" }, - }, - }), - json!({ "type": "content_block_stop", "index": 2 }), - json!({ - "type": "content_block_start", - "index": 3, - "content_block": { - "type": "tool_search_tool_result", - "tool_use_id": "regex_call", - "content": { - "type": "tool_search_tool_search_result", - "tool_references": [{ - "type": "tool_reference", - "tool_name": "get_forecast", - }], - }, - }, - }), - json!({ "type": "content_block_stop", "index": 3 }), - json!({ - "type": "message_delta", - "delta": { "stop_reason": "end_turn" }, - "usage": { "output_tokens": 20 }, - }), - json!({ "type": "message_stop" }), - ]); - mock_stream_raw_predict_sse(&server, &sse_body).await; - let model = make_model(&server); - let options = CallOptions { - tools: Some(vec![ - Tool::Provider(ProviderTool { - id: "anthropic.tool_search_regex_20251119".to_string(), - name: "regexSearch".to_string(), - args: json!({}), - }), - Tool::Provider(ProviderTool { - id: "anthropic.tool_search_bm25_20251119".to_string(), - name: "semanticSearch".to_string(), - args: json!({}), - }), - ]), - ..default_options(test_prompt()) - }; - - let parts = collect_stream(model.do_stream(&options).await.unwrap()).await; - let result_names: std::collections::HashMap<&str, &str> = parts - .iter() - .filter_map(|part| match part { - StreamPart::ToolResult(ToolResult { - tool_call_id, - tool_name, - .. - }) => Some((tool_call_id.as_str(), tool_name.as_str())), - _ => None, - }) - .collect(); - assert_eq!(result_names.get("bm25_call"), Some(&"semanticSearch")); - assert_eq!(result_names.get("regex_call"), Some(&"regexSearch")); -} - -#[tokio::test] -async fn vertex_anthropic_stream_keeps_programmatic_caller_metadata() { - let server = MockServer::start().await; - let sse_body = sse_stream(&[ - json!({ - "type": "message_start", - "message": { - "id": "msg_programmatic", - "model": MODEL_ID, - "usage": { "input_tokens": 100 }, - }, - }), - json!({ - "type": "content_block_start", - "index": 0, - "content_block": { - "type": "tool_use", - "id": "toolu_programmatic", - "name": "query_database", - "input": { "sql": "SELECT 1" }, - "caller": { - "type": "code_execution_20250825", - "tool_id": "srvtoolu_code", - }, - }, - }), - json!({ "type": "content_block_stop", "index": 0 }), - json!({ - "type": "message_delta", - "delta": { "stop_reason": "tool_use" }, - "usage": { "output_tokens": 50 }, - }), - json!({ "type": "message_stop" }), - ]); - mock_stream_raw_predict_sse(&server, &sse_body).await; - let model = make_model(&server); - - let parts = collect_stream( - model - .do_stream(&default_options(test_prompt())) - .await - .unwrap(), - ) - .await; - let metadata = parts.iter().find_map(|part| match part { - StreamPart::ToolCall(RawToolCall { - provider_metadata, .. - }) => provider_metadata.as_ref(), - _ => None, - }); - assert_eq!( - metadata.expect("caller metadata")["anthropic"]["caller"], - json!({ - "type": "code_execution_20250825", - "toolId": "srvtoolu_code", - }) - ); -} - -// Error handling -// ═════════════════════════════════════════════════════════════════════════════ - -/// Test: a 401 response maps to `AiMuxError::ApiCall` (401 in `status_code`). -#[tokio::test] -async fn vertex_anthropic_generate_auth_error() { - let server = MockServer::start().await; - mock_raw_predict_json( - &server, - 401, - json!({ - "error": { - "code": 401, - "message": "Request had invalid authentication credentials.", - "status": "UNAUTHENTICATED" - } - }), - ) - .await; - - let model = make_model(&server); - let result = model.do_generate(&default_options(test_prompt())).await; - assert!( - matches!(result, Err(ref e) if e.status_code() == Some(401)), - "expected Auth, got {result:?}" - ); -} - -/// Test: a generic HTTP error (500) maps to `AiMuxError::ApiCall`. -#[tokio::test] -async fn vertex_anthropic_generate_provider_error() { - let server = MockServer::start().await; - mock_raw_predict_json( - &server, - 500, - json!({ - "error": { - "code": 500, - "message": "Internal error.", - "status": "INTERNAL" - } - }), - ) - .await; - - let model = make_model(&server); - let result = model.do_generate(&default_options(test_prompt())).await; - assert!( - matches!(result, Err(AiMuxError::ApiCall(_))), - "expected Provider, got {result:?}" - ); -} diff --git a/aimux-providers/tests/vertex_model_test.rs b/aimux-providers/tests/vertex_model_test.rs deleted file mode 100644 index 814d7da06..000000000 --- a/aimux-providers/tests/vertex_model_test.rs +++ /dev/null @@ -1,1431 +0,0 @@ -//! Wiremock tests for the Google Vertex AI provider. -//! -//! Tests cover: -//! - Non-streaming text generation (generateContent JSON response) -//! - Non-streaming tool call extraction -//! - Streaming via SSE (streamGenerateContent) -//! - Error handling -//! -//! Vertex AI uses the same request/response format as the public Google Gemini -//! API, so the response fixtures mirror the Gemini API shape. - -use aimux_core::tool::{RawToolCall, ToolResult}; -use futures::StreamExt; -use serde_json::{Value, json}; -use wiremock::matchers::{method, path, query_param}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -use aimux_core::content::ContentPart; -use aimux_core::generate::{GenerateTextOptions, generate_text, stream_text}; -use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{ - LanguageModelPrompt, LanguageModelPromptMessage, convert_to_language_model_prompt, -}; -use aimux_core::message::{ModelMessage, Role}; -use aimux_core::options::{CallOptions, ProviderTool, Tool}; -use aimux_core::result::{GenerateContent, Source, StreamResult}; -use aimux_core::stream_part::StreamPart; -use aimux_core::types::{FinishReasonUnified, ProviderMetadata}; - -use aimux_providers::vertex::{VertexAuth, VertexConfig, VertexModel, VertexProviderConfig}; - -// ── Helpers ────────────────────────────────────────────────────────────────── - -fn test_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] -} - -fn default_options(prompt: LanguageModelPrompt) -> CallOptions { - CallOptions::new(prompt) -} - -fn make_model(server: &MockServer) -> VertexModel { - VertexModel::new( - "gemini-2.0-flash".to_string(), - VertexConfig { - base_url: server.uri(), - auth: VertexAuth::BearerToken("test-token".to_string()), - api_key_source: None, - retry_config: aimux_provider_utils::RetryConfig::default(), - }, - ) -} - -async fn mock_generate_content(server: &MockServer, body: Value) { - Mock::given(method("POST")) - .and(path("/models/gemini-2.0-flash:generateContent")) - .respond_with(ResponseTemplate::new(200).set_body_json(body)) - .mount(server) - .await; -} - -async fn mock_generate_error(server: &MockServer, status: u16, body: Value) { - Mock::given(method("POST")) - .and(path("/models/gemini-2.0-flash:generateContent")) - .respond_with(ResponseTemplate::new(status).set_body_json(body)) - .mount(server) - .await; -} - -async fn mock_stream_content(server: &MockServer, sse_body: &str) { - Mock::given(method("POST")) - .and(path("/models/gemini-2.0-flash:streamGenerateContent")) - .and(query_param("alt", "sse")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(sse_body.to_string()), - ) - .mount(server) - .await; -} - -fn sse(data: &Value) -> String { - format!("data: {data}\n\n") -} - -fn sse_stream(events: &[Value]) -> String { - events.iter().map(sse).collect() -} - -fn as_text(item: &GenerateContent) -> &str { - match item { - GenerateContent::Text { text, .. } => text, - _ => panic!("expected Text content, got {item:?}"), - } -} - -fn as_tool_call(item: &GenerateContent) -> (&str, &str, &str) { - match item { - GenerateContent::ToolCall(RawToolCall { - tool_call_id, - tool_name, - input, - .. - }) => (tool_call_id, tool_name, input), - _ => panic!("expected ToolCall content, got {item:?}"), - } -} - -async fn collect_stream(result: StreamResult) -> Vec { - let mut parts = Vec::new(); - let mut stream = result.stream; - while let Some(part) = stream.next().await { - match part { - Ok(p) => parts.push(p), - Err(e) => panic!("stream error: {e:?}"), - } - } - parts -} - -// ═════════════════════════════════════════════════════════════════════════════ -// doGenerate tests -// ═════════════════════════════════════════════════════════════════════════════ - -/// Test: non-streaming text generation with usage and finish reason. -#[tokio::test] -async fn vertex_generate_text_response() { - let server = MockServer::start().await; - mock_generate_content( - &server, - json!({ - "candidates": [{ - "content": { - "role": "model", - "parts": [{ "text": "Hello from Vertex!" }] - }, - "finishReason": "STOP" - }], - "usageMetadata": { - "promptTokenCount": 5, - "candidatesTokenCount": 10, - "totalTokenCount": 15 - } - }), - ) - .await; - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!(result.content.len(), 1); - assert_eq!(as_text(&result.content[0]), "Hello from Vertex!"); - assert_eq!(result.finish_reason.unified, FinishReasonUnified::Stop); - assert_eq!(result.usage.input_tokens.total, Some(5)); - assert_eq!(result.usage.output_tokens.total, Some(10)); - let metadata = result - .provider_metadata - .as_ref() - .expect("provider metadata"); - assert_eq!(metadata["vertex"], metadata["googleVertex"]); - assert!(metadata.get("google").is_none()); -} - -/// Test: non-streaming tool call extraction. -#[tokio::test] -async fn vertex_generate_tool_call() { - let server = MockServer::start().await; - mock_generate_content( - &server, - json!({ - "candidates": [{ - "content": { - "role": "model", - "parts": [{ - "functionCall": { - "name": "getWeather", - "id": "call_1", - "args": { "location": "Tokyo" } - } - }] - }, - "finishReason": "STOP" - }], - "usageMetadata": { - "promptTokenCount": 5, - "candidatesTokenCount": 5 - } - }), - ) - .await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!(result.content.len(), 1); - let (id, name, input) = as_tool_call(&result.content[0]); - assert_eq!(id, "call_1"); - assert_eq!(name, "getWeather"); - assert_eq!(input, r#"{"location":"Tokyo"}"#); - // STOP with tool calls → ToolCalls - assert_eq!(result.finish_reason.unified, FinishReasonUnified::ToolCalls); -} - -#[tokio::test] -async fn vertex_renamed_code_execution_passes_core_generate_boundary() { - let server = MockServer::start().await; - mock_generate_content( - &server, - json!({ - "candidates": [{ - "content": { - "role": "model", - "parts": [ - { "executableCode": { "language": "PYTHON", "code": "print(2)" } }, - { "codeExecutionResult": { "outcome": "OUTCOME_OK", "output": "2" } }, - { "codeExecutionResult": { "outcome": "OUTCOME_OK", "output": "still 2" } } - ] - }, - "finishReason": "STOP" - }] - }), - ) - .await; - let model = make_model(&server); - let tool = Tool::Provider(ProviderTool { - id: "google.code_execution".to_string(), - name: "runCode".to_string(), - args: json!({}), - }); - - let result = generate_text( - &model, - "Run code", - GenerateTextOptions { - tools: Some(vec![tool.clone()]), - ..GenerateTextOptions::default() - }, - ) - .await - .expect("renamed Vertex code execution should pass Core validation"); - let call = result.tool_calls.first().expect("code execution call"); - assert_eq!(call.tool_name, "runCode"); - assert_eq!(call.provider_executed, Some(true)); - assert_eq!(call.invalid, None); - let call_metadata = call.provider_metadata.as_ref().expect("call metadata"); - assert_eq!( - serde_json::to_value(&call_metadata["googleVertex"]).unwrap(), - json!({ - "serverToolCallId": call.tool_call_id, - "serverToolType": "code_execution", - }) - ); - assert_eq!(call_metadata["vertex"], call_metadata["googleVertex"]); - assert!(call_metadata.get("google").is_none()); - assert!(result.raw.content.iter().any(|content| matches!( - content, - GenerateContent::ToolResult(ToolResult { tool_name, .. }) if tool_name == "runCode" - ))); - let result_ids: Vec<&str> = result - .raw - .content - .iter() - .filter_map(|content| match content { - GenerateContent::ToolResult(ToolResult { tool_call_id, .. }) => { - Some(tool_call_id.as_str()) - } - _ => None, - }) - .collect(); - assert_eq!(result_ids.len(), 2); - assert!( - result_ids - .iter() - .all(|id| *id == call.tool_call_id.as_str()) - ); - - let mut messages = vec![ModelMessage::user("Run code")]; - messages.extend(result.response_messages); - messages.push(ModelMessage::user("Continue")); - let mut next_options = CallOptions::new(convert_to_language_model_prompt(&messages, None)); - next_options.tools = Some(vec![tool]); - let replay = model - .do_generate(&next_options) - .await - .expect("Vertex replay request should succeed") - .request_body - .expect("request body"); - let assistant = replay["contents"] - .as_array() - .unwrap() - .iter() - .find(|content| content["role"] == "model") - .expect("assistant replay content"); - assert_eq!( - assistant["parts"], - json!([ - { "executableCode": { "language": "PYTHON", "code": "print(2)" } }, - { "codeExecutionResult": { "outcome": "OUTCOME_OK", "output": "2" } }, - { "codeExecutionResult": { "outcome": "OUTCOME_OK", "output": "still 2" } }, - ]) - ); -} - -#[tokio::test] -async fn vertex_server_tool_call_and_response_pass_core_generate_boundary() { - let server = MockServer::start().await; - mock_generate_content( - &server, - json!({ - "candidates": [{ - "content": { - "role": "model", - "parts": [ - { "toolCall": { - "toolType": "GOOGLE_SEARCH_WEB", - "args": { "query": "Singapore weather" }, - "id": "server-call-1" - }, "thoughtSignature": "call-signature" }, - { "toolResponse": { - "toolType": "GOOGLE_SEARCH_WEB", - "response": { "results": [{ "title": "Sunny" }] }, - "id": "server-call-1" - }, "thoughtSignature": "result-signature" } - ] - }, - "finishReason": "STOP" - }] - }), - ) - .await; - - let model = make_model(&server); - let result = generate_text(&model, "Search the web", GenerateTextOptions::default()) - .await - .expect("dynamic Vertex server tool should pass Core validation"); - - let call = result.tool_calls.first().expect("server tool call"); - assert_eq!(call.tool_call_id, "server-call-1"); - assert_eq!(call.tool_name, "server:GOOGLE_SEARCH_WEB"); - assert_eq!(call.input, json!({ "query": "Singapore weather" })); - assert_eq!(call.provider_executed, Some(true)); - assert_eq!(call.dynamic, Some(true)); - assert_eq!(call.invalid, None); - assert_eq!( - serde_json::to_value( - &call.provider_metadata.as_ref().expect("call metadata")["googleVertex"] - ) - .unwrap(), - json!({ - "serverToolCallId": "server-call-1", - "serverToolType": "GOOGLE_SEARCH_WEB", - "thoughtSignature": "call-signature" - }) - ); - assert_eq!( - call.provider_metadata.as_ref().unwrap()["vertex"], - call.provider_metadata.as_ref().unwrap()["googleVertex"] - ); - assert!( - call.provider_metadata - .as_ref() - .unwrap() - .get("google") - .is_none() - ); - assert!(result.raw.content.iter().any(|content| matches!( - content, - GenerateContent::ToolResult(ToolResult { - tool_call_id, - tool_name, - result, - dynamic: None, - provider_metadata: Some(metadata), - .. - }) if tool_call_id == "server-call-1" - && tool_name == "server:GOOGLE_SEARCH_WEB" - && *result == json!({ "results": [{ "title": "Sunny" }] }) - && serde_json::to_value(&metadata["googleVertex"]).unwrap() == json!({ - "serverToolCallId": "server-call-1", - "serverToolType": "GOOGLE_SEARCH_WEB", - "thoughtSignature": "result-signature" - }) - && metadata["vertex"] == metadata["googleVertex"] - && metadata.get("google").is_none() - ))); - assert_eq!(result.finish_reason.unified, FinishReasonUnified::Stop); -} - -/// Vertex Gemini thinking models echo `thoughtSignature` on `functionCall` -/// parts the same way the public Gemini API does — it must be preserved. -#[tokio::test] -async fn vertex_generate_tool_call_with_thought_signature() { - let server = MockServer::start().await; - mock_generate_content( - &server, - json!({ - "candidates": [{ - "content": { - "role": "model", - "parts": [{ - "functionCall": { - "name": "getWeather", - "id": "call_1", - "args": { "location": "Tokyo" } - }, - "thoughtSignature": "EuIDCt8DARFNMg/aRDRK3THWhBjzltCEy5/VM6ImWLJU8oHmnC75abdcZBMH" - }] - }, - "finishReason": "STOP" - }], - "usageMetadata": { - "promptTokenCount": 5, - "candidatesTokenCount": 5 - } - }), - ) - .await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed"); - - assert_eq!(result.content.len(), 1); - match &result.content[0] { - GenerateContent::ToolCall(RawToolCall { - tool_call_id, - tool_name, - input, - thought_signature, - provider_metadata, - .. - }) => { - assert_eq!(tool_call_id, "call_1"); - assert_eq!(tool_name, "getWeather"); - assert_eq!(input, r#"{"location":"Tokyo"}"#); - assert_eq!( - thought_signature.as_deref(), - Some("EuIDCt8DARFNMg/aRDRK3THWhBjzltCEy5/VM6ImWLJU8oHmnC75abdcZBMH") - ); - let metadata = provider_metadata.as_ref().expect("thought metadata"); - assert_eq!( - metadata["googleVertex"]["thoughtSignature"], - json!("EuIDCt8DARFNMg/aRDRK3THWhBjzltCEy5/VM6ImWLJU8oHmnC75abdcZBMH") - ); - assert_eq!(metadata["vertex"], metadata["googleVertex"]); - assert!(metadata.get("google").is_none()); - } - other => panic!("expected ToolCall, got {other:?}"), - } - assert_eq!(result.finish_reason.unified, FinishReasonUnified::ToolCalls); -} - -/// Test: HTTP error status is propagated. -#[tokio::test] -async fn vertex_generate_error() { - let server = MockServer::start().await; - mock_generate_error( - &server, - 400, - json!({ - "error": { - "code": 400, - "message": "Invalid model name", - "status": "INVALID_ARGUMENT" - } - }), - ) - .await; - - let model = make_model(&server); - let result = model.do_generate(&default_options(test_prompt())).await; - - assert!(result.is_err(), "should return error for 400 status"); -} - -// ═════════════════════════════════════════════════════════════════════════════ -// doStream tests -// ═════════════════════════════════════════════════════════════════════════════ - -/// Test: streaming text via SSE. -#[tokio::test] -async fn vertex_stream_text() { - let server = MockServer::start().await; - - let sse_body = sse_stream(&[ - json!({ - "candidates": [{ - "content": { "role": "model", "parts": [{ "text": "Hello" }] } - }] - }), - json!({ - "candidates": [{ - "content": { "role": "model", "parts": [{ "text": " world" }] }, - "finishReason": "STOP" - }], - "usageMetadata": { - "promptTokenCount": 3, - "candidatesTokenCount": 5 - } - }), - ]); - - mock_stream_content(&server, &sse_body).await; - - let model = make_model(&server); - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - - let parts = collect_stream(result).await; - - let text_deltas: Vec = parts - .iter() - .filter_map(|p| match p { - StreamPart::TextDelta { delta, .. } => Some(delta.clone()), - _ => None, - }) - .collect(); - assert_eq!(text_deltas, vec!["Hello", " world"]); - - let finish = parts.iter().find_map(|p| match p { - StreamPart::Finish { finish_reason, .. } => Some(finish_reason.clone()), - _ => None, - }); - assert!(finish.is_some()); - assert_eq!(finish.unwrap().unified, FinishReasonUnified::Stop); -} - -/// Test: API key authentication uses x-goog-api-key header. -#[tokio::test] -async fn vertex_api_key_auth() { - let server = MockServer::start().await; - mock_generate_content( - &server, - json!({ - "candidates": [{ - "content": { "parts": [{ "text": "Express!" }] }, - "finishReason": "STOP" - }] - }), - ) - .await; - - let model = VertexModel::new( - "gemini-2.0-flash".to_string(), - VertexConfig { - base_url: server.uri(), - auth: VertexAuth::ApiKey("test-api-key".to_string()), - api_key_source: None, - retry_config: aimux_provider_utils::RetryConfig::default(), - }, - ); - - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("do_generate should succeed with API key"); - - assert_eq!(as_text(&result.content[0]), "Express!"); -} - -/// Test: provider config construction and base URL. -#[tokio::test] -async fn vertex_provider_config() { - let config = VertexProviderConfig::new("test-token", "my-project", "us-central1"); - assert!( - config - .base_url - .contains("us-central1-aiplatform.googleapis.com") - ); - assert!(config.base_url.contains("my-project")); - - let config_express = VertexProviderConfig::with_api_key("test-key"); - assert!( - config_express - .base_url - .contains("aiplatform.googleapis.com") - ); - match config_express.auth { - VertexAuth::ApiKey(k) => assert_eq!(k, "test-key"), - _ => panic!("expected ApiKey auth"), - } -} - -// ═════════════════════════════════════════════════════════════════════════════ -// Additional cases — finish reasons, settings, headers, stream tool calls. -// ═════════════════════════════════════════════════════════════════════════════ - -/// A minimal Vertex generateContent "ok" body. -fn ok_vertex_body() -> Value { - json!({ - "candidates": [{ - "content": { "role": "model", "parts": [{ "text": "ok" }] }, - "finishReason": "STOP" - }], - "usageMetadata": { "promptTokenCount": 4, "candidatesTokenCount": 7 } - }) -} - -/// TS: MAX_TOKENS → Length -#[tokio::test] -async fn vertex_generate_finish_reason_max_tokens() { - let server = MockServer::start().await; - mock_generate_content( - &server, - json!({ - "candidates": [{ - "content": { "parts": [{ "text": "truncated" }] }, - "finishReason": "MAX_TOKENS" - }], - "usageMetadata": { "promptTokenCount": 4, "candidatesTokenCount": 34 } - }), - ) - .await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - assert_eq!(result.finish_reason.unified, FinishReasonUnified::Length); - assert_eq!(result.finish_reason.raw.as_deref(), Some("MAX_TOKENS")); -} - -/// TS: SAFETY → ContentFilter -#[tokio::test] -async fn vertex_generate_finish_reason_safety() { - let server = MockServer::start().await; - mock_generate_content( - &server, - json!({ - "candidates": [{ - "content": { "parts": [{ "text": "" }] }, - "finishReason": "SAFETY" - }], - "usageMetadata": { "promptTokenCount": 4, "candidatesTokenCount": 1 } - }), - ) - .await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - assert_eq!( - result.finish_reason.unified, - FinishReasonUnified::ContentFilter - ); -} - -/// TS: settings land in `generationConfig`. -#[tokio::test] -async fn vertex_generate_settings() { - let server = MockServer::start().await; - mock_generate_content(&server, ok_vertex_body()).await; - - let model = make_model(&server); - let mut opts = default_options(test_prompt()); - opts.max_output_tokens = Some(256); - opts.temperature = Some(0.5); - opts.top_p = Some(0.9); - opts.top_k = Some(40.0); - - let result = model.do_generate(&opts).await.expect("should succeed"); - let body = result.request_body.expect("body"); - assert_eq!(body["generationConfig"]["maxOutputTokens"], json!(256)); - assert!((body["generationConfig"]["temperature"].as_f64().unwrap() - 0.5).abs() < 1e-6); - assert!((body["generationConfig"]["topP"].as_f64().unwrap() - 0.9).abs() < 1e-6); - assert_eq!(body["generationConfig"]["topK"], json!(40.0)); -} - -/// TS: request body carries the user message as `contents`. -#[tokio::test] -async fn vertex_generate_request_body() { - let server = MockServer::start().await; - mock_generate_content(&server, ok_vertex_body()).await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - let body = result.request_body.expect("body"); - assert_eq!(body["contents"][0]["role"], json!("user")); - assert_eq!(body["contents"][0]["parts"][0]["text"], json!("Hello")); -} - -/// TS: response headers are exposed on the generate result. -#[tokio::test] -async fn vertex_generate_response_headers() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/models/gemini-2.0-flash:generateContent")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("test-header", "test-value") - .set_body_json(ok_vertex_body()), - ) - .mount(&server) - .await; - - let model = make_model(&server); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed"); - - let headers = result - .response_headers - .as_ref() - .expect("response_headers should be Some"); - assert_eq!(headers.get("test-header"), Some(&"test-value".to_string())); -} - -/// TS: streaming tool calls via SSE. -#[tokio::test] -async fn vertex_stream_tool_call() { - let server = MockServer::start().await; - let sse_body = sse_stream(&[json!({ - "candidates": [{ - "content": { - "role": "model", - "parts": [{ - "functionCall": { - "name": "getWeather", - "id": "call_1", - "args": { "location": "Tokyo" } - } - }] - }, - "finishReason": "STOP" - }] - })]); - mock_stream_content(&server, &sse_body).await; - - let model = make_model(&server); - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - - let parts = collect_stream(result).await; - let tool_call = parts.iter().find_map(|p| match p { - StreamPart::ToolCall(RawToolCall { - tool_call_id, - tool_name, - input, - .. - }) => Some((tool_call_id.clone(), tool_name.clone(), input.clone())), - _ => None, - }); - let (id, name, input) = tool_call.expect("should have ToolCall"); - assert_eq!(id, "call_1"); - assert_eq!(name, "getWeather"); - assert_eq!(input, r#"{"location":"Tokyo"}"#); -} - -/// TS: response headers are exposed on the stream result. -#[tokio::test] -async fn vertex_stream_response_headers() { - let server = MockServer::start().await; - let sse_body = sse_stream(&[json!({ - "candidates": [{ - "content": { "parts": [{ "text": "Hi" }] }, - "finishReason": "STOP" - }] - })]); - Mock::given(method("POST")) - .and(path("/models/gemini-2.0-flash:streamGenerateContent")) - .and(query_param("alt", "sse")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .insert_header("test-header", "test-value") - .set_body_string(sse_body), - ) - .mount(&server) - .await; - - let model = make_model(&server); - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - - let headers = result - .response_headers - .as_ref() - .expect("response_headers should be Some"); - assert_eq!(headers.get("test-header"), Some(&"test-value".to_string())); -} - -/// TS: a 429 response maps to `AiMuxError::ApiCall` (429 in `status_code`). -#[tokio::test] -async fn vertex_generate_rate_limit() { - let server = MockServer::start().await; - mock_generate_error( - &server, - 429, - json!({ "error": { "code": 429, "message": "Quota exceeded", "status": "RESOURCE_EXHAUSTED" } }), - ) - .await; - - let model = make_model(&server); - let result = model.do_generate(&default_options(test_prompt())).await; - assert!( - matches!(result, Err(ref e) if e.status_code() == Some(429)), - "expected RateLimited, got {result:?}" - ); -} - -// ═════════════════════════════════════════════════════════════════════════════ -// doStream — provider tool results & metadata (mirrors the google provider's -// do_stream tests in google_provider_tools_test.rs; issue #141). -// ═════════════════════════════════════════════════════════════════════════════ - -/// A single SSE chunk carrying `content.parts: [{ text }]`, optional -/// `finishReason`, and optional `groundingMetadata` / `urlContextMetadata`. -fn stream_chunk( - text: &str, - finish_reason: Option<&str>, - grounding: Option, - url_context: Option, -) -> Value { - let mut candidate = json!({ - "content": { "parts": [{ "text": text }], "role": "model" } - }); - if let Some(r) = finish_reason { - candidate["finishReason"] = json!(r); - } - if let Some(g) = grounding { - candidate["groundingMetadata"] = g; - } - if let Some(u) = url_context { - candidate["urlContextMetadata"] = u; - } - json!({ "candidates": [candidate] }) -} - -/// Extract the `Finish` part's provider metadata from a collected stream. -fn finish_provider_metadata(parts: &[StreamPart]) -> Option { - parts.iter().find_map(|p| match p { - StreamPart::Finish { - provider_metadata, .. - } => provider_metadata.clone(), - _ => None, - }) -} - -/// Collect `(tool_call_id, tool_name, input)` from streamed `ToolCall` parts. -fn stream_tool_calls(parts: &[StreamPart]) -> Vec<(String, String, String)> { - parts - .iter() - .filter_map(|p| match p { - StreamPart::ToolCall(RawToolCall { - tool_call_id, - tool_name, - input, - .. - }) => Some((tool_call_id.clone(), tool_name.clone(), input.clone())), - _ => None, - }) - .collect() -} - -/// Collect `(tool_call_id, result)` from streamed `ToolResult` parts. -fn stream_tool_results(parts: &[StreamPart]) -> Vec<(String, Value)> { - parts - .iter() - .filter_map(|p| match p { - StreamPart::ToolResult(ToolResult { - tool_call_id, - result, - .. - }) => Some((tool_call_id.clone(), result.clone())), - _ => None, - }) - .collect() -} - -/// Collect `(id, source_type, url, title)` from streamed `Source` parts. -fn stream_sources(parts: &[StreamPart]) -> Vec<(String, String, Option, Option)> { - parts - .iter() - .filter_map(|p| match p { - StreamPart::Source(Source { - id, - source_type, - url, - title, - .. - }) => Some((id.clone(), source_type.clone(), url.clone(), title.clone())), - _ => None, - }) - .collect() -} - -/// TS: "should stream code execution tool calls and results" — the Vertex -/// stream must not silently drop provider-executed code results (#141). -#[tokio::test] -async fn vertex_stream_code_execution_tool_calls_and_results() { - let server = MockServer::start().await; - mock_stream_content( - &server, - &sse_stream(&[ - json!({ - "candidates": [{ - "content": { - "parts": [{ "executableCode": { "language": "PYTHON", "code": "print(\"hello\")" } }] - } - }] - }), - json!({ - "candidates": [{ - "content": { - "parts": [ - { "codeExecutionResult": { "outcome": "OUTCOME_OK", "output": "hello\n" } }, - { "codeExecutionResult": { "outcome": "OUTCOME_OK", "output": "second result\n" } } - ] - }, - "finishReason": "STOP" - }] - }), - ]), - ) - .await; - mock_generate_content( - &server, - json!({ - "candidates": [{ - "content": { "parts": [{ "text": "ok" }] }, - "finishReason": "STOP" - }] - }), - ) - .await; - - let model = make_model(&server); - let tool = Tool::Provider(ProviderTool { - id: "google.code_execution".to_string(), - name: "runCode".to_string(), - args: json!({}), - }); - let options = CallOptions { - tools: Some(vec![tool.clone()]), - ..default_options(test_prompt()) - }; - let result = model - .do_stream(&options) - .await - .expect("do_stream should succeed"); - let parts = collect_stream(result).await; - - let calls = stream_tool_calls(&parts); - let has_call = calls.iter().any(|(_, name, input)| { - name == "runCode" && *input == r#"{"language":"PYTHON","code":"print(\"hello\")"}"# - }); - assert!( - has_call, - "expected a code_execution tool-call, got {calls:?}" - ); - - // The ToolResult must reference the preceding executableCode call id. - let results = stream_tool_results(&parts); - let call_id = calls - .iter() - .find(|(_, name, _)| name == "runCode") - .map(|(id, _, _)| id.clone()) - .expect("code_execution call id"); - let has_result = results.iter().any(|(id, output)| { - *id == call_id && *output == json!({ "outcome": "OUTCOME_OK", "output": "hello\n" }) - }); - assert!( - has_result, - "expected a code_execution tool-result, got {results:?}" - ); - assert_eq!(results.len(), 2); - assert!(results.iter().all(|(id, _)| id == &call_id)); - assert!(parts.iter().any(|part| matches!( - part, - StreamPart::ToolCall(RawToolCall { - tool_call_id, - tool_name, - provider_metadata: Some(metadata), - .. - }) if tool_name == "runCode" - && serde_json::to_value(&metadata["googleVertex"]).unwrap() == json!({ - "serverToolCallId": tool_call_id, - "serverToolType": "code_execution", - }) - && metadata["vertex"] == metadata["googleVertex"] - && metadata.get("google").is_none() - ))); - assert!(parts.iter().any(|part| matches!( - part, - StreamPart::ToolResult(ToolResult { - tool_call_id, - tool_name, - provider_metadata: Some(metadata), - .. - }) if tool_name == "runCode" - && serde_json::to_value(&metadata["googleVertex"]).unwrap() == json!({ - "serverToolCallId": tool_call_id, - "serverToolType": "code_execution", - }) - && metadata["vertex"] == metadata["googleVertex"] - && metadata.get("google").is_none() - ))); - - // Provider-executed tool → Stop, not ToolCalls. - let finish = parts.iter().find_map(|p| match p { - StreamPart::Finish { finish_reason, .. } => Some(finish_reason.clone()), - _ => None, - }); - assert_eq!( - finish.expect("finish part").unified, - FinishReasonUnified::Stop - ); - - let result = stream_text( - &model, - "Run code", - GenerateTextOptions { - tools: Some(vec![tool.clone()]), - ..GenerateTextOptions::default() - }, - ) - .await - .expect("stream_text should start") - .consume() - .await - .expect("renamed Vertex code execution should pass Core validation"); - let call = result.tool_calls.first().expect("code execution call"); - assert_eq!(call.tool_name, "runCode"); - assert_eq!(call.provider_executed, Some(true)); - assert_eq!(call.invalid, None); - - let mut messages = vec![ModelMessage::user("Run code")]; - messages.extend(result.response_messages); - messages.push(ModelMessage::user("Continue")); - let mut next_options = CallOptions::new(convert_to_language_model_prompt(&messages, None)); - next_options.tools = Some(vec![tool]); - let replay = model - .do_generate(&next_options) - .await - .expect("Vertex stream replay request should succeed") - .request_body - .expect("request body"); - let assistant = replay["contents"] - .as_array() - .unwrap() - .iter() - .find(|content| content["role"] == "model") - .expect("assistant replay content"); - assert_eq!( - assistant["parts"], - json!([ - { "executableCode": { "language": "PYTHON", "code": "print(\"hello\")" } }, - { "codeExecutionResult": { "outcome": "OUTCOME_OK", "output": "hello\n" } }, - { "codeExecutionResult": { "outcome": "OUTCOME_OK", "output": "second result\n" } }, - ]) - ); -} - -/// TS: "should stream code execution result with missing output field". -#[tokio::test] -async fn vertex_stream_code_execution_result_missing_output() { - let server = MockServer::start().await; - mock_stream_content( - &server, - &sse_stream(&[ - json!({ - "candidates": [{ - "content": { - "parts": [{ "executableCode": { - "language": "PYTHON", - "code": "img = PIL.Image.open('input.png')\nimg.save('output.png')\n" - } }] - } - }] - }), - json!({ - "candidates": [{ - "content": { - "parts": [{ "codeExecutionResult": { "outcome": "OUTCOME_OK" } }] - }, - "finishReason": "STOP" - }] - }), - ]), - ) - .await; - - let model = make_model(&server); - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - let parts = collect_stream(result).await; - - assert!( - stream_tool_calls(&parts) - .iter() - .any(|(_, name, _)| name == "code_execution"), - "expected a code_execution tool-call" - ); - // Missing output defaults to "". - let results = stream_tool_results(&parts); - let has_empty = results - .iter() - .any(|(_, output)| *output == json!({ "outcome": "OUTCOME_OK", "output": "" })); - assert!( - has_empty, - "expected a tool-result with empty output, got {results:?}" - ); -} - -/// TS: "should stream server-side toolCall and toolResponse parts (tool -/// combination)" — server tools are provider-executed and must be streamed. -#[tokio::test] -async fn vertex_stream_server_tool_call_and_response() { - let server = MockServer::start().await; - mock_stream_content( - &server, - &sse_stream(&[ - json!({ - "candidates": [{ - "content": { - "parts": [{ "toolCall": { - "toolType": "GOOGLE_SEARCH_WEB", - "args": { "query": "San Francisco weather" }, - "id": "server-call-1" - }, "thoughtSignature": "sig-abc" }] - } - }] - }), - json!({ - "candidates": [{ - "content": { - "parts": [ - { "toolResponse": { - "toolType": "GOOGLE_SEARCH_WEB", - "response": { "results": [{ "title": "Weather in SF" }] }, - "id": "server-call-1" - }, "thoughtSignature": "sig-def" }, - { "text": "The weather in San Francisco is sunny." } - ] - }, - "finishReason": "STOP" - }] - }), - ]), - ) - .await; - - let model = make_model(&server); - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - let parts = collect_stream(result).await; - - let calls = stream_tool_calls(&parts); - assert!( - calls - .iter() - .any(|(_, name, _)| name == "server:GOOGLE_SEARCH_WEB"), - "expected a server-side tool-call in the stream, got {calls:?}" - ); - - let results = stream_tool_results(&parts); - assert!( - results.iter().any(|(id, output)| *id == "server-call-1" - && *output == json!({ "results": [{ "title": "Weather in SF" }] })), - "expected a server tool-response keyed to server-call-1, got {results:?}" - ); - - // Provider-executed server tools → stop, not tool-calls. - let finish = parts.iter().find_map(|p| match p { - StreamPart::Finish { finish_reason, .. } => Some(finish_reason.clone()), - _ => None, - }); - assert_eq!( - finish.expect("finish part").unified, - FinishReasonUnified::Stop - ); - - let result = stream_text(&model, "Search the web", GenerateTextOptions::default()) - .await - .expect("stream_text should start"); - let mut core_stream = result.stream; - let mut core_parts = Vec::new(); - while let Some(part) = core_stream.next().await { - core_parts.push(part.expect("Core stream part should succeed")); - } - - assert!(core_parts.iter().any(|part| matches!( - part, - StreamPart::ToolCall(aimux_core::tool::ToolCall { - tool_call_id, - tool_name, - input, - provider_executed: Some(true), - dynamic: Some(true), - invalid: None, - provider_metadata: Some(metadata), - .. - }) if tool_call_id == "server-call-1" - && tool_name == "server:GOOGLE_SEARCH_WEB" - && *input == json!({ "query": "San Francisco weather" }) - && metadata["googleVertex"]["serverToolCallId"] == "server-call-1" - && metadata["vertex"] == metadata["googleVertex"] - && metadata.get("google").is_none() - ))); - assert!(core_parts.iter().any(|part| matches!( - part, - StreamPart::ToolResult(ToolResult { - tool_call_id, - tool_name, - result, - dynamic: None, - provider_metadata: Some(metadata), - .. - }) if tool_call_id == "server-call-1" - && tool_name == "server:GOOGLE_SEARCH_WEB" - && *result == json!({ "results": [{ "title": "Weather in SF" }] }) - && metadata["googleVertex"]["serverToolType"] == "GOOGLE_SEARCH_WEB" - && metadata["vertex"] == metadata["googleVertex"] - && metadata.get("google").is_none() - ))); -} - -/// TS: "should stream source events" + "should deduplicate sources across -/// chunks" — groundingMetadata is processed, not dropped. -#[tokio::test] -async fn vertex_stream_grounding_metadata_sources() { - let server = MockServer::start().await; - mock_stream_content( - &server, - &sse_stream(&[ - stream_chunk( - "first chunk", - None, - Some(json!({ - "groundingChunks": [ - { "web": { "uri": "https://example.com", "title": "Example" } }, - { "web": { "uri": "https://unique.com", "title": "Unique" } } - ] - })), - None, - ), - stream_chunk( - "second chunk", - None, - Some(json!({ - "groundingChunks": [ - { "web": { "uri": "https://example.com", "title": "Example Duplicate" } }, - { "web": { "uri": "https://another.com", "title": "Another" } } - ] - })), - None, - ), - stream_chunk("final chunk", Some("STOP"), None, None), - ]), - ) - .await; - - let model = make_model(&server); - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - let parts = collect_stream(result).await; - - let sources = stream_sources(&parts); - // The duplicate https://example.com appears in two chunks but should be - // emitted only once (keeping the first title). - let example: Vec<_> = sources - .iter() - .filter(|(_, _, url, _)| url.as_deref() == Some("https://example.com")) - .collect(); - assert_eq!( - example.len(), - 1, - "duplicate source should be emitted once, got {sources:?}" - ); - assert_eq!(example[0].1, "url"); - assert_eq!(example[0].3.as_deref(), Some("Example")); - assert_eq!( - sources.len(), - 3, - "expected 3 deduplicated sources, got {sources:?}" - ); -} - -/// TS: "should preserve grounding/url context metadata when it arrives before -/// the finishReason chunk" — Finish provider_metadata is non-empty with all -/// six fields under the `googleVertex` key. -#[tokio::test] -async fn vertex_stream_finish_provider_metadata() { - let server = MockServer::start().await; - mock_stream_content( - &server, - &sse_stream(&[ - { - let mut c = stream_chunk( - "hello", - None, - Some(json!({ - "webSearchQueries": ["super bowl 2026 halftime show"], - "groundingChunks": [{ - "web": { "uri": "https://example.com/superbowl", "title": "Super Bowl 2026" } - }] - })), - Some(json!({ - "urlMetadata": [{ - "retrievedUrl": "https://example.com/page", - "urlRetrievalStatus": "URL_RETRIEVAL_STATUS_SUCCESS" - }] - })), - ); - // Carry a valued promptFeedback so the assertion below locks - // the captured value, not just key presence. - c["promptFeedback"] = json!({ "promptTokenCount": 12 }); - c - }, - { - let mut c = stream_chunk(" world", Some("STOP"), None, None); - c["candidates"][0]["safetyRatings"] = json!([ - { "category": "HARM_CATEGORY_HARASSMENT", "probability": "NEGLIGIBLE" } - ]); - c["candidates"][0]["finishMessage"] = json!("natural stop"); - c["usageMetadata"] = json!({ - "promptTokenCount": 38, - "candidatesTokenCount": 1335, - "totalTokenCount": 1890 - }); - c - }, - ]), - ) - .await; - - let model = make_model(&server); - let result = model - .do_stream(&default_options(test_prompt())) - .await - .expect("do_stream should succeed"); - let parts = collect_stream(result).await; - - let pm = finish_provider_metadata(&parts).expect("finish part"); - let vertex = &pm["googleVertex"]; - assert_eq!(&pm["vertex"], vertex); - assert!(!pm.contains_key("google")); - assert!( - !vertex.is_empty(), - "googleVertex provider metadata should be non-empty, got {pm:?}" - ); - assert_eq!( - vertex["groundingMetadata"], - json!({ - "webSearchQueries": ["super bowl 2026 halftime show"], - "groundingChunks": [{ - "web": { "uri": "https://example.com/superbowl", "title": "Super Bowl 2026" } - }] - }) - ); - assert_eq!( - vertex["urlContextMetadata"], - json!({ - "urlMetadata": [{ - "retrievedUrl": "https://example.com/page", - "urlRetrievalStatus": "URL_RETRIEVAL_STATUS_SUCCESS" - }] - }) - ); - // usageMetadata is the serialized GoogleUsageMetadata (includes null - // optional fields, mirroring the google provider). - assert_eq!(vertex["usageMetadata"]["promptTokenCount"], json!(38)); - assert_eq!(vertex["usageMetadata"]["candidatesTokenCount"], json!(1335)); - assert_eq!(vertex["usageMetadata"]["totalTokenCount"], json!(1890)); - // All six keys are always present (null when absent), matching google. - for key in [ - "promptFeedback", - "groundingMetadata", - "urlContextMetadata", - "safetyRatings", - "usageMetadata", - "finishMessage", - ] { - assert!( - vertex.get(key).is_some(), - "expected key `{key}` in googleVertex metadata, got {pm:?}" - ); - } - // …and the three snapshot fields carry their captured values, not just - // key presence (locks the capture code, not the key scaffold). - assert_eq!(vertex["promptFeedback"], json!({ "promptTokenCount": 12 })); - assert_eq!( - vertex["safetyRatings"], - json!([{ "category": "HARM_CATEGORY_HARASSMENT", "probability": "NEGLIGIBLE" }]) - ); - assert_eq!(vertex["finishMessage"], json!("natural stop")); - - // Usage also lands on the Finish part itself. - let finish_usage = parts.iter().find_map(|p| match p { - StreamPart::Finish { usage, .. } => Some(usage.clone()), - _ => None, - }); - assert_eq!( - finish_usage.expect("finish usage").input_tokens.total, - Some(38) - ); -} diff --git a/aimux-providers/tests/vertex_remaining_test.rs b/aimux-providers/tests/vertex_remaining_test.rs deleted file mode 100644 index 18eaf7874..000000000 --- a/aimux-providers/tests/vertex_remaining_test.rs +++ /dev/null @@ -1,504 +0,0 @@ -//! Remaining Google Vertex AI provider tests — ported from the TS SDK suite. -//! -//! Mirrors `reference/ai/packages/google-vertex/src/google-vertex-provider.test.ts` -//! (provider configuration: auth headers, base URL, env-var resolution, project -//! handling, Express-mode API key, tuned-model restrictions). -//! -//! The TS tests mock `createAuthTokenGenerator` / `createGoogleVertex` and assert -//! on the options passed to the base provider. The Rust provider has no lazy -//! token generator — a bearer token (or API key) is supplied directly to -//! `VertexProviderConfig` — so each TS scenario is translated to the equivalent -//! observable Rust behaviour: which auth variant is selected, which headers are -//! sent, and how the project/location shape the base URL. -//! -//! Model-level generate/stream behaviour is already covered by -//! `vertex_model_test.rs`; this file focuses on provider configuration only. - -use serde_json::json; -use serial_test::serial; -use wiremock::matchers::{header, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -use aimux_core::content::ContentPart; -use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; -use aimux_core::options::CallOptions; -use aimux_core::provider::Provider; -use aimux_providers::vertex::{VertexAuth, VertexProvider, VertexProviderConfig}; - -// ── Shared helpers ─────────────────────────────────────────────────────────── - -/// All environment variables consulted by `VertexProviderConfig::from_env`. -const ENV_VARS: &[&str] = &[ - "GOOGLE_VERTEX_API_KEY", - "GOOGLE_VERTEX_ACCESS_TOKEN", - "GOOGLE_VERTEX_PROJECT", - "GOOGLE_VERTEX_LOCATION", -]; - -/// Remove every Vertex env var (test isolation for `#[serial]` env tests). -fn clear_vertex_env() { - for var in ENV_VARS { - unsafe { - std::env::remove_var(var); - } - } -} - -fn test_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] -} - -fn default_options(prompt: LanguageModelPrompt) -> CallOptions { - CallOptions::new(prompt) -} - -/// Mount a minimal 200 generateContent mock on the standard model path. -async fn mock_ok(server: &MockServer) { - Mock::given(method("POST")) - .and(path("/models/gemini-2.0-flash:generateContent")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "candidates": [{ - "content": { "parts": [{ "text": "hi" }], "role": "model" }, - "finishReason": "STOP", "index": 0 - }] - }))) - .mount(server) - .await; -} - -// ════════════════════════════════════════════════════════════════════════════ -// Provider identity (TS: google-vertex-provider.test.ts — base wiring) -// ════════════════════════════════════════════════════════════════════════════ - -#[tokio::test] -async fn provider_name_is_google_vertex() { - let config = VertexProviderConfig::new("test-token", "my-project", "us-central1"); - let provider = VertexProvider::new(config); - assert_eq!(provider.name(), "google.vertex"); -} - -#[tokio::test] -async fn language_model_trait_method_returns_boxed_model() { - let config = VertexProviderConfig::new("test-token", "my-project", "us-central1"); - let provider = VertexProvider::new(config); - let model = provider.language_model("gemini-2.0-flash").expect("model"); - assert_eq!(model.model_id(), "gemini-2.0-flash"); - assert_eq!(model.provider(), "google.vertex"); -} - -#[tokio::test] -async fn model_factory_returns_vertex_model() { - let config = VertexProviderConfig::new("test-token", "my-project", "us-central1"); - let provider = VertexProvider::new(config); - let model = provider.model("gemini-2.0-flash").expect("model"); - assert_eq!(model.model_id(), "gemini-2.0-flash"); - assert_eq!(model.provider(), "google.vertex"); -} - -/// TS: "creates the auth token generator once per provider instance" — a single -/// provider instance can mint multiple models that all share its config. -#[tokio::test] -async fn one_provider_instance_creates_multiple_models() { - let config = VertexProviderConfig::new("test-token", "my-project", "us-central1"); - let provider = VertexProvider::new(config); - - let m1 = provider.model("gemini-2.0-flash").expect("model 1"); - let m2 = provider.model("gemini-2.5-pro").expect("model 2"); - - assert_eq!(m1.model_id(), "gemini-2.0-flash"); - assert_eq!(m2.model_id(), "gemini-2.5-pro"); - assert_eq!(m1.provider(), "google.vertex"); - assert_eq!(m2.provider(), "google.vertex"); -} - -// ════════════════════════════════════════════════════════════════════════════ -// Auth headers (TS: "default headers function should return auth token", -// "should use custom headers in addition to auth token", -// "should pass options through to base provider when apiKey -// is provided") -// ════════════════════════════════════════════════════════════════════════════ - -/// TS: bearer-token auth resolves to an `Authorization: Bearer {token}` header. -#[tokio::test] -async fn bearer_token_sent_via_authorization_header() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/models/gemini-2.0-flash:generateContent")) - .and(header("authorization", "Bearer my-bearer-token")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "candidates": [{ - "content": { "parts": [{ "text": "hi" }], "role": "model" }, - "finishReason": "STOP", "index": 0 - }] - }))) - .mount(&server) - .await; - - let config = VertexProviderConfig::new("my-bearer-token", "my-project", "us-central1") - .with_base_url(server.uri()); - let provider = VertexProvider::new(config); - let model = provider.model("gemini-2.0-flash").expect("model"); - - model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed — mock requires the Authorization: Bearer header"); -} - -/// TS: custom headers are sent alongside the auth token. -#[tokio::test] -async fn custom_headers_sent_alongside_bearer_token() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/models/gemini-2.0-flash:generateContent")) - .and(header("authorization", "Bearer my-bearer-token")) - .and(header("custom-header", "custom-value")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "candidates": [{ - "content": { "parts": [{ "text": "hi" }], "role": "model" }, - "finishReason": "STOP", "index": 0 - }] - }))) - .mount(&server) - .await; - - let config = VertexProviderConfig::new("my-bearer-token", "my-project", "us-central1") - .with_base_url(server.uri()); - let provider = VertexProvider::new(config); - let model = provider.model("gemini-2.0-flash").expect("model"); - - let mut opts = default_options(test_prompt()); - let mut headers = std::collections::HashMap::new(); - headers.insert("Custom-Header".to_string(), "custom-value".to_string()); - opts.headers = Some(headers); - - model - .do_generate(&opts) - .await - .expect("should succeed — mock requires both headers"); -} - -/// TS: "should pass options through to base provider when apiKey is provided" — -/// Express-mode API key uses `x-goog-api-key` (and does NOT send an -/// `Authorization: Bearer` header, i.e. the token generator is not invoked). -#[tokio::test] -async fn api_key_uses_x_goog_api_key_header() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/models/gemini-2.0-flash:generateContent")) - .and(header("x-goog-api-key", "express-api-key")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "candidates": [{ - "content": { "parts": [{ "text": "hi" }], "role": "model" }, - "finishReason": "STOP", "index": 0 - }] - }))) - .mount(&server) - .await; - - let config = VertexProviderConfig::with_api_key("express-api-key").with_base_url(server.uri()); - let provider = VertexProvider::new(config); - let model = provider.model("gemini-2.0-flash").expect("model"); - - model - .do_generate(&default_options(test_prompt())) - .await - .expect("should succeed — mock requires the x-goog-api-key header"); -} - -/// Express-mode config selects the `ApiKey` auth variant (no bearer token). -#[tokio::test] -async fn with_api_key_selects_apikey_auth_variant() { - let config = VertexProviderConfig::with_api_key("express-key"); - match &config.auth { - VertexAuth::ApiKey(k) => assert_eq!(k, "express-key"), - other => panic!("expected ApiKey auth, got {other:?}"), - } -} - -/// Bearer-token config selects the `BearerToken` auth variant. -#[tokio::test] -async fn new_selects_bearer_token_auth_variant() { - let config = VertexProviderConfig::new("the-token", "proj", "us-central1"); - match &config.auth { - VertexAuth::BearerToken(t) => assert_eq!(t, "the-token"), - other => panic!("expected BearerToken auth, got {other:?}"), - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// Base URL construction (TS: project / location handling) -// ════════════════════════════════════════════════════════════════════════════ - -/// TS: the project is threaded into the base URL (projectId → URL path). -#[tokio::test] -async fn new_embeds_project_in_base_url() { - let config = VertexProviderConfig::new("token", "my-gcp-project", "us-central1"); - assert!( - config.base_url.contains("/projects/my-gcp-project/"), - "base_url should embed the project: {}", - config.base_url - ); -} - -/// TS: the location is threaded into the base URL. -#[tokio::test] -async fn new_embeds_location_in_base_url() { - let config = VertexProviderConfig::new("token", "proj", "us-central1"); - assert!( - config.base_url.contains("/locations/us-central1/"), - "base_url should embed the location: {}", - config.base_url - ); -} - -/// Location `us-central1` → `{location}-aiplatform.googleapis.com` host. -#[tokio::test] -async fn base_url_for_regional_location() { - let config = VertexProviderConfig::new("token", "proj", "us-central1"); - assert_eq!( - config.base_url, - "https://us-central1-aiplatform.googleapis.com/v1beta1/projects/proj/locations/us-central1/publishers/google" - ); -} - -/// Location `global` → bare `aiplatform.googleapis.com` host. -#[tokio::test] -async fn base_url_for_global_location() { - let config = VertexProviderConfig::new("token", "proj", "global"); - assert_eq!( - config.base_url, - "https://aiplatform.googleapis.com/v1beta1/projects/proj/locations/global/publishers/google" - ); -} - -/// Location `eu`/`us` → multi-region `aiplatform.{region}.rep.googleapis.com`. -#[tokio::test] -async fn base_url_for_eu_multi_region() { - let config = VertexProviderConfig::new("token", "proj", "eu"); - assert_eq!( - config.base_url, - "https://aiplatform.eu.rep.googleapis.com/v1beta1/projects/proj/locations/eu/publishers/google" - ); -} - -#[tokio::test] -async fn base_url_for_us_multi_region() { - let config = VertexProviderConfig::new("token", "proj", "us"); - assert_eq!( - config.base_url, - "https://aiplatform.us.rep.googleapis.com/v1beta1/projects/proj/locations/us/publishers/google" - ); -} - -/// Express-mode (API key) uses the `/v1/publishers/google` endpoint (no -/// project/location scoping). -#[tokio::test] -async fn express_mode_base_url() { - let config = VertexProviderConfig::with_api_key("key"); - assert_eq!( - config.base_url, - "https://aiplatform.googleapis.com/v1/publishers/google" - ); -} - -/// `with_base_url` overrides the constructed URL and strips a trailing slash. -#[tokio::test] -async fn with_base_url_strips_trailing_slash() { - let config = VertexProviderConfig::new("token", "proj", "us-central1") - .with_base_url("https://example.com/v1beta1/"); - assert_eq!(config.base_url, "https://example.com/v1beta1"); -} - -/// `with_base_url` is honoured end-to-end (request hits the override host). -#[tokio::test] -async fn with_base_url_is_used_for_requests() { - let server = MockServer::start().await; - mock_ok(&server).await; - - let config = - VertexProviderConfig::new("token", "proj", "us-central1").with_base_url(server.uri()); - let provider = VertexProvider::new(config); - let model = provider.model("gemini-2.0-flash").expect("model"); - - model - .do_generate(&default_options(test_prompt())) - .await - .expect("request should hit the mock server"); -} - -// ════════════════════════════════════════════════════════════════════════════ -// Tuned-model restriction (TS: Express mode cannot address tuned endpoints) -// ════════════════════════════════════════════════════════════════════════════ - -/// TS: a tuned model (`endpoints/…`) cannot be used with Express-mode API key -/// auth — the provider rejects it up front. -#[tokio::test] -async fn tuned_model_rejected_with_api_key_auth() { - let config = VertexProviderConfig::with_api_key("express-key"); - let provider = VertexProvider::new(config); - let result = provider.model("endpoints/1234567890"); - assert!( - result.is_err(), - "tuned models should be rejected under Express-mode API key auth" - ); -} - -/// Tuned models are allowed with standard (bearer-token) auth. -#[tokio::test] -async fn tuned_model_allowed_with_bearer_token_auth() { - let config = VertexProviderConfig::new("token", "proj", "us-central1"); - let provider = VertexProvider::new(config); - let model = provider - .model("endpoints/1234567890") - .expect("tuned models should be allowed with bearer-token auth"); - assert_eq!(model.model_id(), "endpoints/1234567890"); -} - -// ════════════════════════════════════════════════════════════════════════════ -// from_env (TS: env-var driven provider creation) -// ════════════════════════════════════════════════════════════════════════════ - -/// `GOOGLE_VERTEX_API_KEY` selects Express mode (API key auth). -#[tokio::test] -#[serial] -async fn from_env_uses_api_key_when_set() { - clear_vertex_env(); - unsafe { - std::env::set_var("GOOGLE_VERTEX_API_KEY", "env-api-key"); - } - let config = VertexProviderConfig::from_env().expect("from_env"); - match config.auth { - VertexAuth::ApiKey(k) => assert_eq!(k, "env-api-key"), - other => panic!("expected ApiKey auth, got {other:?}"), - } - clear_vertex_env(); -} - -/// `GOOGLE_VERTEX_API_KEY` takes precedence over the access-token vars. -#[tokio::test] -#[serial] -async fn from_env_prefers_api_key_over_access_token() { - clear_vertex_env(); - unsafe { - std::env::set_var("GOOGLE_VERTEX_API_KEY", "env-api-key"); - std::env::set_var("GOOGLE_VERTEX_ACCESS_TOKEN", "env-token"); - std::env::set_var("GOOGLE_VERTEX_PROJECT", "env-project"); - } - let config = VertexProviderConfig::from_env().expect("from_env"); - // API key wins → Express mode base URL (no project scoping). - assert!( - config.base_url.contains("/v1/publishers/google"), - "API key should select Express mode: {}", - config.base_url - ); - match config.auth { - VertexAuth::ApiKey(k) => assert_eq!(k, "env-api-key"), - other => panic!("expected ApiKey auth, got {other:?}"), - } - clear_vertex_env(); -} - -/// Access token + project + location → bearer-token auth with scoped base URL. -#[tokio::test] -#[serial] -async fn from_env_uses_access_token_project_location() { - clear_vertex_env(); - unsafe { - std::env::set_var("GOOGLE_VERTEX_ACCESS_TOKEN", "env-token"); - std::env::set_var("GOOGLE_VERTEX_PROJECT", "env-project"); - std::env::set_var("GOOGLE_VERTEX_LOCATION", "europe-west1"); - } - let config = VertexProviderConfig::from_env().expect("from_env"); - match config.auth { - VertexAuth::BearerToken(t) => assert_eq!(t, "env-token"), - other => panic!("expected BearerToken auth, got {other:?}"), - } - assert!(config.base_url.contains("/projects/env-project/")); - assert!(config.base_url.contains("/locations/europe-west1/")); - clear_vertex_env(); -} - -/// `GOOGLE_VERTEX_LOCATION` defaults to `us-central1` when unset. -#[tokio::test] -#[serial] -async fn from_env_defaults_location_to_us_central1() { - clear_vertex_env(); - unsafe { - std::env::set_var("GOOGLE_VERTEX_ACCESS_TOKEN", "env-token"); - std::env::set_var("GOOGLE_VERTEX_PROJECT", "env-project"); - } - let config = VertexProviderConfig::from_env().expect("from_env"); - assert!( - config.base_url.contains("/locations/us-central1/"), - "location should default to us-central1: {}", - config.base_url - ); - clear_vertex_env(); -} - -/// Missing every var → error. -#[tokio::test] -#[serial] -async fn from_env_errors_when_all_missing() { - clear_vertex_env(); - let result = VertexProviderConfig::from_env(); - assert!(result.is_err(), "from_env should error with no credentials"); - clear_vertex_env(); -} - -/// Access token present but project missing → error. -#[tokio::test] -#[serial] -async fn from_env_errors_when_project_missing() { - clear_vertex_env(); - unsafe { - std::env::set_var("GOOGLE_VERTEX_ACCESS_TOKEN", "env-token"); - } - let result = VertexProviderConfig::from_env(); - assert!( - result.is_err(), - "from_env should error when GOOGLE_VERTEX_PROJECT is missing" - ); - clear_vertex_env(); -} - -/// Project present but access token missing → error. -#[tokio::test] -#[serial] -async fn from_env_errors_when_access_token_missing() { - clear_vertex_env(); - unsafe { - std::env::set_var("GOOGLE_VERTEX_PROJECT", "env-project"); - } - let result = VertexProviderConfig::from_env(); - assert!( - result.is_err(), - "from_env should error when GOOGLE_VERTEX_ACCESS_TOKEN is missing" - ); - clear_vertex_env(); -} - -/// An empty `GOOGLE_VERTEX_API_KEY` falls through to access-token auth. -#[tokio::test] -#[serial] -async fn from_env_empty_api_key_falls_through_to_access_token() { - clear_vertex_env(); - unsafe { - std::env::set_var("GOOGLE_VERTEX_API_KEY", " "); - std::env::set_var("GOOGLE_VERTEX_ACCESS_TOKEN", "env-token"); - std::env::set_var("GOOGLE_VERTEX_PROJECT", "env-project"); - } - let config = VertexProviderConfig::from_env().expect("from_env"); - match config.auth { - VertexAuth::BearerToken(t) => assert_eq!(t, "env-token"), - other => panic!("expected BearerToken auth, got {other:?}"), - } - clear_vertex_env(); -} diff --git a/aimux-providers/tests/xai_responses_test.rs b/aimux-providers/tests/xai_responses_test.rs deleted file mode 100644 index c482a561a..000000000 --- a/aimux-providers/tests/xai_responses_test.rs +++ /dev/null @@ -1,3041 +0,0 @@ -//! Rust translations of the AI SDK xAI Responses API tests. -//! -//! Sources (TS → Rust): -//! - `xai-responses-language-model.test.ts` → `do_generate` / `do_stream` mods -//! - `xai-responses-prepare-tools.test.ts` → `prepare_tools` mod -//! - `convert-xai-responses-usage.test.ts` → `convert_usage` mod -//! - `convert-to-xai-responses-input.test.ts` → `convert_input` mod -//! -//! Each test uses `wiremock` to spin up a mock HTTP server, configures a JSON -//! or SSE response, creates an `XaiResponsesModel` via `XAIProvider`, calls -//! `do_generate` / `do_stream`, and asserts on the result. - -use aimux_core::shared::{SharedProviderOptions, provider_namespace}; -use aimux_core::tool::{RawToolCall, ToolResult}; - -use futures::StreamExt; -use serde_json::{Value, json}; -use wiremock::matchers::{method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -use aimux_core::content::ContentPart; -use aimux_core::generate::{GenerateTextOptions, generate_text, stream_text}; -use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::LanguageModelPromptMessage; -use aimux_core::message::Role; -use aimux_core::options::{CallOptions, ResponseFormat, ToolChoice}; -use aimux_core::result::{GenerateContent, ReasoningOutput, Source}; -use aimux_core::stream_part::StreamPart; -use aimux_core::tool::{FunctionTool, ProviderTool, Tool}; -use aimux_core::types::ReasoningEffort; - -use aimux_providers::{XAIConfig, XAIProvider}; - -// ── shared helpers ─────────────────────────────────────────────────────────── - -fn test_prompt() -> Vec { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("hello")], - ..Default::default() - }] -} - -fn default_options(prompt: Vec) -> CallOptions { - CallOptions::new(prompt) -} - -fn sse_event(json_str: &str) -> String { - format!("data: {json_str}\n\n") -} - -fn sse_body(events: &[&str]) -> String { - let mut body = String::new(); - for event in events { - body.push_str(event); - } - body.push_str("data: [DONE]\n\n"); - body -} - -async fn collect_stream(result: aimux_core::result::StreamResult) -> Vec { - let mut parts = Vec::new(); - let mut stream = result.stream; - while let Some(part) = stream.next().await { - match part { - Ok(p) => parts.push(p), - Err(e) => panic!("stream error: {e:?}"), - } - } - parts -} - -fn make_provider(server: &MockServer) -> XAIProvider { - let config = XAIConfig::new("test-api-key").with_base_url(server.uri()); - XAIProvider::new(config) -} - -fn xai_provider_options(opts: Value) -> Option { - Some(provider_namespace("xai", opts)) -} - -/// A standard Responses API JSON body returning a text message. -fn text_response_body() -> Value { - json!({ - "id": "resp_123", - "object": "response", - "created_at": 1700000000, - "status": "completed", - "model": "grok-4-fast-non-reasoning", - "output": [{ - "type": "message", - "id": "msg_123", - "status": "completed", - "role": "assistant", - "content": [{ - "type": "output_text", - "text": "hello world", - "annotations": [] - }] - }], - "usage": { "input_tokens": 10, "output_tokens": 5, "total_tokens": 15 } - }) -} - -// ════════════════════════════════════════════════════════════════════════════ -// doGenerate — basic text generation -// ════════════════════════════════════════════════════════════════════════════ - -mod do_generate { - use super::*; - - /// TS: should generate text content - #[tokio::test] - async fn generate_text_content() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_response_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - - assert_eq!(result.content.len(), 1); - match &result.content[0] { - GenerateContent::Text { text, .. } => assert_eq!(text, "hello world"), - other => panic!("expected Text, got {other:?}"), - } - } - - /// TS: should extract usage correctly - #[tokio::test] - async fn extract_usage() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", - "object": "response", - "status": "completed", - "model": "grok-4-fast-non-reasoning", - "output": [], - "usage": { - "input_tokens": 345, - "output_tokens": 538, - "total_tokens": 883, - "output_tokens_details": { "reasoning_tokens": 123 } - } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - - assert_eq!(result.usage.input_tokens.total, Some(345)); - assert_eq!(result.usage.input_tokens.no_cache, Some(345)); - assert_eq!(result.usage.input_tokens.cache_read, Some(0)); - assert_eq!(result.usage.output_tokens.total, Some(538)); - assert_eq!(result.usage.output_tokens.text, Some(415)); // 538 - 123 - assert_eq!(result.usage.output_tokens.reasoning, Some(123)); - } - - /// TS: should expose cost_in_usd_ticks in providerMetadata - #[tokio::test] - async fn cost_in_usd_ticks() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", - "object": "response", - "status": "completed", - "model": "grok-4-fast-non-reasoning", - "output": [], - "usage": { "input_tokens": 10, "output_tokens": 5, "cost_in_usd_ticks": 113500 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - - assert_eq!( - result.provider_metadata, - Some(provider_namespace( - "xai", - json!({ "costInUsdTicks": 113500 }) - )) - ); - } - - /// TS: should not include providerMetadata when cost_in_usd_ticks is missing - #[tokio::test] - async fn no_cost_no_metadata() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", - "object": "response", - "status": "completed", - "model": "grok-4-fast-non-reasoning", - "output": [], - "usage": { "input_tokens": 10, "output_tokens": 5 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - - assert!(result.provider_metadata.is_none()); - } - - /// TS: should extract finish reason from status - #[tokio::test] - async fn finish_reason_from_status() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", - "object": "response", - "status": "completed", - "model": "grok-4-fast-non-reasoning", - "output": [], - "usage": { "input_tokens": 10, "output_tokens": 5 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - - assert_eq!(result.finish_reason.raw.as_deref(), Some("completed")); - assert_eq!( - result.finish_reason.unified, - aimux_core::types::FinishReasonUnified::Stop - ); - } - - /// TS: should return tool-calls finish reason when function_call is present - #[tokio::test] - async fn tool_calls_finish_reason() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", - "object": "response", - "status": "completed", - "model": "grok-4-fast-non-reasoning", - "output": [{ - "type": "function_call", - "id": "fc_123", - "name": "weather", - "arguments": "{\"location\":\"sf\"}", - "call_id": "call_123" - }], - "usage": { "input_tokens": 10, "output_tokens": 5 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let options = CallOptions { - tools: Some(vec![ - FunctionTool::new( - "weather", - json!({ - "type": "object", - "properties": { "location": { "type": "string" } } - }), - ) - .into(), - ]), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - assert_eq!( - result.finish_reason.unified, - aimux_core::types::FinishReasonUnified::ToolCalls - ); - assert_eq!(result.finish_reason.raw.as_deref(), Some("completed")); - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// doGenerate — reasoning content -// ════════════════════════════════════════════════════════════════════════════ - -mod reasoning { - use super::*; - - /// TS: should extract reasoning with encrypted content when store=false - #[tokio::test] - async fn reasoning_with_encrypted_content() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", - "object": "response", - "status": "completed", - "model": "grok-4-fast-non-reasoning", - "output": [ - { - "type": "reasoning", - "id": "rs_456", - "status": "completed", - "summary": [{ "type": "summary_text", "text": "First, analyze the question carefully." }], - "encrypted_content": "abc123encryptedcontent" - }, - { - "type": "message", - "id": "msg_123", - "status": "completed", - "role": "assistant", - "content": [{ "type": "output_text", "text": "The answer is 42.", "annotations": [] }] - } - ], - "usage": { "input_tokens": 10, "output_tokens": 20, "output_tokens_details": { "reasoning_tokens": 15 } } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - - assert_eq!(result.content.len(), 2); - match &result.content[0] { - GenerateContent::Reasoning(ReasoningOutput { - text, - provider_metadata, - }) => { - assert_eq!(text, "First, analyze the question carefully."); - assert_eq!( - provider_metadata, - &Some(provider_namespace( - "xai", - json!({ "itemId": "rs_456", "reasoningEncryptedContent": "abc123encryptedcontent" }) - )) - ); - } - other => panic!("expected Reasoning, got {other:?}"), - } - match &result.content[1] { - GenerateContent::Text { text, .. } => assert_eq!(text, "The answer is 42."), - other => panic!("expected Text, got {other:?}"), - } - } - - /// TS: should handle reasoning without encrypted content - #[tokio::test] - async fn reasoning_without_encrypted_content() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", - "object": "response", - "status": "completed", - "model": "grok-4-fast-non-reasoning", - "output": [ - { - "type": "reasoning", - "id": "rs_456", - "status": "completed", - "summary": [{ "type": "summary_text", "text": "Thinking through the problem." }] - }, - { - "type": "message", - "id": "msg_123", - "status": "completed", - "role": "assistant", - "content": [{ "type": "output_text", "text": "Solution found.", "annotations": [] }] - } - ], - "usage": { "input_tokens": 10, "output_tokens": 15 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - - match &result.content[0] { - GenerateContent::Reasoning(ReasoningOutput { - text, - provider_metadata, - }) => { - assert_eq!(text, "Thinking through the problem."); - assert_eq!( - provider_metadata, - &Some(provider_namespace("xai", json!({ "itemId": "rs_456" }))) - ); - } - other => panic!("expected Reasoning, got {other:?}"), - } - } - - /// TS: should extract reasoning from content when summary is empty - #[tokio::test] - async fn reasoning_from_content() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", - "object": "response", - "status": "completed", - "model": "grok-3-mini", - "output": [ - { - "type": "reasoning", - "id": "rs_456", - "status": "completed", - "summary": [], - "content": [{ "type": "reasoning_text", "text": "Let me think step by step." }] - }, - { - "type": "message", - "id": "msg_123", - "status": "completed", - "role": "assistant", - "content": [{ "type": "output_text", "text": "The answer is 444.", "annotations": [] }] - } - ], - "usage": { "input_tokens": 10, "output_tokens": 15 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-3-mini"); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - - match &result.content[0] { - GenerateContent::Reasoning(ReasoningOutput { text, .. }) => { - assert_eq!(text, "Let me think step by step."); - } - other => panic!("expected Reasoning, got {other:?}"), - } - } - - /// TS: should extract reasoning with encrypted content but empty summary text - #[tokio::test] - async fn reasoning_empty_summary_with_encrypted() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", - "object": "response", - "status": "completed", - "model": "grok-4-fast-non-reasoning", - "output": [ - { - "type": "reasoning", - "id": "rs_789", - "status": "completed", - "summary": [], - "encrypted_content": "encrypted_zdr_content_xyz" - }, - { - "type": "message", - "id": "msg_123", - "status": "completed", - "role": "assistant", - "content": [{ "type": "output_text", "text": "Here is my response.", "annotations": [] }] - } - ], - "usage": { "input_tokens": 10, "output_tokens": 20, "output_tokens_details": { "reasoning_tokens": 15 } } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - - match &result.content[0] { - GenerateContent::Reasoning(ReasoningOutput { - text, - provider_metadata, - }) => { - assert_eq!(text, ""); - assert_eq!( - provider_metadata, - &Some(provider_namespace( - "xai", - json!({ "itemId": "rs_789", "reasoningEncryptedContent": "encrypted_zdr_content_xyz" }) - )) - ); - } - other => panic!("expected Reasoning, got {other:?}"), - } - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// doGenerate — settings and provider options -// ════════════════════════════════════════════════════════════════════════════ - -mod settings { - use super::*; - - /// TS: should send model id and settings - #[tokio::test] - async fn send_model_id_and_settings() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", - "object": "response", - "status": "completed", - "model": "grok-4-fast-non-reasoning", - "output": [], - "usage": { "input_tokens": 10, "output_tokens": 5 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let options = CallOptions { - prompt: vec![ - LanguageModelPromptMessage { - role: Role::System, - content: vec![ContentPart::text("you are helpful")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("hello")], - ..Default::default() - }, - ], - temperature: Some(0.5), - top_p: Some(0.9), - max_output_tokens: Some(100), - ..CallOptions::new(vec![]) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!(body["model"], "grok-4-fast-non-reasoning"); - assert_eq!(body["temperature"], 0.5); - assert_eq!(body["top_p"], 0.9); - assert_eq!(body["max_output_tokens"], 100); - assert_eq!( - body["input"], - json!([ - { "role": "system", "content": "you are helpful" }, - { "role": "user", "content": [{ "type": "input_text", "text": "hello" }] } - ]) - ); - } - - /// TS: reasoningEffort provider option - #[tokio::test] - async fn reasoning_effort_option() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", "object": "response", "status": "completed", - "model": "grok-4-fast-non-reasoning", "output": [], - "usage": { "input_tokens": 10, "output_tokens": 5 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let options = CallOptions { - provider_options: xai_provider_options(json!({ "reasoningEffort": "high" })), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - assert_eq!(result.request_body.unwrap()["reasoning"]["effort"], "high"); - } - - /// TS: reasoningSummary provider option - #[tokio::test] - async fn reasoning_summary_option() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", "object": "response", "status": "completed", - "model": "grok-4-fast-non-reasoning", "output": [], - "usage": { "input_tokens": 10, "output_tokens": 5 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let options = CallOptions { - provider_options: xai_provider_options(json!({ "reasoningSummary": "concise" })), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - assert_eq!( - result.request_body.unwrap()["reasoning"]["summary"], - "concise" - ); - } - - /// TS: reasoningEffort and reasoningSummary together - #[tokio::test] - async fn reasoning_effort_and_summary() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", "object": "response", "status": "completed", - "model": "grok-4-fast-non-reasoning", "output": [], - "usage": { "input_tokens": 10, "output_tokens": 5 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let options = CallOptions { - provider_options: xai_provider_options( - json!({ "reasoningEffort": "high", "reasoningSummary": "detailed" }), - ), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - assert_eq!( - result.request_body.unwrap()["reasoning"], - json!({ "effort": "high", "summary": "detailed" }) - ); - } - - /// TS: store:false - #[tokio::test] - async fn store_false() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", "object": "response", "status": "completed", - "model": "grok-4-fast-non-reasoning", "output": [], - "usage": { "input_tokens": 10, "output_tokens": 5 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let options = CallOptions { - provider_options: xai_provider_options(json!({ "store": false })), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!(body["store"], false); - assert_eq!(body["include"], json!(["reasoning.encrypted_content"])); - } - - /// TS: store:true (should not set store or include) - #[tokio::test] - async fn store_true() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", "object": "response", "status": "completed", - "model": "grok-4-fast-non-reasoning", "output": [], - "usage": { "input_tokens": 10, "output_tokens": 5 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let options = CallOptions { - provider_options: xai_provider_options(json!({ "store": true })), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert!(body.get("store").is_none()); - assert!(body.get("include").is_none()); - } - - /// TS: previousResponseId - #[tokio::test] - async fn previous_response_id() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", "object": "response", "status": "completed", - "model": "grok-4-fast-non-reasoning", "output": [], - "usage": { "input_tokens": 10, "output_tokens": 5 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let options = CallOptions { - provider_options: xai_provider_options(json!({ "previousResponseId": "resp_456" })), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - assert_eq!( - result.request_body.unwrap()["previous_response_id"], - "resp_456" - ); - } - - /// TS: include with file_search_call.results - #[tokio::test] - async fn include_option() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", "object": "response", "status": "completed", - "model": "grok-4-fast-non-reasoning", "output": [], - "usage": { "input_tokens": 10, "output_tokens": 5 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let options = CallOptions { - provider_options: xai_provider_options( - json!({ "include": ["file_search_call.results"] }), - ), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - assert_eq!( - result.request_body.unwrap()["include"], - json!(["file_search_call.results"]) - ); - } - - /// TS: include with file_search_call.results and store:false - #[tokio::test] - async fn include_with_store_false() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", "object": "response", "status": "completed", - "model": "grok-4-fast-non-reasoning", "output": [], - "usage": { "input_tokens": 10, "output_tokens": 5 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let options = CallOptions { - provider_options: xai_provider_options( - json!({ "include": ["file_search_call.results"], "store": false }), - ), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!(body["store"], false); - assert_eq!( - body["include"], - json!(["file_search_call.results", "reasoning.encrypted_content"]) - ); - } - - /// TS: should map top-level reasoning to reasoning effort - #[tokio::test] - async fn top_level_reasoning() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", "object": "response", "status": "completed", - "model": "grok-4.3", "output": [], - "usage": { "input_tokens": 10, "output_tokens": 5 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4.3"); - let options = CallOptions { - reasoning: Some(ReasoningEffort::High), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - assert_eq!(result.request_body.unwrap()["reasoning"]["effort"], "high"); - } - - /// TS: should map top-level reasoning none to "none" - #[tokio::test] - async fn top_level_reasoning_none() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", "object": "response", "status": "completed", - "model": "grok-4.3", "output": [], - "usage": { "input_tokens": 10, "output_tokens": 5 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4.3"); - let options = CallOptions { - reasoning: Some(ReasoningEffort::None), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - assert_eq!(result.request_body.unwrap()["reasoning"]["effort"], "none"); - } - - /// TS: should prefer providerOptions reasoningEffort over top-level reasoning - #[tokio::test] - async fn prefer_provider_reasoning_effort() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", "object": "response", "status": "completed", - "model": "grok-4.3", "output": [], - "usage": { "input_tokens": 10, "output_tokens": 5 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4.3"); - let options = CallOptions { - reasoning: Some(ReasoningEffort::None), - provider_options: xai_provider_options(json!({ "reasoningEffort": "high" })), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - assert_eq!(result.request_body.unwrap()["reasoning"]["effort"], "high"); - } - - /// TS: should omit reasoning effort and warn for models that do not support it - #[tokio::test] - async fn unsupported_reasoning_model() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", "object": "response", "status": "completed", - "model": "grok-4.20-reasoning", "output": [], - "usage": { "input_tokens": 10, "output_tokens": 5 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4.20-reasoning"); - let options = CallOptions { - reasoning: Some(ReasoningEffort::None), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert!(body.get("reasoning").is_none()); - assert!(result.warnings.iter().any(|w| match w { - aimux_core::types::Warning::Unsupported { feature, details } => { - feature == "reasoning" - && details.as_deref() - == Some("reasoning \"none\" is not supported by this model.") - } - _ => false, - })); - } - - /// TS: should warn about unsupported stopSequences - #[tokio::test] - async fn warn_stop_sequences() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", "object": "response", "status": "completed", - "model": "grok-4-fast-non-reasoning", "output": [], - "usage": { "input_tokens": 10, "output_tokens": 5 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let options = CallOptions { - stop_sequences: Some(vec!["\n\n".to_string(), "STOP".to_string()]), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - assert!(result.warnings.iter().any(|w| match w { - aimux_core::types::Warning::Unsupported { feature, .. } => feature == "stopSequences", - _ => false, - })); - } - - /// TS: should send response format json schema - #[tokio::test] - async fn response_format_json_schema() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", "object": "response", "status": "completed", - "model": "grok-4-fast-non-reasoning", "output": [], - "usage": { "input_tokens": 10, "output_tokens": 5 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let options = CallOptions { - response_format: Some(ResponseFormat::Json { - schema: Some(json!({ - "type": "object", - "properties": { "name": { "type": "string" } }, - "required": ["name"], - "additionalProperties": false - })), - name: Some("recipe".to_string()), - description: Some("A recipe object".to_string()), - }), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!(body["text"]["format"]["type"], "json_schema"); - assert_eq!(body["text"]["format"]["strict"], true); - assert_eq!(body["text"]["format"]["name"], "recipe"); - assert_eq!(body["text"]["format"]["description"], "A recipe object"); - } - - /// TS: should send response format json object when no schema provided - #[tokio::test] - async fn response_format_json_object() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", "object": "response", "status": "completed", - "model": "grok-4-fast-non-reasoning", "output": [], - "usage": { "input_tokens": 10, "output_tokens": 5 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let options = CallOptions { - response_format: Some(ResponseFormat::Json { - schema: None, - name: None, - description: None, - }), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - assert_eq!( - result.request_body.unwrap()["text"]["format"]["type"], - "json_object" - ); - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// doGenerate — xAI-specific tools -// ════════════════════════════════════════════════════════════════════════════ - -mod tools { - use super::*; - - #[tokio::test] - async fn renamed_view_tools_pass_core_validation() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", - "object": "response", - "status": "completed", - "model": "grok-4-fast-non-reasoning", - "output": [ - { - "type": "view_image_call", - "id": "image_1", - "name": "view_image", - "arguments": "{\"image_url\":\"https://example.com/image.png\"}", - "status": "completed" - }, - { - "type": "view_x_video_call", - "id": "video_1", - "name": "view_x_video", - "arguments": "{\"video_url\":\"https://x.com/i/status/1\"}", - "status": "completed" - } - ], - "usage": { "input_tokens": 10, "output_tokens": 5 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let result = generate_text( - &model, - "Inspect the media", - GenerateTextOptions { - tools: Some(vec![ - Tool::Provider(ProviderTool { - id: "xai.view_image".to_string(), - name: "inspectImage".to_string(), - args: json!({}), - }), - Tool::Provider(ProviderTool { - id: "xai.view_x_video".to_string(), - name: "inspectVideo".to_string(), - args: json!({}), - }), - ]), - ..GenerateTextOptions::default() - }, - ) - .await - .expect("renamed xAI view tools should pass Core validation"); - - assert_eq!(result.tool_calls.len(), 2); - assert_eq!(result.tool_calls[0].tool_name, "inspectImage"); - assert_eq!(result.tool_calls[1].tool_name, "inspectVideo"); - assert!( - result - .tool_calls - .iter() - .all(|call| { call.provider_executed == Some(true) && call.invalid.is_none() }) - ); - - let next_turn_prompt = aimux_core::language_model_message::convert_to_language_model_prompt( - &result.response_messages, - None, - ); - let (next_turn_input, warnings) = - aimux_providers::xai::responses::convert::convert_to_xai_responses_input( - &next_turn_prompt, - ) - .expect("Core response messages should convert for the next xAI turn"); - assert!(warnings.is_empty()); - assert!( - next_turn_input.is_empty(), - "provider-executed calls must not be replayed as client function calls" - ); - } - - /// TS: should send web_search tool with args in request - #[tokio::test] - async fn web_search_tool_request() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", "object": "response", "status": "completed", - "model": "grok-4-fast-non-reasoning", "output": [], - "usage": { "input_tokens": 10, "output_tokens": 5 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let options = CallOptions { - tools: Some(vec![Tool::Provider(ProviderTool { - id: "xai.web_search".to_string(), - name: "web_search".to_string(), - args: json!({ - "allowedDomains": ["wikipedia.org"], - "enableImageSearch": true, - "enableImageUnderstanding": true - }), - })]), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!( - body["tools"], - json!([{ - "type": "web_search", - "allowed_domains": ["wikipedia.org"], - "enable_image_search": true, - "enable_image_understanding": true - }]) - ); - } - - /// TS: should include web_search tool call - #[tokio::test] - async fn web_search_tool_call() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", "object": "response", "status": "completed", - "model": "grok-4-fast-non-reasoning", - "output": [{ - "type": "web_search_call", - "id": "ws_123", - "name": "web_search", - "arguments": "{\"query\":\"test\"}", - "call_id": "", - "status": "completed" - }], - "usage": { "input_tokens": 10, "output_tokens": 5 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let options = CallOptions { - tools: Some(vec![Tool::Provider(ProviderTool { - id: "xai.web_search".to_string(), - name: "web_search".to_string(), - args: json!({}), - })]), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - assert_eq!(result.content.len(), 1); - match &result.content[0] { - GenerateContent::ToolCall(RawToolCall { - tool_call_id, - tool_name, - input, - .. - }) => { - assert_eq!(tool_call_id, "ws_123"); - assert_eq!(tool_name, "web_search"); - assert_eq!(input, "{\"query\":\"test\"}"); - } - other => panic!("expected ToolCall, got {other:?}"), - } - } - - /// TS: should map web_search_call type to web_search tool name when name is empty - #[tokio::test] - async fn web_search_call_empty_name() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", "object": "response", "status": "completed", - "model": "grok-4-fast-non-reasoning", - "output": [{ - "type": "web_search_call", - "id": "ws_123", - "name": "", - "arguments": "{\"query\":\"test\"}", - "call_id": "", - "status": "completed" - }], - "usage": { "input_tokens": 10, "output_tokens": 5 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let options = CallOptions { - tools: Some(vec![Tool::Provider(ProviderTool { - id: "xai.web_search".to_string(), - name: "web_search".to_string(), - args: json!({}), - })]), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - match &result.content[0] { - GenerateContent::ToolCall(RawToolCall { tool_name, .. }) => { - assert_eq!(tool_name, "web_search") - } - other => panic!("expected ToolCall, got {other:?}"), - } - } - - /// TS: should map x_search_call type to x_search tool name when name is empty - #[tokio::test] - async fn x_search_call_empty_name() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", "object": "response", "status": "completed", - "model": "grok-4-fast-non-reasoning", - "output": [{ - "type": "x_search_call", - "id": "xs_123", - "name": "", - "arguments": "{\"query\":\"test\"}", - "call_id": "", - "status": "completed" - }], - "usage": { "input_tokens": 10, "output_tokens": 5 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let options = CallOptions { - tools: Some(vec![Tool::Provider(ProviderTool { - id: "xai.x_search".to_string(), - name: "x_search".to_string(), - args: json!({}), - })]), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - match &result.content[0] { - GenerateContent::ToolCall(RawToolCall { tool_name, .. }) => { - assert_eq!(tool_name, "x_search") - } - other => panic!("expected ToolCall, got {other:?}"), - } - } - - /// TS: should map code_interpreter_call type to code_execution tool name - #[tokio::test] - async fn code_interpreter_call_empty_name() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", "object": "response", "status": "completed", - "model": "grok-4-fast-non-reasoning", - "output": [{ - "type": "code_interpreter_call", - "id": "ci_123", - "name": "", - "arguments": "{}", - "call_id": "", - "status": "completed" - }], - "usage": { "input_tokens": 10, "output_tokens": 5 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let options = CallOptions { - tools: Some(vec![Tool::Provider(ProviderTool { - id: "xai.code_execution".to_string(), - name: "code_execution".to_string(), - args: json!({}), - })]), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - match &result.content[0] { - GenerateContent::ToolCall(RawToolCall { tool_name, .. }) => { - assert_eq!(tool_name, "code_execution") - } - other => panic!("expected ToolCall, got {other:?}"), - } - } - - /// TS: should map code_execution_call type to code_execution tool name - #[tokio::test] - async fn code_execution_call_empty_name() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", "object": "response", "status": "completed", - "model": "grok-4-fast-non-reasoning", - "output": [{ - "type": "code_execution_call", - "id": "ce_123", - "name": "", - "arguments": "{}", - "call_id": "", - "status": "completed" - }], - "usage": { "input_tokens": 10, "output_tokens": 5 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let options = CallOptions { - tools: Some(vec![Tool::Provider(ProviderTool { - id: "xai.code_execution".to_string(), - name: "code_execution".to_string(), - args: json!({}), - })]), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - match &result.content[0] { - GenerateContent::ToolCall(RawToolCall { tool_name, .. }) => { - assert_eq!(tool_name, "code_execution") - } - other => panic!("expected ToolCall, got {other:?}"), - } - } - - /// TS: should use custom tool name from provider tool when type matches - #[tokio::test] - async fn custom_tool_name() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", "object": "response", "status": "completed", - "model": "grok-4-fast-non-reasoning", - "output": [{ - "type": "web_search_call", - "id": "ws_123", - "name": "", - "arguments": "{}", - "call_id": "", - "status": "completed" - }], - "usage": { "input_tokens": 10, "output_tokens": 5 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let options = CallOptions { - tools: Some(vec![Tool::Provider(ProviderTool { - id: "xai.web_search".to_string(), - name: "my_custom_search".to_string(), - args: json!({}), - })]), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - match &result.content[0] { - GenerateContent::ToolCall(RawToolCall { tool_name, .. }) => { - assert_eq!(tool_name, "my_custom_search") - } - other => panic!("expected ToolCall, got {other:?}"), - } - } - - /// TS: should include file_search tool call and result - #[tokio::test] - async fn file_search_tool_call_and_result() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", "object": "response", "status": "completed", - "model": "grok-4-fast-non-reasoning", - "output": [ - { - "type": "file_search_call", - "id": "fs_123", - "status": "completed", - "queries": ["AI safety research"], - "results": [ - { "file_id": "file_abc123", "filename": "ai-safety-paper.pdf", "score": 0.95, "text": "Recent advances..." }, - { "file_id": "file_def456", "filename": "research-notes.md", "score": 0.82, "text": "Key findings..." } - ] - }, - { - "type": "message", - "id": "msg_123", - "status": "completed", - "role": "assistant", - "content": [{ "type": "output_text", "text": "Based on the documents...", "annotations": [] }] - } - ], - "usage": { "input_tokens": 100, "output_tokens": 20 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let options = CallOptions { - tools: Some(vec![Tool::Provider(ProviderTool { - id: "xai.file_search".to_string(), - name: "file_search".to_string(), - args: json!({ "vectorStoreIds": ["collection_test123"] }), - })]), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - assert_eq!(result.content.len(), 3); - // Tool call - match &result.content[0] { - GenerateContent::ToolCall(RawToolCall { - tool_call_id, - tool_name, - input, - .. - }) => { - assert_eq!(tool_call_id, "fs_123"); - assert_eq!(tool_name, "file_search"); - assert_eq!(input, ""); - } - other => panic!("expected ToolCall, got {other:?}"), - } - // Tool result - match &result.content[1] { - GenerateContent::ToolResult(ToolResult { - tool_call_id, - tool_name, - result, - .. - }) => { - assert_eq!(tool_call_id, "fs_123"); - assert_eq!(tool_name, "file_search"); - assert_eq!(result["queries"], json!(["AI safety research"])); - assert_eq!(result["results"].as_array().unwrap().len(), 2); - assert_eq!(result["results"][0]["fileId"], "file_abc123"); - } - other => panic!("expected ToolResult, got {other:?}"), - } - // Text - match &result.content[2] { - GenerateContent::Text { text, .. } => assert_eq!(text, "Based on the documents..."), - other => panic!("expected Text, got {other:?}"), - } - } - - /// TS: should handle file_search with null results - #[tokio::test] - async fn file_search_null_results() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", "object": "response", "status": "completed", - "model": "grok-4-fast-non-reasoning", - "output": [ - { - "type": "file_search_call", - "id": "fs_456", - "status": "completed", - "queries": ["nonexistent topic"], - "results": null - }, - { - "type": "message", - "id": "msg_456", - "status": "completed", - "role": "assistant", - "content": [{ "type": "output_text", "text": "No relevant documents found.", "annotations": [] }] - } - ], - "usage": { "input_tokens": 100, "output_tokens": 20 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let options = CallOptions { - tools: Some(vec![Tool::Provider(ProviderTool { - id: "xai.file_search".to_string(), - name: "file_search".to_string(), - args: json!({ "vectorStoreIds": ["collection_test123"] }), - })]), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - match &result.content[1] { - GenerateContent::ToolResult(ToolResult { result, .. }) => { - assert_eq!(result["queries"], json!(["nonexistent topic"])); - assert_eq!(result["results"], Value::Null); - } - other => panic!("expected ToolResult, got {other:?}"), - } - } - - /// TS: should include function tool calls - #[tokio::test] - async fn function_tool_call() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", "object": "response", "status": "completed", - "model": "grok-4-fast-non-reasoning", - "output": [{ - "type": "function_call", - "id": "fc_123", - "name": "weather", - "arguments": "{\"location\":\"sf\"}", - "call_id": "call_123" - }], - "usage": { "input_tokens": 10, "output_tokens": 5 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let options = CallOptions { - tools: Some(vec![ - FunctionTool::new( - "weather", - json!({ - "type": "object", - "properties": { "location": { "type": "string" } } - }), - ) - .into(), - ]), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - match &result.content[0] { - GenerateContent::ToolCall(RawToolCall { - tool_call_id, - tool_name, - input, - .. - }) => { - assert_eq!(tool_call_id, "call_123"); - assert_eq!(tool_name, "weather"); - assert_eq!(input, r#"{"location":"sf"}"#); - } - other => panic!("expected ToolCall, got {other:?}"), - } - } - - /// TS: should omit additionalProperties from serialized function tool schemas - #[tokio::test] - async fn omit_additional_properties() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", "object": "response", "status": "completed", - "model": "grok-4-fast-non-reasoning", "output": [], - "usage": { "input_tokens": 10, "output_tokens": 5 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let options = CallOptions { - tools: Some(vec![ - FunctionTool::new( - "saveContact", - json!({ - "type": "object", - "properties": { - "address": { - "type": "object", - "properties": { "city": { "type": "string" } }, - "required": ["city"], - "additionalProperties": false - } - }, - "required": ["address"], - "additionalProperties": false - }), - ) - .into(), - ]), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let tools = result.request_body.unwrap()["tools"] - .as_array() - .unwrap() - .clone(); - let params = &tools[0]["parameters"]; - // additionalProperties: false should be removed at all levels. - assert!(params.get("additionalProperties").is_none()); - assert!( - params["properties"]["address"] - .get("additionalProperties") - .is_none() - ); - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// doGenerate — citations -// ════════════════════════════════════════════════════════════════════════════ - -mod citations { - use super::*; - - /// TS: should extract citations from annotations - #[tokio::test] - async fn extract_citations() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "resp_123", "object": "response", "status": "completed", - "model": "grok-4-fast-non-reasoning", - "output": [{ - "type": "message", - "id": "msg_123", - "status": "completed", - "role": "assistant", - "content": [{ - "type": "output_text", - "text": "based on research", - "annotations": [ - { "type": "url_citation", "url": "https://example.com", "title": "example title" }, - { "type": "url_citation", "url": "https://test.com" } - ] - }] - }], - "usage": { "input_tokens": 10, "output_tokens": 5 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - - assert_eq!(result.content.len(), 3); - // Text - match &result.content[0] { - GenerateContent::Text { text, .. } => assert_eq!(text, "based on research"), - other => panic!("expected Text, got {other:?}"), - } - // Source 1 - match &result.content[1] { - GenerateContent::Source(Source { url, title, .. }) => { - assert_eq!(url.as_deref(), Some("https://example.com")); - assert_eq!(title.as_deref(), Some("example title")); - } - other => panic!("expected Source, got {other:?}"), - } - // Source 2 (title falls back to url) - match &result.content[2] { - GenerateContent::Source(Source { url, title, .. }) => { - assert_eq!(url.as_deref(), Some("https://test.com")); - assert_eq!(title.as_deref(), Some("https://test.com")); - } - other => panic!("expected Source, got {other:?}"), - } - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// doStream — text streaming -// ════════════════════════════════════════════════════════════════════════════ - -mod do_stream { - use super::*; - - /// TS: should stream text deltas - #[tokio::test] - async fn stream_text_deltas() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(sse_body(&[ - &sse_event(&json!({ - "type": "response.created", - "response": { "id": "resp_123", "object": "response", "model": "grok-4-fast-non-reasoning", "output": [] } - }).to_string()), - &sse_event(&json!({ - "type": "response.output_text.delta", - "item_id": "msg_123", - "output_index": 0, - "content_index": 0, - "delta": "Hello" - }).to_string()), - &sse_event(&json!({ - "type": "response.output_text.delta", - "item_id": "msg_123", - "output_index": 0, - "content_index": 0, - "delta": " world" - }).to_string()), - &sse_event(&json!({ - "type": "response.completed", - "response": { - "id": "resp_123", "object": "response", "model": "grok-4-fast-non-reasoning", - "status": "completed", "output": [], - "usage": { "input_tokens": 10, "output_tokens": 5 } - } - }).to_string()), - ])), - ) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let result = model - .do_stream(&default_options(test_prompt())) - .await - .unwrap(); - let parts = collect_stream(result).await; - - let text_deltas: Vec<&str> = parts - .iter() - .filter_map(|p| match p { - StreamPart::TextDelta { delta, .. } => Some(delta.as_str()), - _ => None, - }) - .collect(); - assert_eq!(text_deltas, vec!["Hello", " world"]); - - // Should have StreamStart, ResponseMetadata, TextStart, TextDelta x2, TextEnd, Finish - let has_start = parts - .iter() - .any(|p| matches!(p, StreamPart::StreamStart { .. })); - let has_finish = parts.iter().any(|p| matches!(p, StreamPart::Finish { .. })); - assert!(has_start); - assert!(has_finish); - } - - /// TS: should not emit duplicate text-delta from response.output_item.done after streaming - #[tokio::test] - async fn no_duplicate_text_delta() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(sse_body(&[ - &sse_event(&json!({ - "type": "response.created", - "response": { "id": "resp_123", "object": "response", "model": "grok-4-fast-non-reasoning", "status": "in_progress", "output": [] } - }).to_string()), - &sse_event(&json!({ - "type": "response.output_item.added", - "item": { "type": "message", "id": "msg_123", "status": "in_progress", "role": "assistant", "content": [] }, - "output_index": 0 - }).to_string()), - &sse_event(&json!({ "type": "response.output_text.delta", "item_id": "msg_123", "output_index": 0, "content_index": 0, "delta": "Hello" }).to_string()), - &sse_event(&json!({ "type": "response.output_text.delta", "item_id": "msg_123", "output_index": 0, "content_index": 0, "delta": " " }).to_string()), - &sse_event(&json!({ "type": "response.output_text.delta", "item_id": "msg_123", "output_index": 0, "content_index": 0, "delta": "world" }).to_string()), - &sse_event(&json!({ - "type": "response.output_item.done", - "item": { "type": "message", "id": "msg_123", "status": "completed", "role": "assistant", "content": [{ "type": "output_text", "text": "Hello world", "annotations": [] }] }, - "output_index": 0 - }).to_string()), - &sse_event(&json!({ - "type": "response.done", - "response": { "id": "resp_123", "object": "response", "status": "completed", "output": [], "usage": { "input_tokens": 10, "output_tokens": 5 } } - }).to_string()), - ])), - ) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let result = model - .do_stream(&default_options(test_prompt())) - .await - .unwrap(); - let parts = collect_stream(result).await; - - let text_deltas: Vec<&str> = parts - .iter() - .filter_map(|p| match p { - StreamPart::TextDelta { delta, .. } => Some(delta.as_str()), - _ => None, - }) - .collect(); - // Should only have 3 text-deltas, NOT 4 (with duplicate full text) - assert_eq!(text_deltas, vec!["Hello", " ", "world"]); - } - - /// TS: should stream reasoning text deltas (response.reasoning_text.delta) - #[tokio::test] - async fn stream_reasoning_text_deltas() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(sse_body(&[ - &sse_event(&json!({ "type": "response.created", "response": { "id": "resp_123", "object": "response", "model": "grok-code-fast-1", "output": [] } }).to_string()), - &sse_event(&json!({ "type": "response.output_item.added", "item": { "type": "reasoning", "id": "rs_456", "status": "in_progress", "summary": [] }, "output_index": 0 }).to_string()), - &sse_event(&json!({ "type": "response.reasoning_text.delta", "item_id": "rs_456", "output_index": 0, "content_index": 0, "delta": "First" }).to_string()), - &sse_event(&json!({ "type": "response.reasoning_text.delta", "item_id": "rs_456", "output_index": 0, "content_index": 0, "delta": ", analyze the question." }).to_string()), - &sse_event(&json!({ "type": "response.reasoning_text.done", "item_id": "rs_456", "output_index": 0, "content_index": 0, "text": "First, analyze the question." }).to_string()), - &sse_event(&json!({ "type": "response.output_item.done", "item": { "type": "reasoning", "id": "rs_456", "status": "completed", "summary": [{ "type": "summary_text", "text": "First, analyze the question." }] }, "output_index": 0 }).to_string()), - &sse_event(&json!({ "type": "response.output_item.added", "item": { "type": "message", "id": "msg_789", "role": "assistant", "status": "in_progress", "content": [] }, "output_index": 1 }).to_string()), - &sse_event(&json!({ "type": "response.output_text.delta", "item_id": "msg_789", "output_index": 1, "content_index": 0, "delta": "The answer." }).to_string()), - &sse_event(&json!({ "type": "response.done", "response": { "id": "resp_123", "object": "response", "model": "grok-code-fast-1", "status": "completed", "output": [], "usage": { "input_tokens": 10, "output_tokens": 20, "output_tokens_details": { "reasoning_tokens": 15 } } } }).to_string()), - ])), - ) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-code-fast-1"); - let result = model - .do_stream(&default_options(test_prompt())) - .await - .unwrap(); - let parts = collect_stream(result).await; - - // Verify reasoning start/delta/end ordering. - let start_idx = parts - .iter() - .position(|p| matches!(p, StreamPart::ReasoningStart { .. })); - let first_delta_idx = parts - .iter() - .position(|p| matches!(p, StreamPart::ReasoningDelta { .. })); - let end_idx = parts - .iter() - .position(|p| matches!(p, StreamPart::ReasoningEnd { .. })); - let text_idx = parts - .iter() - .position(|p| matches!(p, StreamPart::TextDelta { .. })); - - assert!(start_idx.is_some()); - assert!(first_delta_idx.is_some()); - assert!(end_idx.is_some()); - assert!(text_idx.is_some()); - assert!(start_idx.unwrap() < first_delta_idx.unwrap()); - assert!(first_delta_idx.unwrap() < end_idx.unwrap()); - assert!(end_idx.unwrap() < text_idx.unwrap()); - - // Verify reasoning deltas. - let reasoning_deltas: Vec<&str> = parts - .iter() - .filter_map(|p| match p { - StreamPart::ReasoningDelta { delta, .. } => Some(delta.as_str()), - _ => None, - }) - .collect(); - assert_eq!(reasoning_deltas, vec!["First", ", analyze the question."]); - } - - /// TS: should include encrypted content in reasoning-end providerMetadata - #[tokio::test] - async fn reasoning_end_encrypted_content() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(sse_body(&[ - &sse_event(&json!({ "type": "response.created", "response": { "id": "resp_123", "object": "response", "model": "grok-4-fast-non-reasoning", "output": [] } }).to_string()), - &sse_event(&json!({ "type": "response.output_item.added", "item": { "type": "reasoning", "id": "rs_456", "status": "in_progress", "summary": [] }, "output_index": 0 }).to_string()), - &sse_event(&json!({ "type": "response.reasoning_summary_part.added", "item_id": "rs_456", "output_index": 0, "summary_index": 0, "part": { "type": "summary_text", "text": "" } }).to_string()), - &sse_event(&json!({ "type": "response.reasoning_summary_text.delta", "item_id": "rs_456", "output_index": 0, "summary_index": 0, "delta": "Analyzing..." }).to_string()), - &sse_event(&json!({ "type": "response.reasoning_summary_text.done", "item_id": "rs_456", "output_index": 0, "summary_index": 0, "text": "Analyzing..." }).to_string()), - &sse_event(&json!({ "type": "response.output_item.done", "item": { "type": "reasoning", "id": "rs_456", "status": "completed", "summary": [{ "type": "summary_text", "text": "Analyzing..." }], "encrypted_content": "encrypted_data_abc123" }, "output_index": 0 }).to_string()), - &sse_event(&json!({ "type": "response.output_item.added", "item": { "type": "message", "id": "msg_789", "role": "assistant", "status": "in_progress", "content": [] }, "output_index": 1 }).to_string()), - &sse_event(&json!({ "type": "response.output_text.delta", "item_id": "msg_789", "output_index": 1, "content_index": 0, "delta": "Result." }).to_string()), - &sse_event(&json!({ "type": "response.done", "response": { "id": "resp_123", "object": "response", "model": "grok-4-fast-non-reasoning", "status": "completed", "output": [], "usage": { "input_tokens": 10, "output_tokens": 20 } } }).to_string()), - ])), - ) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let result = model - .do_stream(&default_options(test_prompt())) - .await - .unwrap(); - let parts = collect_stream(result).await; - - let reasoning_end = parts - .iter() - .find(|p| matches!(p, StreamPart::ReasoningEnd { .. })); - assert!(reasoning_end.is_some()); - if let Some(StreamPart::ReasoningEnd { - id, - provider_metadata, - }) = reasoning_end - { - assert_eq!(id, "reasoning-rs_456"); - assert_eq!( - provider_metadata, - &Some(provider_namespace( - "xai", - json!({ "itemId": "rs_456", "reasoningEncryptedContent": "encrypted_data_abc123" }) - )) - ); - } - } - - /// TS: should stream web_search tool calls - #[tokio::test] - async fn stream_web_search_tool() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(sse_body(&[ - &sse_event(&json!({ "type": "response.created", "response": { "id": "resp_123", "object": "response", "model": "grok-4-fast-non-reasoning", "status": "in_progress", "output": [] } }).to_string()), - &sse_event(&json!({ "type": "response.output_item.added", "item": { "type": "web_search_call", "id": "ws_123", "name": "web_search", "arguments": "{\"query\":\"test\"}", "call_id": "", "status": "completed" }, "output_index": 0 }).to_string()), - &sse_event(&json!({ "type": "response.done", "response": { "id": "resp_123", "object": "response", "status": "completed", "output": [], "usage": { "input_tokens": 10, "output_tokens": 5 } } }).to_string()), - ])), - ) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let options = CallOptions { - tools: Some(vec![Tool::Provider(ProviderTool { - id: "xai.web_search".to_string(), - name: "mySearch".to_string(), - args: json!({}), - })]), - ..default_options(test_prompt()) - }; - let result = model.do_stream(&options).await.unwrap(); - let parts = collect_stream(result).await; - - let tool_call = parts.iter().find(|p| matches!(p, StreamPart::ToolCall(_))); - assert!(tool_call.is_some()); - if let Some(StreamPart::ToolCall(RawToolCall { - tool_call_id, - tool_name, - input, - .. - })) = tool_call - { - assert_eq!(tool_call_id, "ws_123"); - assert_eq!(tool_name, "mySearch"); - assert_eq!(input, "{\"query\":\"test\"}"); - } - - assert!(parts.iter().any(|part| matches!( - part, - StreamPart::ToolInputStart { - tool_name, - provider_executed: Some(true), - .. - } if tool_name == "mySearch" - ))); - - let result = stream_text( - &model, - "Search", - GenerateTextOptions { - tools: options.tools.clone(), - ..GenerateTextOptions::default() - }, - ) - .await - .expect("stream_text should start") - .consume() - .await - .expect("renamed provider tool should pass Core validation"); - let call = result.tool_calls.first().expect("web search call"); - assert_eq!(call.tool_name, "mySearch"); - assert_eq!(call.provider_executed, Some(true)); - assert_eq!(call.invalid, None); - } - - /// TS: should stream function tool call arguments - #[tokio::test] - async fn stream_function_tool_arguments() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(sse_body(&[ - &sse_event(&json!({ "type": "response.created", "response": { "id": "resp_123", "object": "response", "model": "grok-4-fast-non-reasoning", "status": "in_progress", "output": [] } }).to_string()), - &sse_event(&json!({ "type": "response.output_item.added", "output_index": 0, "item": { "type": "function_call", "id": "fc_123", "call_id": "call_123", "name": "weather", "arguments": "", "status": "in_progress" } }).to_string()), - &sse_event(&json!({ "type": "response.function_call_arguments.delta", "item_id": "fc_123", "output_index": 0, "delta": "{\"location\"" }).to_string()), - &sse_event(&json!({ "type": "response.function_call_arguments.delta", "item_id": "fc_123", "output_index": 0, "delta": ":\"sf\"}" }).to_string()), - &sse_event(&json!({ "type": "response.function_call_arguments.done", "item_id": "fc_123", "output_index": 0, "arguments": "{\"location\":\"sf\"}" }).to_string()), - &sse_event(&json!({ "type": "response.output_item.done", "output_index": 0, "item": { "type": "function_call", "id": "fc_123", "call_id": "call_123", "name": "weather", "arguments": "{\"location\":\"sf\"}", "status": "completed" } }).to_string()), - &sse_event(&json!({ "type": "response.done", "response": { "id": "resp_123", "object": "response", "status": "completed", "output": [], "usage": { "input_tokens": 10, "output_tokens": 5 } } }).to_string()), - ])), - ) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let options = CallOptions { - tools: Some(vec![ - FunctionTool::new( - "weather", - json!({ - "type": "object", - "properties": { "location": { "type": "string" } } - }), - ) - .into(), - ]), - ..default_options(test_prompt()) - }; - let result = model.do_stream(&options).await.unwrap(); - let parts = collect_stream(result).await; - - // Should have tool-input-start, tool-input-delta x2, tool-input-end, tool-call - let has_start = parts.iter().any(|p| matches!(p, StreamPart::ToolInputStart { id, tool_name, .. } if id == "call_123" && tool_name == "weather")); - assert!(has_start); - - let deltas: Vec<&str> = parts - .iter() - .filter_map(|p| match p { - StreamPart::ToolInputDelta { id, delta, .. } if id == "call_123" => { - Some(delta.as_str()) - } - _ => None, - }) - .collect(); - assert_eq!(deltas, vec!["{\"location\"", ":\"sf\"}"]); - - let has_end = parts - .iter() - .any(|p| matches!(p, StreamPart::ToolInputEnd { id, .. } if id == "call_123")); - assert!(has_end); - - let tool_call = parts.iter().find(|p| matches!(p, StreamPart::ToolCall(_))); - assert!(tool_call.is_some()); - if let Some(StreamPart::ToolCall(RawToolCall { - tool_call_id, - tool_name, - input, - .. - })) = tool_call - { - assert_eq!(tool_call_id, "call_123"); - assert_eq!(tool_name, "weather"); - assert_eq!(input, r#"{"location":"sf"}"#); - } - - // Finish reason should be tool-calls. - let finish = parts - .iter() - .find(|p| matches!(p, StreamPart::Finish { .. })); - if let Some(StreamPart::Finish { finish_reason, .. }) = finish { - assert_eq!( - finish_reason.unified, - aimux_core::types::FinishReasonUnified::ToolCalls - ); - } - } - - /// TS: should expose cost_in_usd_ticks in finish providerMetadata - #[tokio::test] - async fn stream_cost_in_usd_ticks() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(sse_body(&[ - &sse_event(&json!({ "type": "response.created", "response": { "id": "resp_123", "object": "response", "model": "grok-4-fast-non-reasoning", "output": [] } }).to_string()), - &sse_event(&json!({ "type": "response.output_text.delta", "output_index": 0, "content_index": 0, "delta": "Hello" }).to_string()), - &sse_event(&json!({ - "type": "response.completed", - "response": { - "id": "resp_123", "object": "response", "model": "grok-4-fast-non-reasoning", - "status": "completed", "output": [], - "usage": { "input_tokens": 10, "output_tokens": 5, "cost_in_usd_ticks": 113500 } - } - }).to_string()), - ])), - ) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let result = model - .do_stream(&default_options(test_prompt())) - .await - .unwrap(); - let parts = collect_stream(result).await; - - let finish = parts - .iter() - .find(|p| matches!(p, StreamPart::Finish { .. })); - if let Some(StreamPart::Finish { - provider_metadata, .. - }) = finish - { - assert_eq!( - provider_metadata, - &Some(provider_namespace( - "xai", - json!({ "costInUsdTicks": 113500 }) - )) - ); - } else { - panic!("no finish part found"); - } - } - - /// TS: should stream citations as sources - #[tokio::test] - async fn stream_citations() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(sse_body(&[ - &sse_event(&json!({ "type": "response.created", "response": { "id": "resp_123", "object": "response", "model": "grok-4-fast-non-reasoning", "status": "in_progress", "output": [] } }).to_string()), - &sse_event(&json!({ - "type": "response.output_text.annotation.added", - "item_id": "msg_123", "output_index": 0, "content_index": 0, "annotation_index": 0, - "annotation": { "type": "url_citation", "url": "https://example.com", "title": "example" } - }).to_string()), - &sse_event(&json!({ "type": "response.done", "response": { "id": "resp_123", "object": "response", "status": "completed", "output": [], "usage": { "input_tokens": 10, "output_tokens": 5 } } }).to_string()), - ])), - ) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let result = model - .do_stream(&default_options(test_prompt())) - .await - .unwrap(); - let parts = collect_stream(result).await; - - let source = parts.iter().find(|p| matches!(p, StreamPart::Source(_))); - assert!(source.is_some()); - if let Some(StreamPart::Source(Source { url, title, .. })) = source { - assert_eq!(url.as_deref(), Some("https://example.com")); - assert_eq!(title.as_deref(), Some("example")); - } - } - - /// TS: should set finish reason to length for max_output_tokens (incomplete) - #[tokio::test] - async fn incomplete_max_output_tokens() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(sse_body(&[ - &sse_event(&json!({ "type": "response.created", "response": { "id": "resp_123", "object": "response", "model": "grok-4-fast-non-reasoning", "output": [] } }).to_string()), - &sse_event(&json!({ "type": "response.output_text.delta", "item_id": "msg_456", "output_index": 0, "content_index": 0, "delta": "partial output" }).to_string()), - &sse_event(&json!({ - "type": "response.incomplete", - "response": { "incomplete_details": { "reason": "max_output_tokens" }, "usage": { "input_tokens": 100, "output_tokens": 4096 } } - }).to_string()), - ])), - ) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let result = model - .do_stream(&default_options(test_prompt())) - .await - .unwrap(); - let parts = collect_stream(result).await; - - let finish = parts - .iter() - .find(|p| matches!(p, StreamPart::Finish { .. })); - if let Some(StreamPart::Finish { - finish_reason, - usage, - .. - }) = finish - { - assert_eq!( - finish_reason.unified, - aimux_core::types::FinishReasonUnified::Length - ); - assert_eq!(finish_reason.raw.as_deref(), Some("max_output_tokens")); - assert_eq!(usage.input_tokens.total, Some(100)); - assert_eq!(usage.output_tokens.total, Some(4096)); - } else { - panic!("no finish part found"); - } - } - - /// TS: should set finish reason to error on response.failed - #[tokio::test] - async fn failed_response() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(sse_body(&[ - &sse_event(&json!({ "type": "response.created", "response": { "id": "resp_123", "object": "response", "model": "grok-4-fast-non-reasoning", "output": [] } }).to_string()), - &sse_event(&json!({ - "type": "response.failed", - "response": { "error": { "code": "server_error", "message": "Internal server error" }, "usage": { "input_tokens": 50, "output_tokens": 0 } } - }).to_string()), - ])), - ) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let result = model - .do_stream(&default_options(test_prompt())) - .await - .unwrap(); - let parts = collect_stream(result).await; - - let finish = parts - .iter() - .find(|p| matches!(p, StreamPart::Finish { .. })); - if let Some(StreamPart::Finish { finish_reason, .. }) = finish { - assert_eq!( - finish_reason.unified, - aimux_core::types::FinishReasonUnified::Error - ); - assert_eq!(finish_reason.raw.as_deref(), Some("error")); - } else { - panic!("no finish part found"); - } - } - - /// TS: should handle missing usage in streaming response - #[tokio::test] - async fn missing_usage() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/responses")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(sse_body(&[ - &sse_event(&json!({ "type": "response.created", "response": { "id": "resp_123", "object": "response", "model": "grok-4-fast-non-reasoning", "status": "in_progress", "output": [] } }).to_string()), - &sse_event(&json!({ "type": "response.output_text.delta", "output_index": 0, "content_index": 0, "delta": "Hello" }).to_string()), - &sse_event(&json!({ - "type": "response.completed", - "response": { "id": "resp_123", "object": "response", "model": "grok-4-fast-non-reasoning", "status": "completed", "output": [] } - }).to_string()), - ])), - ) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("grok-4-fast-non-reasoning"); - let result = model - .do_stream(&default_options(test_prompt())) - .await - .unwrap(); - let parts = collect_stream(result).await; - - let finish = parts - .iter() - .find(|p| matches!(p, StreamPart::Finish { .. })); - if let Some(StreamPart::Finish { - finish_reason, - usage, - .. - }) = finish - { - assert_eq!( - finish_reason.unified, - aimux_core::types::FinishReasonUnified::Stop - ); - assert_eq!(finish_reason.raw.as_deref(), Some("completed")); - assert_eq!(usage.input_tokens.total, Some(0)); - assert_eq!(usage.output_tokens.total, Some(0)); - } else { - panic!("no finish part found"); - } - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// convertXaiResponsesUsage — direct function tests -// (convert-xai-responses-usage.test.ts) -// ════════════════════════════════════════════════════════════════════════════ - -mod convert_usage { - use aimux_providers::xai::responses::convert::convert_xai_responses_usage; - use aimux_providers::xai::responses::types::XaiResponsesUsage; - - fn make_usage(input: u64, output: u64) -> XaiResponsesUsage { - XaiResponsesUsage { - input_tokens: input, - output_tokens: output, - ..Default::default() - } - } - - /// TS: should convert basic usage without caching or reasoning - #[test] - fn basic_usage() { - let result = convert_xai_responses_usage(&make_usage(100, 50)); - assert_eq!(result.input_tokens.total, Some(100)); - assert_eq!(result.input_tokens.no_cache, Some(100)); - assert_eq!(result.input_tokens.cache_read, Some(0)); - assert_eq!(result.output_tokens.total, Some(50)); - assert_eq!(result.output_tokens.text, Some(50)); - assert_eq!(result.output_tokens.reasoning, Some(0)); - } - - /// TS: should convert usage with reasoning tokens - #[test] - fn usage_with_reasoning() { - let mut usage = make_usage(1941, 583); - usage.output_tokens_details = Some( - aimux_providers::xai::responses::types::XaiResponsesOutputTokensDetails { - reasoning_tokens: Some(380), - }, - ); - let result = convert_xai_responses_usage(&usage); - assert_eq!(result.output_tokens.total, Some(583)); - assert_eq!(result.output_tokens.text, Some(203)); // 583 - 380 - assert_eq!(result.output_tokens.reasoning, Some(380)); - } - - /// TS: should convert usage with cached input tokens - #[test] - fn usage_with_cached_tokens() { - let mut usage = make_usage(200, 50); - usage.input_tokens_details = Some( - aimux_providers::xai::responses::types::XaiResponsesInputTokensDetails { - cached_tokens: Some(150), - }, - ); - let result = convert_xai_responses_usage(&usage); - assert_eq!(result.input_tokens.cache_read, Some(150)); - assert_eq!(result.input_tokens.no_cache, Some(50)); // 200 - 150 - assert_eq!(result.input_tokens.total, Some(200)); - } - - /// TS: should handle cached_tokens exceeding input_tokens (non-inclusive reporting) - #[test] - fn cached_exceeds_input() { - let mut usage = make_usage(4142, 254); - usage.input_tokens_details = Some( - aimux_providers::xai::responses::types::XaiResponsesInputTokensDetails { - cached_tokens: Some(4328), - }, - ); - let result = convert_xai_responses_usage(&usage); - assert_eq!(result.input_tokens.cache_read, Some(4328)); - assert_eq!(result.input_tokens.no_cache, Some(4142)); - assert_eq!(result.input_tokens.total, Some(8470)); // 4142 + 4328 - } - - /// TS: should convert usage with both cached input and reasoning - #[test] - fn both_cached_and_reasoning() { - let mut usage = make_usage(200, 583); - usage.input_tokens_details = Some( - aimux_providers::xai::responses::types::XaiResponsesInputTokensDetails { - cached_tokens: Some(150), - }, - ); - usage.output_tokens_details = Some( - aimux_providers::xai::responses::types::XaiResponsesOutputTokensDetails { - reasoning_tokens: Some(380), - }, - ); - let result = convert_xai_responses_usage(&usage); - assert_eq!(result.input_tokens.cache_read, Some(150)); - assert_eq!(result.input_tokens.no_cache, Some(50)); - assert_eq!(result.input_tokens.total, Some(200)); - assert_eq!(result.output_tokens.reasoning, Some(380)); - assert_eq!(result.output_tokens.text, Some(203)); - assert_eq!(result.output_tokens.total, Some(583)); - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// prepareResponsesTools — direct function tests -// (xai-responses-prepare-tools.test.ts) -// ════════════════════════════════════════════════════════════════════════════ - -mod prepare_tools { - use super::*; - use aimux_providers::xai::responses::convert::prepare_responses_tools; - - /// TS: should return undefined tools when tools are undefined - #[test] - fn no_tools() { - let result = prepare_responses_tools(&None, None); - assert!(result.tools.is_none()); - assert!(result.tool_choice.is_none()); - } - - /// TS: should return undefined tools when tools are empty - #[test] - fn empty_tools() { - let result = prepare_responses_tools(&Some(vec![]), None); - assert!(result.tools.is_none()); - } - - /// TS: should prepare web_search tool with no args - #[test] - fn web_search_no_args() { - let result = prepare_responses_tools( - &Some(vec![Tool::Provider(ProviderTool { - id: "xai.web_search".to_string(), - name: "web_search".to_string(), - args: json!({}), - })]), - None, - ); - let tools = result.tools.unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(tools[0]["type"], "web_search"); - } - - /// TS: should prepare web_search tool with allowed domains - #[test] - fn web_search_with_domains() { - let result = prepare_responses_tools( - &Some(vec![Tool::Provider(ProviderTool { - id: "xai.web_search".to_string(), - name: "web_search".to_string(), - args: json!({ "allowedDomains": ["wikipedia.org", "example.com"] }), - })]), - None, - ); - let tools = result.tools.unwrap(); - assert_eq!( - tools[0]["allowed_domains"], - json!(["wikipedia.org", "example.com"]) - ); - } - - /// TS: should prepare x_search tool with no args - #[test] - fn x_search_no_args() { - let result = prepare_responses_tools( - &Some(vec![Tool::Provider(ProviderTool { - id: "xai.x_search".to_string(), - name: "x_search".to_string(), - args: json!({}), - })]), - None, - ); - let tools = result.tools.unwrap(); - assert_eq!(tools[0]["type"], "x_search"); - } - - /// TS: should prepare code_execution tool as code_interpreter - #[test] - fn code_execution() { - let result = prepare_responses_tools( - &Some(vec![Tool::Provider(ProviderTool { - id: "xai.code_execution".to_string(), - name: "code_execution".to_string(), - args: json!({}), - })]), - None, - ); - let tools = result.tools.unwrap(); - assert_eq!(tools[0]["type"], "code_interpreter"); - } - - /// TS: should prepare view_image tool - #[test] - fn view_image() { - let result = prepare_responses_tools( - &Some(vec![Tool::Provider(ProviderTool { - id: "xai.view_image".to_string(), - name: "view_image".to_string(), - args: json!({}), - })]), - None, - ); - let tools = result.tools.unwrap(); - assert_eq!(tools[0]["type"], "view_image"); - } - - /// TS: should prepare view_x_video tool - #[test] - fn view_x_video() { - let result = prepare_responses_tools( - &Some(vec![Tool::Provider(ProviderTool { - id: "xai.view_x_video".to_string(), - name: "view_x_video".to_string(), - args: json!({}), - })]), - None, - ); - let tools = result.tools.unwrap(); - assert_eq!(tools[0]["type"], "view_x_video"); - } - - /// TS: should prepare file_search tool with vector store IDs - #[test] - fn file_search() { - let result = prepare_responses_tools( - &Some(vec![Tool::Provider(ProviderTool { - id: "xai.file_search".to_string(), - name: "file_search".to_string(), - args: json!({ "vectorStoreIds": ["collection_1", "collection_2"] }), - })]), - None, - ); - let tools = result.tools.unwrap(); - assert_eq!(tools[0]["type"], "file_search"); - assert_eq!( - tools[0]["vector_store_ids"], - json!(["collection_1", "collection_2"]) - ); - } - - /// TS: should prepare file_search tool with max num results - #[test] - fn file_search_max_results() { - let result = prepare_responses_tools( - &Some(vec![Tool::Provider(ProviderTool { - id: "xai.file_search".to_string(), - name: "file_search".to_string(), - args: json!({ "vectorStoreIds": ["collection_1"], "maxNumResults": 10 }), - })]), - None, - ); - let tools = result.tools.unwrap(); - assert_eq!(tools[0]["max_num_results"], 10); - } - - /// TS: should prepare mcp tool with required args only - #[test] - fn mcp_required_args() { - let result = prepare_responses_tools( - &Some(vec![Tool::Provider(ProviderTool { - id: "xai.mcp".to_string(), - name: "mcp".to_string(), - args: json!({ "serverUrl": "https://example.com/mcp", "serverLabel": "test-server" }), - })]), - None, - ); - let tools = result.tools.unwrap(); - assert_eq!(tools[0]["type"], "mcp"); - assert_eq!(tools[0]["server_url"], "https://example.com/mcp"); - assert_eq!(tools[0]["server_label"], "test-server"); - } - - /// TS: should prepare mcp tool with all optional args - #[test] - fn mcp_all_args() { - let result = prepare_responses_tools( - &Some(vec![Tool::Provider(ProviderTool { - id: "xai.mcp".to_string(), - name: "mcp".to_string(), - args: json!({ - "serverUrl": "https://example.com/mcp", - "serverLabel": "test-server", - "serverDescription": "A test MCP server", - "allowedTools": ["tool1", "tool2"], - "headers": { "X-Custom": "value" }, - "authorization": "Bearer token123" - }), - })]), - None, - ); - let tools = result.tools.unwrap(); - assert_eq!(tools[0]["server_description"], "A test MCP server"); - assert_eq!(tools[0]["allowed_tools"], json!(["tool1", "tool2"])); - assert_eq!(tools[0]["headers"]["X-Custom"], "value"); - assert_eq!(tools[0]["authorization"], "Bearer token123"); - } - - /// TS: should prepare function tools - #[test] - fn function_tools() { - let result = prepare_responses_tools( - &Some(vec![ - FunctionTool::new( - "weather", - json!({ - "type": "object", - "properties": { "location": { "type": "string" } }, - "required": ["location"] - }), - ) - .with_description("get weather information") - .into(), - ]), - None, - ); - let tools = result.tools.unwrap(); - assert_eq!(tools[0]["type"], "function"); - assert_eq!(tools[0]["name"], "weather"); - assert_eq!(tools[0]["description"], "get weather information"); - assert_eq!( - tools[0]["parameters"]["properties"]["location"]["type"], - "string" - ); - } - - /// TS: should pass through strict mode when strict is true - #[test] - fn strict_true() { - let result = prepare_responses_tools( - &Some(vec![ - FunctionTool::new( - "testFunction", - json!({ - "type": "object", "properties": {} - }), - ) - .with_description("A test function") - .with_strict(true) - .into(), - ]), - None, - ); - let tools = result.tools.unwrap(); - assert_eq!(tools[0]["strict"], true); - } - - /// TS: should not include strict when strict is undefined - #[test] - fn strict_undefined() { - let result = prepare_responses_tools( - &Some(vec![ - FunctionTool::new( - "testFunction", - json!({ - "type": "object", "properties": {} - }), - ) - .with_description("A test function") - .into(), - ]), - None, - ); - let tools = result.tools.unwrap(); - assert!(tools[0].get("strict").is_none()); - } - - /// TS: should handle tool choice auto - #[test] - fn tool_choice_auto() { - let result = prepare_responses_tools( - &Some(vec![Tool::Provider(ProviderTool { - id: "xai.web_search".to_string(), - name: "web_search".to_string(), - args: json!({}), - })]), - Some(&ToolChoice::Auto), - ); - assert_eq!(result.tool_choice, Some(json!("auto"))); - } - - /// TS: should handle tool choice required - #[test] - fn tool_choice_required() { - let result = prepare_responses_tools( - &Some(vec![Tool::Provider(ProviderTool { - id: "xai.web_search".to_string(), - name: "web_search".to_string(), - args: json!({}), - })]), - Some(&ToolChoice::Required), - ); - assert_eq!(result.tool_choice, Some(json!("required"))); - } - - /// TS: should handle tool choice none - #[test] - fn tool_choice_none() { - let result = prepare_responses_tools( - &Some(vec![Tool::Provider(ProviderTool { - id: "xai.web_search".to_string(), - name: "web_search".to_string(), - args: json!({}), - })]), - Some(&ToolChoice::None), - ); - assert_eq!(result.tool_choice, Some(json!("none"))); - } - - /// TS: should handle specific tool choice (function) - #[test] - fn tool_choice_specific_function() { - let result = prepare_responses_tools( - &Some(vec![ - FunctionTool::new( - "weather", - json!({ - "type": "object", "properties": {} - }), - ) - .into(), - ]), - Some(&ToolChoice::Tool { - tool_name: "weather".to_string(), - }), - ); - assert_eq!( - result.tool_choice, - Some(json!({ "type": "function", "name": "weather" })) - ); - } - - /// TS: should warn when trying to force server-side tool via toolChoice - #[test] - fn tool_choice_server_side_warning() { - let result = prepare_responses_tools( - &Some(vec![Tool::Provider(ProviderTool { - id: "xai.web_search".to_string(), - name: "web_search".to_string(), - args: json!({}), - })]), - Some(&ToolChoice::Tool { - tool_name: "web_search".to_string(), - }), - ); - assert!(result.tool_choice.is_none()); - assert!(result.tool_warnings.iter().any(|w| match w { - aimux_core::types::Warning::Unsupported { feature, .. } => { - feature == "toolChoice for server-side tool \"web_search\"" - } - _ => false, - })); - } - - /// TS: should warn about unsupported provider-defined tools - #[test] - fn unsupported_provider_tool() { - let result = prepare_responses_tools( - &Some(vec![Tool::Provider(ProviderTool { - id: "unsupported.tool".to_string(), - name: "unsupported".to_string(), - args: json!({}), - })]), - None, - ); - assert!(result.tools.is_none()); - assert!(result.tool_warnings.iter().any(|w| match w { - aimux_core::types::Warning::Unsupported { feature, .. } => { - feature == "provider-defined tool unsupported" - } - _ => false, - })); - } - - /// TS: should handle multiple tools including provider-defined and functions - #[test] - fn multiple_tools() { - let result = prepare_responses_tools( - &Some(vec![ - FunctionTool::new("calculator", json!({ "type": "object", "properties": {} })) - .into(), - Tool::Provider(ProviderTool { - id: "xai.web_search".to_string(), - name: "web_search".to_string(), - args: json!({}), - }), - Tool::Provider(ProviderTool { - id: "xai.x_search".to_string(), - name: "x_search".to_string(), - args: json!({}), - }), - ]), - None, - ); - let tools = result.tools.unwrap(); - assert_eq!(tools.len(), 3); - assert_eq!(tools[0]["type"], "function"); - assert_eq!(tools[1]["type"], "web_search"); - assert_eq!(tools[2]["type"], "x_search"); - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// convertToXaiResponsesInput — direct function tests -// (convert-to-xai-responses-input.test.ts) -// ════════════════════════════════════════════════════════════════════════════ - -mod convert_input { - use super::*; - use aimux_providers::xai::responses::convert::convert_to_xai_responses_input; - - /// TS: should convert system messages - #[test] - fn system_message() { - let prompt = vec![LanguageModelPromptMessage { - role: Role::System, - content: vec![ContentPart::text("you are a helpful assistant")], - ..Default::default() - }]; - let (input, warnings) = convert_to_xai_responses_input(&prompt).unwrap(); - assert_eq!(warnings.len(), 0); - assert_eq!( - input, - vec![json!({ "role": "system", "content": "you are a helpful assistant" })] - ); - } - - /// TS: should convert single text part - #[test] - fn user_text() { - let (input, warnings) = convert_to_xai_responses_input(&test_prompt()).unwrap(); - assert_eq!(warnings.len(), 0); - assert_eq!( - input, - vec![json!({ - "role": "user", - "content": [{ "type": "input_text", "text": "hello" }] - })] - ); - } - - /// TS: should convert image file parts with URL - #[test] - fn user_image_url() { - let prompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ - ContentPart::text("what is in this image"), - ContentPart::file_url("https://example.com/image.jpg", "image/jpeg"), - ], - ..Default::default() - }]; - let (input, _) = convert_to_xai_responses_input(&prompt).unwrap(); - assert_eq!( - input[0]["content"][1], - json!({ "type": "input_image", "image_url": "https://example.com/image.jpg" }) - ); - } - - /// TS: should convert non-image file parts with URL to input_file with file_url - #[test] - fn user_pdf_url() { - let prompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ - ContentPart::text("summarize this PDF"), - ContentPart::file_url("https://example.com/document.pdf", "application/pdf"), - ], - ..Default::default() - }]; - let (input, _) = convert_to_xai_responses_input(&prompt).unwrap(); - assert_eq!( - input[0]["content"][1], - json!({ "type": "input_file", "file_url": "https://example.com/document.pdf" }) - ); - } - - /// TS: should convert assistant text - #[test] - fn assistant_text() { - let prompt = vec![ - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("hi")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::text("hello back")], - ..Default::default() - }, - ]; - let (input, _) = convert_to_xai_responses_input(&prompt).unwrap(); - assert_eq!( - input[1], - json!({ "role": "assistant", "content": "hello back" }) - ); - } - - /// AI SDK: provider-executed calls are not replayed as client function calls. - #[test] - fn skips_provider_executed_tool_call_without_provider_options() { - let prompt = vec![LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::ToolCall { - tool_call_id: "server_call_123".to_string(), - tool_name: "web_search".to_string(), - input: json!({ "query": "weather" }), - provider_executed: Some(true), - thought_signature: None, - provider_options: None, - }], - ..Default::default() - }]; - - let (input, warnings) = convert_to_xai_responses_input(&prompt).unwrap(); - assert!(warnings.is_empty()); - assert!(input.is_empty()); - } - - /// An explicit false value must override stale legacy provider metadata. - #[test] - fn explicit_client_execution_overrides_legacy_provider_metadata() { - let prompt = vec![LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::ToolCall { - tool_call_id: "call_123".to_string(), - tool_name: "weather".to_string(), - input: json!({ "city": "Singapore" }), - provider_executed: Some(false), - thought_signature: None, - provider_options: Some(provider_namespace( - "xai", - json!({ "providerExecuted": true }), - )), - }], - ..Default::default() - }]; - - let (input, warnings) = convert_to_xai_responses_input(&prompt).unwrap(); - assert!(warnings.is_empty()); - assert_eq!(input.len(), 1); - assert_eq!(input[0]["type"], "function_call"); - assert_eq!(input[0]["call_id"], "call_123"); - } - - /// TS: should convert tool results - #[test] - fn tool_result() { - let prompt = vec![ - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("weather?")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::tool_call("call_123", "weather", json!({}))], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::Tool, - content: vec![ContentPart::tool_result("call_123", json!("sunny"))], - ..Default::default() - }, - ]; - let (input, _) = convert_to_xai_responses_input(&prompt).unwrap(); - // Should have: user message, function_call, function_call_output - assert_eq!(input.len(), 3); - assert_eq!(input[1]["type"], "function_call"); - assert_eq!(input[1]["call_id"], "call_123"); - assert_eq!(input[2]["type"], "function_call_output"); - assert_eq!(input[2]["call_id"], "call_123"); - assert_eq!(input[2]["output"], "sunny"); - } -} diff --git a/aimux-providers/tests/xai_test.rs b/aimux-providers/tests/xai_test.rs deleted file mode 100644 index 27acc8d5d..000000000 --- a/aimux-providers/tests/xai_test.rs +++ /dev/null @@ -1,2817 +0,0 @@ -//! Rust translations of the AI SDK xAI provider chat-level tests. -//! -//! Sources (TS → Rust): -//! - `packages/xai/src/supports-reasoning-effort.test.ts` → `supports_reasoning_effort` mod -//! - `packages/xai/src/convert-xai-chat-usage.test.ts` → `convert_usage` mod -//! - `packages/xai/src/xai-prepare-tools.test.ts` → `prepare_tools` mod -//! - `packages/xai/src/convert-to-xai-chat-messages.test.ts` → `convert_messages` mod -//! - `packages/xai/src/xai-error.test.ts` → `error_handling` mod -//! - `packages/xai/src/xai-chat-language-model.test.ts` → `do_generate` / `do_stream` / `reasoning` mods -//! - `packages/xai/src/xai-provider.test.ts` → `provider` mod -//! -//! Each test uses `wiremock` to spin up a mock HTTP server, configures a JSON -//! or SSE response, creates an `XaiModel` via `XAIProvider`, calls -//! `do_generate` / `do_stream`, and asserts on the result. - -use aimux_core::shared::{SharedProviderOptions, provider_namespace}; -use aimux_core::tool::RawToolCall; -use std::collections::HashMap; - -use futures::StreamExt; -use serde_json::{Value, json}; -use wiremock::matchers::{method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -use aimux_core::content::ContentPart; -use aimux_core::error::AiMuxError; -use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::LanguageModelPromptMessage; -use aimux_core::message::Role; -use aimux_core::options::{CallOptions, ResponseFormat, ToolChoice}; -use aimux_core::result::{GenerateContent, ReasoningOutput, Source}; -use aimux_core::stream_part::StreamPart; -use aimux_core::tool::{FunctionTool, ProviderTool, Tool}; -use aimux_core::types::{FinishReasonUnified, ReasoningEffort}; - -use aimux_providers::xai::convert::supports_reasoning_effort; -use aimux_providers::{XAIConfig, XAIProvider}; - -// ── shared helpers ─────────────────────────────────────────────────────────── - -/// The TS `TEST_PROMPT`: a single user text message "Hello". -fn test_prompt() -> Vec { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] -} - -/// `CallOptions` with only `prompt` set. -fn default_options(prompt: Vec) -> CallOptions { - CallOptions::new(prompt) -} - -/// A standard non-streaming chat-completion JSON body returning "Hello". -fn text_completion_body() -> Value { - json!({ - "id": "chatcmpl-test", - "object": "chat.completion", - "created": 1711115037, - "model": "test-model", - "choices": [{ - "index": 0, - "message": { "role": "assistant", "content": "Hello, World!" }, - "finish_reason": "stop" - }], - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 } - }) -} - -/// The xai-text fixture response (with reasoning_content and detailed usage). -fn xai_text_fixture() -> Value { - json!({ - "id": "2af5c888-e886-6dcb-7844-95f8fe010b00", - "object": "chat.completion", - "created": 1770774046, - "model": "grok-3-mini", - "choices": [{ - "index": 0, - "message": { - "role": "assistant", - "content": "Hello", - "reasoning_content": "First, the user said: \"Say a single word.\" That's straightforward. They want me to respond with just one word.\n\nResponse: I'll go with \"Hello\" as it's a common greeting and keeps it simple.", - "refusal": null - }, - "finish_reason": "stop" - }], - "usage": { - "prompt_tokens": 12, - "completion_tokens": 1, - "total_tokens": 241, - "prompt_tokens_details": { - "text_tokens": 12, - "audio_tokens": 0, - "image_tokens": 0, - "cached_tokens": 2 - }, - "completion_tokens_details": { - "reasoning_tokens": 228, - "audio_tokens": 0, - "accepted_prediction_tokens": 0, - "rejected_prediction_tokens": 0 - } - }, - "system_fingerprint": "fp_2a885414fb" - }) -} - -/// Build a single SSE `data: \n\n` event string. -fn sse_event(json_str: &str) -> String { - format!("data: {json_str}\n\n") -} - -/// Concatenate SSE events and append the `[DONE]` sentinel. -fn sse_body(events: &[&str]) -> String { - let mut body = String::new(); - for event in events { - body.push_str(event); - } - body.push_str("data: [DONE]\n\n"); - body -} - -/// Collect every `StreamPart` from a `StreamResult` into a `Vec`. -async fn collect_stream(result: aimux_core::result::StreamResult) -> Vec { - let mut parts = Vec::new(); - let mut stream = result.stream; - while let Some(part) = stream.next().await { - match part { - Ok(p) => parts.push(p), - Err(e) => panic!("stream error: {e:?}"), - } - } - parts -} - -/// Extract text deltas from a list of stream parts. -fn text_deltas(parts: &[StreamPart]) -> Vec { - parts - .iter() - .filter_map(|p| match p { - StreamPart::TextDelta { delta, .. } => Some(delta.clone()), - _ => None, - }) - .collect() -} - -/// Extract reasoning deltas from a list of stream parts. -fn reasoning_deltas(parts: &[StreamPart]) -> Vec { - parts - .iter() - .filter_map(|p| match p { - StreamPart::ReasoningDelta { delta, .. } => Some(delta.clone()), - _ => None, - }) - .collect() -} - -/// Build a provider pointed at the mock server. -fn make_provider(server: &MockServer) -> XAIProvider { - let config = XAIConfig::new("test-api-key").with_base_url(server.uri()); - XAIProvider::new(config) -} - -/// Provider options with a single xai key. -fn xai_options(key: &str, value: Value) -> Option { - Some(provider_namespace("xai", json!({ key: value }))) -} - -/// Build provider options from a JSON object. -fn xai_provider_options(opts: Value) -> Option { - Some(provider_namespace("xai", opts)) -} - -// ════════════════════════════════════════════════════════════════════════════ -// supportsReasoningEffort — direct function tests -// (supports-reasoning-effort.test.ts) -// ════════════════════════════════════════════════════════════════════════════ - -mod supports_reasoning_effort_tests { - use super::*; - - /// TS: should return true for grok-4.3 - #[test] - fn true_for_grok_4_3() { - assert!(supports_reasoning_effort("grok-4.3")); - } - - /// TS: should return true for grok-latest - #[test] - fn true_for_grok_latest() { - assert!(supports_reasoning_effort("grok-latest")); - } - - /// TS: should return true for grok-4.20-multi-agent - #[test] - fn true_for_grok_4_20_multi_agent() { - assert!(supports_reasoning_effort("grok-4.20-multi-agent")); - } - - /// TS: should return true for grok-4.20-multi-agent-0309 - #[test] - fn true_for_grok_4_20_multi_agent_0309() { - assert!(supports_reasoning_effort("grok-4.20-multi-agent-0309")); - } - - /// TS: should return true for grok-3-mini - #[test] - fn true_for_grok_3_mini() { - assert!(supports_reasoning_effort("grok-3-mini")); - } - - /// TS: should return false for grok-4.20-reasoning - #[test] - fn false_for_grok_4_20_reasoning() { - assert!(!supports_reasoning_effort("grok-4.20-reasoning")); - } - - /// TS: should return false for grok-4.20-non-reasoning - #[test] - fn false_for_grok_4_20_non_reasoning() { - assert!(!supports_reasoning_effort("grok-4.20-non-reasoning")); - } - - /// TS: should return false for grok-4.20-0309-reasoning - #[test] - fn false_for_grok_4_20_0309_reasoning() { - assert!(!supports_reasoning_effort("grok-4.20-0309-reasoning")); - } - - /// TS: should return false for grok-4.20-0309-non-reasoning - #[test] - fn false_for_grok_4_20_0309_non_reasoning() { - assert!(!supports_reasoning_effort("grok-4.20-0309-non-reasoning")); - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// convertXaiChatUsage — tested through do_generate -// (convert-xai-chat-usage.test.ts) -// ════════════════════════════════════════════════════════════════════════════ - -mod convert_usage { - use super::*; - - /// TS: should convert basic usage without reasoning tokens - #[tokio::test] - async fn basic_usage_without_reasoning() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "test", - "object": "chat.completion", - "created": 1699472111, - "model": "grok-3", - "choices": [{ - "index": 0, - "message": { "role": "assistant", "content": "Hi" }, - "finish_reason": "stop" - }], - "usage": { - "prompt_tokens": 12, - "completion_tokens": 1, - "total_tokens": 13 - } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - - assert_eq!(result.usage.input_tokens.total, Some(12)); - assert_eq!(result.usage.input_tokens.no_cache, Some(12)); - assert_eq!(result.usage.input_tokens.cache_read, Some(0)); - assert_eq!(result.usage.output_tokens.total, Some(1)); - assert_eq!(result.usage.output_tokens.text, Some(1)); - assert_eq!(result.usage.output_tokens.reasoning, Some(0)); - } - - /// TS: should convert usage with reasoning tokens (xai reports separately) - #[tokio::test] - async fn usage_with_reasoning_tokens() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "test", - "object": "chat.completion", - "created": 1699472111, - "model": "grok-3", - "choices": [{ - "index": 0, - "message": { "role": "assistant", "content": "Hi" }, - "finish_reason": "stop" - }], - "usage": { - "prompt_tokens": 12, - "completion_tokens": 1, - "total_tokens": 241, - "completion_tokens_details": { - "reasoning_tokens": 228 - } - } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - - // xAI reports reasoning separately: total = completion + reasoning - assert_eq!(result.usage.output_tokens.total, Some(229)); - assert_eq!(result.usage.output_tokens.text, Some(1)); - assert_eq!(result.usage.output_tokens.reasoning, Some(228)); - assert_eq!(result.usage.input_tokens.total, Some(12)); - } - - /// TS: should convert usage with cached input tokens - #[tokio::test] - async fn usage_with_cached_tokens() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "test", - "object": "chat.completion", - "created": 1699472111, - "model": "grok-3", - "choices": [{ - "index": 0, - "message": { "role": "assistant", "content": "Hi" }, - "finish_reason": "stop" - }], - "usage": { - "prompt_tokens": 12, - "completion_tokens": 2, - "total_tokens": 438, - "prompt_tokens_details": { - "text_tokens": 12, - "audio_tokens": 0, - "image_tokens": 0, - "cached_tokens": 3 - }, - "completion_tokens_details": { - "reasoning_tokens": 424 - } - } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - - assert_eq!(result.usage.input_tokens.cache_read, Some(3)); - assert_eq!(result.usage.input_tokens.no_cache, Some(9)); // 12 - 3 - assert_eq!(result.usage.input_tokens.total, Some(12)); - assert_eq!(result.usage.output_tokens.reasoning, Some(424)); - assert_eq!(result.usage.output_tokens.text, Some(2)); - assert_eq!(result.usage.output_tokens.total, Some(426)); // 2 + 424 - } - - /// TS: should handle cached_tokens exceeding prompt_tokens (non-inclusive) - #[tokio::test] - async fn cached_tokens_exceeding_prompt_tokens() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "test", - "object": "chat.completion", - "created": 1699472111, - "model": "grok-3", - "choices": [{ - "index": 0, - "message": { "role": "assistant", "content": "Hi" }, - "finish_reason": "stop" - }], - "usage": { - "prompt_tokens": 4142, - "completion_tokens": 254, - "total_tokens": 8724, - "prompt_tokens_details": { - "cached_tokens": 4328 - } - } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - - // Non-inclusive: total = prompt + cached, noCache = prompt - assert_eq!(result.usage.input_tokens.cache_read, Some(4328)); - assert_eq!(result.usage.input_tokens.no_cache, Some(4142)); - assert_eq!(result.usage.input_tokens.total, Some(8470)); // 4142 + 4328 - } - - /// TS: should handle null token details - #[tokio::test] - async fn null_token_details() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "test", - "object": "chat.completion", - "created": 1699472111, - "model": "grok-3", - "choices": [{ - "index": 0, - "message": { "role": "assistant", "content": "Hi" }, - "finish_reason": "stop" - }], - "usage": { - "prompt_tokens": 100, - "completion_tokens": 200, - "total_tokens": 300, - "prompt_tokens_details": null, - "completion_tokens_details": null - } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - - assert_eq!(result.usage.input_tokens.cache_read, Some(0)); - assert_eq!(result.usage.input_tokens.no_cache, Some(100)); - assert_eq!(result.usage.output_tokens.reasoning, Some(0)); - assert_eq!(result.usage.output_tokens.text, Some(200)); - assert_eq!(result.usage.output_tokens.total, Some(200)); - } - - /// TS: should handle zero reasoning tokens - #[tokio::test] - async fn zero_reasoning_tokens() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "test", - "object": "chat.completion", - "created": 1699472111, - "model": "grok-3", - "choices": [{ - "index": 0, - "message": { "role": "assistant", "content": "Hi" }, - "finish_reason": "stop" - }], - "usage": { - "prompt_tokens": 50, - "completion_tokens": 100, - "total_tokens": 150, - "completion_tokens_details": { - "reasoning_tokens": 0 - } - } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - - assert_eq!(result.usage.output_tokens.reasoning, Some(0)); - assert_eq!(result.usage.output_tokens.text, Some(100)); - assert_eq!(result.usage.output_tokens.total, Some(100)); - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// prepareTools — tested through do_generate request body and warnings -// (xai-prepare-tools.test.ts) -// ════════════════════════════════════════════════════════════════════════════ - -mod prepare_tools { - use super::*; - - /// TS: should return undefined tools and toolChoice when tools are undefined - #[tokio::test] - async fn no_tools() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - - let body = result.request_body.unwrap(); - assert!(body.get("tools").is_none()); - assert!(body.get("tool_choice").is_none()); - } - - /// TS: should return undefined tools and toolChoice when tools are empty - #[tokio::test] - async fn empty_tools() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let options = CallOptions { - tools: Some(vec![]), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert!(body.get("tools").is_none()); - assert!(body.get("tool_choice").is_none()); - } - - /// TS: should correctly prepare function tools - #[tokio::test] - async fn function_tools() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let options = CallOptions { - tools: Some(vec![Tool::from(FunctionTool { - name: "testFunction".to_string(), - description: Some("A test function".to_string()), - input_schema: json!({"type": "object", "properties": {}}), - strict: None, - provider_options: None, - input_examples: None, - })]), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - let tools = body["tools"].as_array().expect("tools should be array"); - assert_eq!(tools.len(), 1); - assert_eq!(tools[0]["type"], "function"); - assert_eq!(tools[0]["function"]["name"], "testFunction"); - assert_eq!(tools[0]["function"]["description"], "A test function"); - assert_eq!(tools[0]["function"]["parameters"]["type"], "object"); - assert!(tools[0]["function"].get("strict").is_none()); - } - - /// TS: should add warnings for provider-defined tools - #[tokio::test] - async fn provider_defined_tools_warning() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let options = CallOptions { - tools: Some(vec![Tool::Provider(ProviderTool { - id: "xai.unsupported_tool".to_string(), - name: "unsupported_tool".to_string(), - args: json!({}), - })]), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - // No tools in body (all were provider-defined) - let tools = body.get("tools"); - assert!( - tools.is_none() - || tools - .unwrap() - .as_array() - .map(std::vec::Vec::is_empty) - .unwrap_or(true) - ); - - // Should have a warning - let has_warning = result.warnings.iter().any(|w| { - matches!(w, aimux_core::types::Warning::Unsupported { feature, .. } - if feature == "provider-defined tool unsupported_tool") - }); - assert!( - has_warning, - "should have unsupported warning for provider-defined tool" - ); - } - - /// TS: should handle multiple tools including provider-defined and function tools - #[tokio::test] - async fn mixed_tools() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let options = CallOptions { - tools: Some(vec![ - Tool::from(FunctionTool { - name: "calculator".to_string(), - description: Some("calculate numbers".to_string()), - input_schema: json!({"type": "object", "properties": {}}), - strict: None, - provider_options: None, - input_examples: None, - }), - Tool::Provider(ProviderTool { - id: "xai.some_tool".to_string(), - name: "some_tool".to_string(), - args: json!({}), - }), - Tool::from(FunctionTool { - name: "weather".to_string(), - description: Some("get weather".to_string()), - input_schema: json!({"type": "object", "properties": {}}), - strict: None, - provider_options: None, - input_examples: None, - }), - ]), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - let tools = body["tools"].as_array().expect("tools should be array"); - assert_eq!(tools.len(), 2); - assert_eq!(tools[0]["function"]["name"], "calculator"); - assert_eq!(tools[1]["function"]["name"], "weather"); - - let has_warning = result.warnings.iter().any(|w| { - matches!(w, aimux_core::types::Warning::Unsupported { feature, .. } - if feature == "provider-defined tool some_tool") - }); - assert!(has_warning); - } - - /// TS: should handle tool choice "auto" - #[tokio::test] - async fn tool_choice_auto() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let options = CallOptions { - tools: Some(vec![Tool::from(FunctionTool::new( - "testFunction", - json!({}), - ))]), - tool_choice: ToolChoice::Auto, - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!(body["tool_choice"], "auto"); - } - - /// TS: should handle tool choice "none" - #[tokio::test] - async fn tool_choice_none() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let options = CallOptions { - tools: Some(vec![Tool::from(FunctionTool::new( - "testFunction", - json!({}), - ))]), - tool_choice: ToolChoice::None, - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!(body["tool_choice"], "none"); - } - - /// TS: should handle tool choice "required" - #[tokio::test] - async fn tool_choice_required() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let options = CallOptions { - tools: Some(vec![Tool::from(FunctionTool::new( - "testFunction", - json!({}), - ))]), - tool_choice: ToolChoice::Required, - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!(body["tool_choice"], "required"); - } - - /// TS: should handle tool choice "tool" - #[tokio::test] - async fn tool_choice_tool() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let options = CallOptions { - tools: Some(vec![Tool::from(FunctionTool::new( - "testFunction", - json!({}), - ))]), - tool_choice: ToolChoice::Tool { - tool_name: "testFunction".to_string(), - }, - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!(body["tool_choice"]["type"], "function"); - assert_eq!(body["tool_choice"]["function"]["name"], "testFunction"); - } - - /// TS: should pass through strict mode when strict is true - #[tokio::test] - async fn strict_true() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let options = CallOptions { - tools: Some(vec![Tool::from(FunctionTool { - name: "testFunction".to_string(), - description: Some("A test function".to_string()), - input_schema: json!({"type": "object", "properties": {}}), - strict: Some(true), - provider_options: None, - input_examples: None, - })]), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!(body["tools"][0]["function"]["strict"], true); - } - - /// TS: should pass through strict mode when strict is false - #[tokio::test] - async fn strict_false() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let options = CallOptions { - tools: Some(vec![Tool::from(FunctionTool { - name: "testFunction".to_string(), - description: Some("A test function".to_string()), - input_schema: json!({"type": "object", "properties": {}}), - strict: Some(false), - provider_options: None, - input_examples: None, - })]), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!(body["tools"][0]["function"]["strict"], false); - } - - /// TS: should not include strict when strict is undefined - #[tokio::test] - async fn strict_undefined() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let options = CallOptions { - tools: Some(vec![Tool::from(FunctionTool { - name: "testFunction".to_string(), - description: Some("A test function".to_string()), - input_schema: json!({"type": "object", "properties": {}}), - strict: None, - provider_options: None, - input_examples: None, - })]), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert!(body["tools"][0]["function"].get("strict").is_none()); - } - - /// TS: should remove additionalProperties: false from tool schemas - #[tokio::test] - async fn removes_additional_properties_false() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let options = CallOptions { - tools: Some(vec![Tool::from(FunctionTool { - name: "test-tool".to_string(), - description: None, - input_schema: json!({ - "type": "object", - "properties": { "value": { "type": "string" } }, - "required": ["value"], - "additionalProperties": false, - "$schema": "http://json-schema.org/draft-07/schema#" - }), - strict: None, - provider_options: None, - input_examples: None, - })]), - tool_choice: ToolChoice::Tool { - tool_name: "test-tool".to_string(), - }, - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - let params = &body["tools"][0]["function"]["parameters"]; - assert!(params.get("additionalProperties").is_none()); - assert_eq!(params["type"], "object"); - assert_eq!(params["properties"]["value"]["type"], "string"); - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// convertToXaiChatMessages — tested through do_generate request body -// (convert-to-xai-chat-messages.test.ts) -// ════════════════════════════════════════════════════════════════════════════ - -mod convert_messages { - use super::*; - - /// TS: should convert simple text messages - #[tokio::test] - async fn simple_text() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!(body["messages"][0]["role"], "user"); - assert_eq!(body["messages"][0]["content"], "Hello"); - } - - /// TS: should convert system messages - #[tokio::test] - async fn system_message() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let prompt = vec![ - LanguageModelPromptMessage { - role: Role::System, - content: vec![ContentPart::text("You are a helpful assistant.")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }, - ]; - let result = model.do_generate(&default_options(prompt)).await.unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!(body["messages"][0]["role"], "system"); - assert_eq!( - body["messages"][0]["content"], - "You are a helpful assistant." - ); - } - - /// TS: should convert assistant messages - #[tokio::test] - async fn assistant_message() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let prompt = vec![ - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::text("Hello there!")], - ..Default::default() - }, - ]; - let result = model.do_generate(&default_options(prompt)).await.unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!(body["messages"][1]["role"], "assistant"); - assert_eq!(body["messages"][1]["content"], "Hello there!"); - } - - /// TS: should convert messages with image parts (data) - #[tokio::test] - async fn image_data_part() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let prompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ - ContentPart::text("What is in this image?"), - ContentPart::file(vec![0, 1, 2, 3], "image/png"), - ], - ..Default::default() - }]; - let result = model.do_generate(&default_options(prompt)).await.unwrap(); - - let body = result.request_body.unwrap(); - let content = body["messages"][0]["content"].as_array().unwrap(); - assert_eq!(content[0]["type"], "text"); - assert_eq!(content[0]["text"], "What is in this image?"); - assert_eq!(content[1]["type"], "image_url"); - assert_eq!( - content[1]["image_url"]["url"], - "data:image/png;base64,AAECAw==" - ); - } - - /// TS: should convert image URLs - #[tokio::test] - async fn image_url_part() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let prompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::file_url( - "https://example.com/image.jpg", - "image/jpeg", - )], - ..Default::default() - }]; - let result = model.do_generate(&default_options(prompt)).await.unwrap(); - - let body = result.request_body.unwrap(); - let content = body["messages"][0]["content"].as_array().unwrap(); - assert_eq!(content[0]["type"], "image_url"); - assert_eq!( - content[0]["image_url"]["url"], - "https://example.com/image.jpg" - ); - } - - /// TS: should convert image file parts with provider reference (xai key) - #[tokio::test] - async fn image_reference() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let prompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::file_reference( - "image/png", - json!({"xai": "file-abc123", "openai": "file-xyz789"}), - )], - ..Default::default() - }]; - let result = model.do_generate(&default_options(prompt)).await.unwrap(); - - let body = result.request_body.unwrap(); - let content = body["messages"][0]["content"].as_array().unwrap(); - assert_eq!(content[0]["type"], "file"); - assert_eq!(content[0]["file"]["file_id"], "file-abc123"); - } - - /// TS: should throw error when provider reference is missing xai key - #[tokio::test] - async fn missing_xai_reference_panics() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let prompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::file_reference( - "image/png", - json!({"openai": "file-xyz789"}), - )], - ..Default::default() - }]; - let result = model.do_generate(&default_options(prompt)).await; - assert!( - result.is_err(), - "should error when xai reference is missing" - ); - } - - /// TS: should convert tool calls and tool responses - #[tokio::test] - async fn tool_calls_and_responses() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let prompt = vec![ - LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::tool_call( - "call_123", - "weather", - json!({"location": "Paris"}), - )], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::Tool, - content: vec![ContentPart::tool_result( - "call_123", - json!({"temperature": 20}), - )], - ..Default::default() - }, - ]; - let result = model.do_generate(&default_options(prompt)).await.unwrap(); - - let body = result.request_body.unwrap(); - // Assistant message with tool_calls - assert_eq!(body["messages"][0]["role"], "assistant"); - assert_eq!(body["messages"][0]["content"], ""); - let tool_calls = body["messages"][0]["tool_calls"].as_array().unwrap(); - assert_eq!(tool_calls[0]["id"], "call_123"); - assert_eq!(tool_calls[0]["type"], "function"); - assert_eq!(tool_calls[0]["function"]["name"], "weather"); - assert_eq!( - tool_calls[0]["function"]["arguments"], - "{\"location\":\"Paris\"}" - ); - - // Tool message - assert_eq!(body["messages"][1]["role"], "tool"); - assert_eq!(body["messages"][1]["tool_call_id"], "call_123"); - assert_eq!(body["messages"][1]["content"], "{\"temperature\":20}"); - } - - /// TS: should handle multiple tool calls in one message - #[tokio::test] - async fn multiple_tool_calls() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let prompt = vec![LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ - ContentPart::tool_call("call_123", "weather", json!({"location": "Paris"})), - ContentPart::tool_call("call_456", "time", json!({"timezone": "UTC"})), - ], - ..Default::default() - }]; - let result = model.do_generate(&default_options(prompt)).await.unwrap(); - - let body = result.request_body.unwrap(); - let tool_calls = body["messages"][0]["tool_calls"].as_array().unwrap(); - assert_eq!(tool_calls.len(), 2); - assert_eq!(tool_calls[0]["function"]["name"], "weather"); - assert_eq!(tool_calls[1]["function"]["name"], "time"); - } - - /// TS: should handle mixed content with text and tool calls - #[tokio::test] - async fn mixed_text_and_tool_calls() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let prompt = vec![LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ - ContentPart::text("Let me check the weather for you."), - ContentPart::tool_call("call_123", "weather", json!({"location": "Paris"})), - ], - ..Default::default() - }]; - let result = model.do_generate(&default_options(prompt)).await.unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!( - body["messages"][0]["content"], - "Let me check the weather for you." - ); - let tool_calls = body["messages"][0]["tool_calls"].as_array().unwrap(); - assert_eq!(tool_calls.len(), 1); - assert_eq!(tool_calls[0]["function"]["name"], "weather"); - } - - /// TS: should pass imageDetail from xai provider options on image parts - #[tokio::test] - async fn image_detail_provider_option() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let prompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ - ContentPart::text("What is in this image?"), - ContentPart::File { - data: vec![0, 1, 2, 3], - media_type: "image/png".to_string(), - filename: None, - provider_options: Some(provider_namespace( - "xai", - json!({"imageDetail": "low"}), - )), - }, - ], - ..Default::default() - }]; - let result = model.do_generate(&default_options(prompt)).await.unwrap(); - - let body = result.request_body.unwrap(); - let content = body["messages"][0]["content"].as_array().unwrap(); - assert_eq!(content[1]["image_url"]["detail"], "low"); - } - - /// TS: should not set detail when imageDetail is not set - #[tokio::test] - async fn no_image_detail() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let prompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ - ContentPart::text("What is in this image?"), - ContentPart::file(vec![0, 1, 2, 3], "image/png"), - ], - ..Default::default() - }]; - let result = model.do_generate(&default_options(prompt)).await.unwrap(); - - let body = result.request_body.unwrap(); - let content = body["messages"][0]["content"].as_array().unwrap(); - assert!(content[1]["image_url"].get("detail").is_none()); - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// doGenerate — response parsing and request body -// (xai-chat-language-model.test.ts → describe('doGenerate')) -// ════════════════════════════════════════════════════════════════════════════ - -mod do_generate { - use super::*; - - /// TS: should be instantiated correctly - #[tokio::test] - async fn instantiated_correctly() { - let provider = make_provider(&MockServer::start().await); - let model = provider.model("grok-3"); - assert_eq!(model.model_id(), "grok-3"); - assert_eq!(model.provider(), "xai.chat"); - } - - /// TS: should extract text content - #[tokio::test] - async fn extract_text_content() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "test", - "object": "chat.completion", - "created": 1699472111, - "model": "grok-3", - "choices": [{ - "index": 0, - "message": { "role": "assistant", "content": "Hello from object" }, - "finish_reason": "stop" - }], - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - - assert_eq!(result.content.len(), 1); - match &result.content[0] { - GenerateContent::Text { text, .. } => assert_eq!(text, "Hello from object"), - other => panic!("expected Text, got {other:?}"), - } - assert_eq!(result.finish_reason.unified, FinishReasonUnified::Stop); - } - - /// TS: should extract tool call content - #[tokio::test] - async fn extract_tool_call() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "test", - "object": "chat.completion", - "created": 1699472111, - "model": "grok-3", - "choices": [{ - "index": 0, - "message": { - "role": "assistant", - "content": "", - "tool_calls": [{ - "id": "call_93562515", - "function": { - "name": "weather", - "arguments": "{\"location\":\"San Francisco\"}" - }, - "type": "function" - }] - }, - "finish_reason": "tool_calls" - }], - "usage": { "prompt_tokens": 291, "total_tokens": 506, "completion_tokens": 26 } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - - let tool_call = result.content.iter().find_map(|c| match c { - GenerateContent::ToolCall(RawToolCall { - tool_call_id, - tool_name, - input, - .. - }) => Some((tool_call_id.clone(), tool_name.clone(), input.clone())), - _ => None, - }); - let (id, name, input) = tool_call.expect("should have ToolCall"); - assert_eq!(id, "call_93562515"); - assert_eq!(name, "weather"); - assert_eq!(input, r#"{"location":"San Francisco"}"#); - assert_eq!(result.finish_reason.unified, FinishReasonUnified::ToolCalls); - } - - /// TS: should extract usage (from xai-text fixture) - #[tokio::test] - async fn extract_usage() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(xai_text_fixture())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - - assert_eq!(result.usage.input_tokens.cache_read, Some(2)); - assert_eq!(result.usage.input_tokens.no_cache, Some(10)); - assert_eq!(result.usage.input_tokens.total, Some(12)); - assert_eq!(result.usage.output_tokens.reasoning, Some(228)); - assert_eq!(result.usage.output_tokens.text, Some(1)); - assert_eq!(result.usage.output_tokens.total, Some(229)); - } - - /// TS: should send additional response information (id, modelId, timestamp) - #[tokio::test] - async fn response_metadata() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(xai_text_fixture())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - - assert_eq!( - result.response.id.as_deref(), - Some("2af5c888-e886-6dcb-7844-95f8fe010b00") - ); - assert_eq!(result.response.model_id.as_deref(), Some("grok-3-mini")); - assert!(result.response.timestamp.is_some()); - } - - /// TS: should expose the raw response headers - #[tokio::test] - async fn response_headers() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("test-header", "test-value") - .set_body_json(text_completion_body()), - ) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - - let headers = result.response_headers.as_ref().expect("headers"); - assert_eq!(headers.get("test-header"), Some(&"test-value".to_string())); - } - - /// TS: should pass the model and the messages - #[tokio::test] - async fn pass_model_and_messages() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let _ = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - - let requests = server.received_requests().await.unwrap(); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["model"], "grok-3"); - assert_eq!(body["messages"][0]["content"], "Hello"); - assert_eq!(body["messages"][0]["role"], "user"); - } - - /// TS: should pass tools and toolChoice - #[tokio::test] - async fn pass_tools_and_tool_choice() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let options = CallOptions { - tools: Some(vec![Tool::from(FunctionTool { - name: "test-tool".to_string(), - description: None, - input_schema: json!({ - "type": "object", - "properties": { "value": { "type": "string" } }, - "required": ["value"], - "additionalProperties": false, - "$schema": "http://json-schema.org/draft-07/schema#" - }), - strict: None, - provider_options: None, - input_examples: None, - })]), - tool_choice: ToolChoice::Tool { - tool_name: "test-tool".to_string(), - }, - ..default_options(test_prompt()) - }; - let _ = model.do_generate(&options).await.unwrap(); - - let requests = server.received_requests().await.unwrap(); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["tool_choice"]["function"]["name"], "test-tool"); - assert_eq!(body["tools"][0]["function"]["name"], "test-tool"); - // additionalProperties should be removed - assert!( - body["tools"][0]["function"]["parameters"] - .get("additionalProperties") - .is_none() - ); - } - - /// TS: should pass parallel_function_calling provider option - #[tokio::test] - async fn pass_parallel_function_calling() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let options = CallOptions { - provider_options: xai_options("parallel_function_calling", json!(false)), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!(body["parallel_function_calling"], false); - } - - /// TS: should pass logprobs provider options - #[tokio::test] - async fn pass_logprobs() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let options = CallOptions { - provider_options: xai_provider_options(json!({ - "logprobs": true, - "topLogprobs": 5 - })), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!(body["logprobs"], true); - assert_eq!(body["top_logprobs"], 5); - } - - /// TS: should enable logprobs when topLogprobs is set - #[tokio::test] - async fn enable_logprobs_when_top_logprobs_set() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let options = CallOptions { - provider_options: xai_options("topLogprobs", json!(3)), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!(body["logprobs"], true); - assert_eq!(body["top_logprobs"], 3); - } - - /// TS: should pass search parameters - #[tokio::test] - async fn pass_search_parameters() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let options = CallOptions { - provider_options: xai_provider_options(json!({ - "searchParameters": { - "mode": "auto", - "returnCitations": true, - "fromDate": "2024-01-01", - "toDate": "2024-12-31", - "maxSearchResults": 10 - } - })), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!(body["search_parameters"]["mode"], "auto"); - assert_eq!(body["search_parameters"]["return_citations"], true); - assert_eq!(body["search_parameters"]["from_date"], "2024-01-01"); - assert_eq!(body["search_parameters"]["to_date"], "2024-12-31"); - assert_eq!(body["search_parameters"]["max_search_results"], 10); - } - - /// TS: should pass search parameters with sources array - #[tokio::test] - async fn pass_search_parameters_with_sources() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let options = CallOptions { - provider_options: xai_provider_options(json!({ - "searchParameters": { - "mode": "on", - "sources": [ - {"type": "web", "country": "US", "excludedWebsites": ["example.com"], "safeSearch": false}, - {"type": "x", "includedXHandles": ["grok"], "excludedXHandles": ["openai"], "postFavoriteCount": 5, "postViewCount": 50}, - {"type": "news", "country": "GB"}, - {"type": "rss", "links": ["https://status.x.ai/feed.xml"]} - ] - } - })), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - let sp = &body["search_parameters"]; - assert_eq!(sp["mode"], "on"); - let sources = sp["sources"].as_array().unwrap(); - assert_eq!(sources.len(), 4); - assert_eq!(sources[0]["type"], "web"); - assert_eq!(sources[0]["country"], "US"); - assert_eq!(sources[0]["excluded_websites"][0], "example.com"); - assert_eq!(sources[0]["safe_search"], false); - assert_eq!(sources[1]["type"], "x"); - assert_eq!(sources[1]["included_x_handles"][0], "grok"); - assert_eq!(sources[1]["excluded_x_handles"][0], "openai"); - assert_eq!(sources[1]["post_favorite_count"], 5); - assert_eq!(sources[1]["post_view_count"], 50); - assert_eq!(sources[2]["type"], "news"); - assert_eq!(sources[2]["country"], "GB"); - assert_eq!(sources[3]["type"], "rss"); - assert_eq!(sources[3]["links"][0], "https://status.x.ai/feed.xml"); - } - - /// TS: should support json schema response format without warnings - #[tokio::test] - async fn json_schema_response_format() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let options = CallOptions { - response_format: Some(ResponseFormat::Json { - schema: Some(json!({ - "type": "object", - "properties": { "name": { "type": "string" } }, - "required": ["name"] - })), - name: Some("person".to_string()), - description: None, - }), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!(body["response_format"]["type"], "json_schema"); - assert_eq!(body["response_format"]["json_schema"]["name"], "person"); - assert_eq!(body["response_format"]["json_schema"]["strict"], true); - assert_eq!( - body["response_format"]["json_schema"]["schema"]["type"], - "object" - ); - assert!(result.warnings.is_empty()); - } - - /// TS: should handle missing usage in response - #[tokio::test] - async fn missing_usage() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "no-usage-test", - "object": "chat.completion", - "created": 1699472111, - "model": "grok-3", - "choices": [{ - "index": 0, - "message": { "role": "assistant", "content": "Hello", "tool_calls": null }, - "finish_reason": "stop" - }] - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - - assert_eq!(result.usage.input_tokens.total, Some(0)); - assert_eq!(result.usage.input_tokens.no_cache, Some(0)); - assert_eq!(result.usage.input_tokens.cache_read, Some(0)); - assert_eq!(result.usage.output_tokens.total, Some(0)); - assert_eq!(result.usage.output_tokens.text, Some(0)); - assert_eq!(result.usage.output_tokens.reasoning, Some(0)); - } - - /// TS: should extract citations as sources - #[tokio::test] - async fn extract_citations() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "citations-test", - "object": "chat.completion", - "created": 1699472111, - "model": "grok-3", - "choices": [{ - "index": 0, - "message": { "role": "assistant", "content": "Here are the latest developments in AI.", "tool_calls": null }, - "finish_reason": "stop" - }], - "usage": { "prompt_tokens": 4, "total_tokens": 34, "completion_tokens": 30 }, - "citations": [ - "https://example.com/article1", - "https://example.com/article2" - ] - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let options = CallOptions { - provider_options: xai_provider_options(json!({ - "searchParameters": { "mode": "auto", "returnCitations": true } - })), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let sources: Vec<_> = result - .content - .iter() - .filter_map(|c| match c { - GenerateContent::Source(Source { - url, source_type, .. - }) => Some((url.clone(), source_type.clone())), - _ => None, - }) - .collect(); - assert_eq!(sources.len(), 2); - assert_eq!( - sources[0].0.as_deref(), - Some("https://example.com/article1") - ); - assert_eq!(sources[0].1, "url"); - assert_eq!( - sources[1].0.as_deref(), - Some("https://example.com/article2") - ); - } - - /// TS: should avoid duplication when there is a trailing assistant message - #[tokio::test] - async fn avoid_duplication_trailing_assistant() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(xai_text_fixture())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let prompt = vec![ - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::text("prefix ")], - ..Default::default() - }, - ]; - let result = model.do_generate(&default_options(prompt)).await.unwrap(); - - // Should have text "Hello" (not "prefix ") and reasoning content - let text = result.content.iter().find_map(|c| match c { - GenerateContent::Text { text, .. } => Some(text.clone()), - _ => None, - }); - assert_eq!(text.as_deref(), Some("Hello")); - - let has_reasoning = result - .content - .iter() - .any(|c| matches!(c, GenerateContent::Reasoning(_))); - assert!(has_reasoning, "should have reasoning content"); - } - - /// TS: should send request body with correct fields - #[tokio::test] - async fn request_body_fields() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let result = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!(body["model"], "grok-3"); - assert_eq!(body["messages"][0]["content"], "Hello"); - // Should NOT have stream fields - assert!(body.get("stream").is_none()); - assert!(body.get("stream_options").is_none()); - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// doStream — streaming response parsing -// (xai-chat-language-model.test.ts → describe('doStream')) -// ════════════════════════════════════════════════════════════════════════════ - -mod do_stream { - use super::*; - - /// TS: should stream text content - #[tokio::test] - async fn stream_text() { - let server = MockServer::start().await; - let body = sse_body(&[ - &sse_event( - r#"{"id":"chatcmpl-1","model":"grok-3","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello"},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-1","model":"grok-3","choices":[{"index":0,"delta":{"content":", World!"},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-1","model":"grok-3","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":4,"completion_tokens":30,"total_tokens":34}}"#, - ), - ]); - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(body), - ) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let result = model - .do_stream(&default_options(test_prompt())) - .await - .unwrap(); - let parts = collect_stream(result).await; - - assert_eq!( - text_deltas(&parts), - vec!["Hello".to_string(), ", World!".to_string()] - ); - - let finish = parts - .iter() - .find(|p| matches!(p, StreamPart::Finish { .. })); - match finish { - Some(StreamPart::Finish { finish_reason, .. }) => { - assert_eq!(finish_reason.unified, FinishReasonUnified::Stop); - } - other => panic!("expected Finish, got {other:?}"), - } - } - - /// TS: should stream tool call content - #[tokio::test] - async fn stream_tool_call() { - let server = MockServer::start().await; - let body = sse_body(&[ - &sse_event( - r#"{"id":"de9d896d","model":"grok-3-mini","choices":[{"index":0,"delta":{"tool_calls":[{"id":"call_55117580","function":{"name":"weather","arguments":"{\"location\":\"San Francisco\"}"}}]}}]}"#, - ), - &sse_event( - r#"{"id":"de9d896d","model":"grok-3-mini","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}"#, - ), - ]); - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(body), - ) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let result = model - .do_stream(&default_options(test_prompt())) - .await - .unwrap(); - let parts = collect_stream(result).await; - - let tool_call = parts.iter().find_map(|p| match p { - StreamPart::ToolCall(RawToolCall { - tool_call_id, - tool_name, - input, - .. - }) => Some((tool_call_id.clone(), tool_name.clone(), input.clone())), - _ => None, - }); - let (id, name, input) = tool_call.expect("should have ToolCall"); - assert_eq!(id, "call_55117580"); - assert_eq!(name, "weather"); - assert_eq!(input, r#"{"location":"San Francisco"}"#); - } - - /// TS: should pass the messages (stream request body) - #[tokio::test] - async fn stream_request_body() { - let server = MockServer::start().await; - let body = sse_body(&[ - &sse_event( - r#"{"id":"chatcmpl-1","model":"grok-3","choices":[{"index":0,"delta":{"content":"Hi"},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"chatcmpl-1","model":"grok-3","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":4,"completion_tokens":30,"total_tokens":34}}"#, - ), - ]); - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(body), - ) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let result = model - .do_stream(&default_options(test_prompt())) - .await - .unwrap(); - let _ = collect_stream(result).await; - - let requests = server.received_requests().await.unwrap(); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["stream"], true); - assert_eq!(body["stream_options"]["include_usage"], true); - assert_eq!(body["model"], "grok-3"); - assert_eq!(body["messages"][0]["content"], "Hello"); - } - - /// TS: should handle missing usage in streaming response - #[tokio::test] - async fn stream_missing_usage() { - let server = MockServer::start().await; - let body = sse_body(&[ - &sse_event( - r#"{"id":"no-usage","model":"grok-3","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"no-usage","model":"grok-3","choices":[{"index":0,"delta":{"content":"Hello"},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"no-usage","model":"grok-3","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}"#, - ), - ]); - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(body), - ) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let result = model - .do_stream(&default_options(test_prompt())) - .await - .unwrap(); - let parts = collect_stream(result).await; - - let finish = parts.iter().find_map(|p| match p { - StreamPart::Finish { usage, .. } => Some(usage.clone()), - _ => None, - }); - let usage = finish.expect("should have Finish"); - assert_eq!(usage.input_tokens.total, Some(0)); - assert_eq!(usage.output_tokens.total, Some(0)); - } - - /// TS: should stream citations as sources - #[tokio::test] - async fn stream_citations() { - let server = MockServer::start().await; - let body = sse_body(&[ - &sse_event( - r#"{"id":"c8e45f92","model":"grok-3","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"c8e45f92","model":"grok-3","choices":[{"index":0,"delta":{"content":"Latest AI news"},"finish_reason":null}]}"#, - ), - &sse_event( - r#"{"id":"c8e45f92","model":"grok-3","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":4,"completion_tokens":30,"total_tokens":34},"citations":["https://example.com/source1","https://example.com/source2"]}"#, - ), - ]); - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(body), - ) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let options = CallOptions { - provider_options: xai_provider_options(json!({ - "searchParameters": { "mode": "auto", "returnCitations": true } - })), - ..default_options(test_prompt()) - }; - let result = model.do_stream(&options).await.unwrap(); - let parts = collect_stream(result).await; - - let sources: Vec<_> = parts - .iter() - .filter_map(|p| match p { - StreamPart::Source(Source { url, .. }) => url.clone(), - _ => None, - }) - .collect(); - assert_eq!(sources.len(), 2); - assert_eq!(sources[0], "https://example.com/source1"); - assert_eq!(sources[1], "https://example.com/source2"); - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// Reasoning models — reasoning_effort, reasoning content, streaming -// (xai-chat-language-model.test.ts → describe('reasoning models')) -// ════════════════════════════════════════════════════════════════════════════ - -mod reasoning { - use super::*; - - /// TS: should pass reasoning_effort parameter - #[tokio::test] - async fn pass_reasoning_effort() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3-mini"); - let options = CallOptions { - provider_options: xai_options("reasoningEffort", json!("high")), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!(body["reasoning_effort"], "high"); - } - - /// TS: should pass reasoning_effort: "none" via providerOptions - #[tokio::test] - async fn pass_reasoning_effort_none() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3-mini"); - let options = CallOptions { - provider_options: xai_options("reasoningEffort", json!("none")), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!(body["reasoning_effort"], "none"); - } - - /// TS: should map top-level reasoning to reasoning_effort - #[tokio::test] - async fn map_top_level_reasoning() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3-mini"); - let options = CallOptions { - reasoning: Some(ReasoningEffort::High), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!(body["reasoning_effort"], "high"); - } - - /// TS: should map top-level reasoning medium to reasoning_effort: "medium" - #[tokio::test] - async fn map_top_level_reasoning_medium() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3-mini"); - let options = CallOptions { - reasoning: Some(ReasoningEffort::Medium), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!(body["reasoning_effort"], "medium"); - } - - /// TS: should coerce top-level reasoning xhigh to high - #[tokio::test] - async fn coerce_xhigh_to_high() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3-mini"); - let options = CallOptions { - reasoning: Some(ReasoningEffort::Xhigh), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!(body["reasoning_effort"], "high"); - } - - /// TS: should map top-level reasoning none to reasoning_effort: "none" - #[tokio::test] - async fn map_top_level_reasoning_none() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3-mini"); - let options = CallOptions { - reasoning: Some(ReasoningEffort::None), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!(body["reasoning_effort"], "none"); - } - - /// TS: should prefer providerOptions reasoningEffort over top-level reasoning - #[tokio::test] - async fn prefer_provider_options_over_top_level() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3-mini"); - let options = CallOptions { - reasoning: Some(ReasoningEffort::Medium), - provider_options: xai_options("reasoningEffort", json!("high")), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!(body["reasoning_effort"], "high"); - } - - /// TS: should omit reasoning_effort and warn for models that do not support it - #[tokio::test] - async fn omit_for_unsupported_model() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-4.20-reasoning"); - let options = CallOptions { - reasoning: Some(ReasoningEffort::None), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert!(body.get("reasoning_effort").is_none()); - - let has_warning = result.warnings.iter().any(|w| { - matches!(w, aimux_core::types::Warning::Unsupported { feature, details } - if feature == "reasoning" - && details.as_deref() == Some("reasoning \"none\" is not supported by this model.")) - }); - assert!(has_warning, "should have reasoning unsupported warning"); - } - - /// TS: should still pass providerOptions reasoningEffort for models that do not support top-level reasoning - #[tokio::test] - async fn still_pass_provider_options_for_unsupported_model() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-4.20-reasoning"); - let options = CallOptions { - provider_options: xai_options("reasoningEffort", json!("none")), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let body = result.request_body.unwrap(); - assert_eq!(body["reasoning_effort"], "none"); - } - - /// TS: should extract reasoning content - #[tokio::test] - async fn extract_reasoning_content() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(xai_text_fixture())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3-mini"); - let options = CallOptions { - provider_options: xai_options("reasoningEffort", json!("low")), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - // Should have text content and reasoning content - let has_text = result - .content - .iter() - .any(|c| matches!(c, GenerateContent::Text { text, .. } if text == "Hello")); - assert!(has_text, "should have text 'Hello'"); - - let reasoning = result.content.iter().find_map(|c| match c { - GenerateContent::Reasoning(ReasoningOutput { text, .. }) => Some(text.clone()), - _ => None, - }); - assert!(reasoning.is_some(), "should have reasoning content"); - assert!(reasoning.unwrap().starts_with("First, the user said")); - } - - /// TS: should extract reasoning tokens from usage - #[tokio::test] - async fn extract_reasoning_tokens_from_usage() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(xai_text_fixture())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3-mini"); - let options = CallOptions { - provider_options: xai_options("reasoningEffort", json!("high")), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - assert_eq!(result.usage.output_tokens.reasoning, Some(228)); - assert_eq!(result.usage.output_tokens.text, Some(1)); - assert_eq!(result.usage.output_tokens.total, Some(229)); - } - - /// TS: should handle reasoning streaming - #[tokio::test] - async fn reasoning_streaming() { - let server = MockServer::start().await; - let chunks = [ - r#"{"id":"7327b9f5","model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"First","role":"assistant"}}]}"#, - r#"{"id":"7327b9f5","model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":","}}]}"#, - r#"{"id":"7327b9f5","model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" the"}}]}"#, - r#"{"id":"7327b9f5","model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" user"}}]}"#, - r#"{"id":"7327b9f5","model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" said"}}]}"#, - r#"{"id":"7327b9f5","model":"grok-3-mini","choices":[{"index":0,"delta":{"content":"Hello"}}]}"#, - r#"{"id":"7327b9f5","model":"grok-3-mini","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}"#, - ]; - let body = sse_body( - &chunks - .iter() - .map(|c| sse_event(c)) - .collect::>() - .iter() - .map(std::string::String::as_str) - .collect::>(), - ); - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(body), - ) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3-mini"); - let options = CallOptions { - provider_options: xai_options("reasoningEffort", json!("low")), - ..default_options(test_prompt()) - }; - let result = model.do_stream(&options).await.unwrap(); - let parts = collect_stream(result).await; - - let deltas = reasoning_deltas(&parts); - assert_eq!(deltas, vec!["First", ",", " the", " user", " said"]); - - // Should also have text delta - let text = text_deltas(&parts); - assert_eq!(text, vec!["Hello"]); - } - - /// TS: should deduplicate repetitive reasoning deltas - #[tokio::test] - async fn dedup_reasoning_deltas() { - let server = MockServer::start().await; - let chunks = [ - r#"{"id":"grok-4-test","model":"grok-4-0709","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}"#, - r#"{"id":"grok-4-test","model":"grok-4-0709","choices":[{"index":0,"delta":{"reasoning_content":"Thinking... "},"finish_reason":null}]}"#, - r#"{"id":"grok-4-test","model":"grok-4-0709","choices":[{"index":0,"delta":{"reasoning_content":"Thinking... "},"finish_reason":null}]}"#, - r#"{"id":"grok-4-test","model":"grok-4-0709","choices":[{"index":0,"delta":{"reasoning_content":"Thinking... "},"finish_reason":null}]}"#, - r#"{"id":"grok-4-test","model":"grok-4-0709","choices":[{"index":0,"delta":{"reasoning_content":"Actually calculating now..."},"finish_reason":null}]}"#, - r#"{"id":"grok-4-test","model":"grok-4-0709","choices":[{"index":0,"delta":{"content":"The answer is 42."},"finish_reason":null}]}"#, - r#"{"id":"grok-4-test","model":"grok-4-0709","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":15,"completion_tokens":20,"total_tokens":35,"completion_tokens_details":{"reasoning_tokens":10}}}"#, - ]; - let body = sse_body( - &chunks - .iter() - .map(|c| sse_event(c)) - .collect::>() - .iter() - .map(std::string::String::as_str) - .collect::>(), - ); - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(body), - ) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3-mini"); - let options = CallOptions { - provider_options: xai_options("reasoningEffort", json!("low")), - ..default_options(test_prompt()) - }; - let result = model.do_stream(&options).await.unwrap(); - let parts = collect_stream(result).await; - - // "Thinking... " should only appear once (dedup) - let deltas = reasoning_deltas(&parts); - assert_eq!(deltas, vec!["Thinking... ", "Actually calculating now..."]); - - // Should have text delta - let text = text_deltas(&parts); - assert_eq!(text, vec!["The answer is 42."]); - - // Should have reasoning tokens in finish usage - let finish = parts.iter().find_map(|p| match p { - StreamPart::Finish { usage, .. } => Some(usage.clone()), - _ => None, - }); - let usage = finish.expect("should have Finish"); - assert_eq!(usage.output_tokens.reasoning, Some(10)); - assert_eq!(usage.output_tokens.text, Some(20)); - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// Error handling — 200-status errors, chat completions error shape -// (xai-error.test.ts, xai-chat-language-model.test.ts → error handling) -// ════════════════════════════════════════════════════════════════════════════ - -mod error_handling { - use super::*; - - /// TS: extracts message from chat completions error shape (400) - #[tokio::test] - async fn chat_completions_error_400() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(400).set_body_json(json!({ - "error": { - "message": "Invalid value: temperature must be between 0 and 2", - "type": "invalid_request_error", - "code": "invalid_value" - } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let result = model.do_generate(&default_options(test_prompt())).await; - - assert!(result.is_err()); - let err = result.unwrap_err(); - let msg = err.to_string(); - assert!( - msg.contains("Invalid value: temperature must be between 0 and 2"), - "got: {msg}" - ); - } - - /// TS: extracts message and code from responses api error shape - #[tokio::test] - async fn responses_error_shape() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(400).set_body_json(json!({ - "code": "Client specified an invalid argument", - "error": "Invalid request content: Each message must have at least one content element." - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let result = model.do_generate(&default_options(test_prompt())).await; - - assert!(result.is_err()); - let err = result.unwrap_err(); - let msg = err.to_string(); - assert!( - msg.contains("Client specified an invalid argument"), - "got: {msg}" - ); - assert!(msg.contains("Invalid request content"), "got: {msg}"); - } - - /// TS: should throw APICallError when xai returns error with 200 status (doGenerate) - #[tokio::test] - async fn error_with_200_status_do_generate() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "code": "The service is currently unavailable", - "error": "Timed out waiting for first token" - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let result = model.do_generate(&default_options(test_prompt())).await; - - assert!(result.is_err()); - let err = result.unwrap_err(); - let msg = err.to_string(); - assert!( - msg.contains("Timed out waiting for first token"), - "got: {msg}" - ); - } - - /// TS: should throw APICallError when xai returns error with 200 status (doStream) - #[tokio::test] - async fn error_with_200_status_do_stream() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "code": "The service is currently unavailable", - "error": "Timed out waiting for first token" - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let result = model.do_stream(&default_options(test_prompt())).await; - - assert!(result.is_err()); - let err = result.unwrap_err(); - let msg = err.to_string(); - assert!( - msg.contains("Timed out waiting for first token"), - "got: {msg}" - ); - } - - /// TS: 401 maps to `ApiCall` (401) - #[tokio::test] - async fn status_401_maps_to_auth_error() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(401).set_body_json(json!({ - "error": { - "message": "Incorrect API key provided", - "type": "invalid_request_error" - } - }))) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let result = model.do_generate(&default_options(test_prompt())).await; - - assert!( - matches!(result, Err(AiMuxError::ApiCall(ref m)) if m.status_code == Some(401) && m.message == "Incorrect API key provided") - ); - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// Provider configuration — base URL, headers, auth -// (xai-provider.test.ts → chat-related tests) -// ════════════════════════════════════════════════════════════════════════════ - -mod provider { - use super::*; - - /// TS: should construct a chat model with correct configuration - #[tokio::test] - async fn chat_model_config() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - - assert_eq!(model.model_id(), "grok-3"); - assert_eq!(model.provider(), "xai.chat"); - } - - /// TS: should use custom baseURL - #[tokio::test] - async fn custom_base_url() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let config = XAIConfig::new("test-api-key").with_base_url(server.uri()); - let provider = XAIProvider::new(config); - let model = provider.model("grok-3"); - let _ = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - - let requests = server.received_requests().await.unwrap(); - assert_eq!(requests.len(), 1); - assert_eq!(requests[0].url.path(), "/chat/completions"); - } - - /// TS: should pass headers (provider + request) - #[tokio::test] - async fn pass_headers() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let mut headers = HashMap::new(); - headers.insert( - "Custom-Request-Header".to_string(), - "request-header-value".to_string(), - ); - let options = CallOptions { - headers: Some(headers), - ..default_options(test_prompt()) - }; - let _ = model.do_generate(&options).await.unwrap(); - - let requests = server.received_requests().await.unwrap(); - assert_eq!( - requests[0] - .headers - .get("authorization") - .and_then(|v| v.to_str().ok()), - Some("Bearer test-api-key") - ); - assert_eq!( - requests[0] - .headers - .get("custom-request-header") - .and_then(|v| v.to_str().ok()), - Some("request-header-value") - ); - } - - /// TS: request uses correct URL and auth header - #[tokio::test] - async fn request_url_and_auth() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-2"); - let _ = model - .do_generate(&default_options(test_prompt())) - .await - .unwrap(); - - let requests = server.received_requests().await.unwrap(); - assert_eq!(requests.len(), 1); - assert_eq!(requests[0].url.path(), "/chat/completions"); - assert_eq!( - requests[0] - .headers - .get("authorization") - .and_then(|v| v.to_str().ok()), - Some("Bearer test-api-key") - ); - let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!(body["model"], "grok-2"); - } - - /// TS: should warn for unsupported parameters (topK, frequencyPenalty, etc.) - #[tokio::test] - async fn warn_for_unsupported_params() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/chat/completions")) - .respond_with(ResponseTemplate::new(200).set_body_json(text_completion_body())) - .mount(&server) - .await; - - let provider = make_provider(&server); - let model = provider.model("grok-3"); - let options = CallOptions { - top_k: Some(40.0), - frequency_penalty: Some(0.5), - presence_penalty: Some(0.3), - stop_sequences: Some(vec!["stop".to_string()]), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - let features: Vec<_> = result - .warnings - .iter() - .filter_map(|w| match w { - aimux_core::types::Warning::Unsupported { feature, .. } => Some(feature.clone()), - _ => None, - }) - .collect(); - assert!(features.contains(&"topK".to_string())); - assert!(features.contains(&"frequencyPenalty".to_string())); - assert!(features.contains(&"presencePenalty".to_string())); - assert!(features.contains(&"stopSequences".to_string())); - } -} From 1629f1b1311ca9c43e7898fbcc3a5ab01dd9b68c Mon Sep 17 00:00:00 2001 From: chenhaonan Date: Mon, 5 Oct 2026 11:51:21 +0800 Subject: [PATCH 02/10] refactor(core)!: model the provider prompt by role, with one file part What: `LanguageModelPrompt` is now `Vec`, an enum by role: a system message holds a string; user, assistant and tool messages hold their own part unions (`UserPart`, `AssistantPart`, `ToolPart`). The part structs (`TextPart`, `FilePart`, `ReasoningPart`, `ToolCallPart`, `ToolResultPart`) are each defined once. A provider sees exactly one file part, whose `data` is the existing `FileData` union. `convert_to_language_model_prompt` is the single conversion from user messages: it folds the five user-facing file variants into `FilePart` and rejects a part its role does not allow, or a system message that is not plain text, with `InvalidPrompt`. `LanguageModelPromptMessage` is removed. Why: this is the shape of upstream's `LanguageModelV4Message`. Before, every provider received the user-facing part union for every role, so each converter repeated five file arms and handled role and part combinations that cannot occur. The user-facing `ModelMessage` and `ContentPart` and their JSON are unchanged; the tool result fields and the two signature fields keep their current form and are listed as remaining differences. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01WGwanvo9WLU9HWD7sRR8TS --- aimux-core/src/composite.rs | 8 +- aimux-core/src/generate.rs | 4 +- aimux-core/src/language_model_message.rs | 370 ++++++++++++++++++++--- aimux-core/src/moa.rs | 22 +- aimux-core/src/recording.rs | 3 +- aimux-core/src/replay.rs | 185 +++++++----- aimux-core/src/session.rs | 19 +- 7 files changed, 450 insertions(+), 161 deletions(-) diff --git a/aimux-core/src/composite.rs b/aimux-core/src/composite.rs index 215fce58d..8d326c1b9 100644 --- a/aimux-core/src/composite.rs +++ b/aimux-core/src/composite.rs @@ -19,7 +19,7 @@ use std::sync::Arc; use crate::language_model::LanguageModel; -use crate::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; +use crate::language_model_message::{LanguageModelMessage, LanguageModelPrompt}; use crate::result::GenerateContent; use crate::types::{TokenUsage, Usage}; @@ -119,10 +119,6 @@ pub fn build_aggregator_prompt( body.push('\n'); } - out.push(LanguageModelPromptMessage { - role: crate::message::Role::User, - content: vec![crate::content::ContentPart::text(body)], - provider_options: None, - }); + out.push(LanguageModelMessage::user_text(body)); out } diff --git a/aimux-core/src/generate.rs b/aimux-core/src/generate.rs index bcc4a2ecf..2b99c63f1 100644 --- a/aimux-core/src/generate.rs +++ b/aimux-core/src/generate.rs @@ -492,7 +492,7 @@ pub async fn generate_text( let tools = options.tools.clone(); let operation_instructions = options.instructions.clone(); let (messages, instructions) = split_prompt(prompt.into(), operation_instructions.as_deref()); - let lm_prompt = convert_to_language_model_prompt(&messages, instructions); + let lm_prompt = convert_to_language_model_prompt(&messages, instructions)?; // 2. Build CallOptions. let mut call_options = options.into_call_options(lm_prompt); @@ -776,7 +776,7 @@ pub async fn stream_text( let tools = options.tools.clone(); let operation_instructions = options.instructions.clone(); let (messages, instructions) = split_prompt(prompt.into(), operation_instructions.as_deref()); - let lm_prompt = convert_to_language_model_prompt(&messages, instructions); + let lm_prompt = convert_to_language_model_prompt(&messages, instructions)?; // 2. Build CallOptions. let mut call_options = options.into_call_options(lm_prompt); diff --git a/aimux-core/src/language_model_message.rs b/aimux-core/src/language_model_message.rs index 23c8321d1..7cfb22cf6 100644 --- a/aimux-core/src/language_model_message.rs +++ b/aimux-core/src/language_model_message.rs @@ -1,65 +1,363 @@ -//! Provider-facing message types. +//! Provider-facing prompt types. //! -//! `LanguageModelPrompt` is the standardized prompt format that providers receive -//! in `CallOptions.prompt`. It is converted from `ModelMessage` (user-facing) -//! by the `generate_text` / `stream_text` functions. +//! Aligned with Vercel AI SDK `LanguageModelV4Prompt` / `LanguageModelV4Message` +//! (`provider/src/language-model/v4/language-model-v4-prompt.ts`): a prompt is +//! a list of messages, a message is a union by role, and there is exactly one +//! file part. `convert_to_language_model_prompt` is the single conversion from +//! the user-facing `ModelMessage`s. use crate::content::ContentPart; -use crate::message::Role; -use crate::shared::SharedProviderOptions; +use crate::error::AiMuxError; +use crate::message::{MessageContent, ModelMessage, Role}; +use crate::shared::{FileBytes, FileData, SharedProviderOptions}; +use std::borrow::Cow; + use serde::{Deserialize, Serialize}; +use serde_json::Value; use ts_rs::TS; -/// A single provider-facing message. +/// The standardized prompt passed to `LanguageModel::do_generate` / `do_stream`. +pub type LanguageModelPrompt = Vec; + +/// A single provider-facing message, a union by role. /// -/// Unlike `ModelMessage` (which can have string content), this always has -/// `content: Vec` — strings are normalized to `ContentPart::Text`. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, TS)] +/// `provider_options` is message-level (e.g. `anthropic.cacheControl`), +/// mirroring `LanguageModelV4Message.providerOptions`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[serde(tag = "role", rename_all = "snake_case")] +#[ts(export)] +pub enum LanguageModelMessage { + System { + content: String, + provider_options: Option, + }, + User { + content: Vec, + provider_options: Option, + }, + Assistant { + content: Vec, + provider_options: Option, + }, + Tool { + content: Vec, + provider_options: Option, + }, +} + +/// Text content part. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] #[ts(export)] -pub struct LanguageModelPromptMessage { - pub role: Role, - pub content: Vec, - /// Message-level provider-specific options (e.g. - /// `anthropic.cacheControl`), applied to the last part of the message when - /// converting to a provider prompt. Mirrors the Vercel AI SDK - /// `LanguageModelV4Message.providerOptions`. +pub struct TextPart { + pub text: String, + #[serde(default, skip_serializing_if = "Option::is_none")] pub provider_options: Option, } -/// The standardized prompt passed to `LanguageModel::do_generate` / `do_stream`. -pub type LanguageModelPrompt = Vec; +/// File content part (the only file part; `data` is a tagged `FileData`). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct FilePart { + pub data: FileData, + pub media_type: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub filename: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_options: Option, +} + +/// Reasoning / thinking content part. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct ReasoningPart { + pub text: String, + pub signature: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_options: Option, +} + +/// Tool call content part. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct ToolCallPart { + pub tool_call_id: String, + pub tool_name: String, + pub input: Value, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_executed: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub thought_signature: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_options: Option, +} + +/// Tool result content part. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct ToolResultPart { + pub tool_call_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool_name: Option, + #[serde(alias = "output")] + pub result: Value, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub is_error: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub preliminary: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dynamic: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_options: Option, +} + +/// Parts allowed in a user message. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[serde(tag = "type", rename_all = "snake_case")] +#[ts(export)] +pub enum UserPart { + Text(TextPart), + File(FilePart), +} + +/// Parts allowed in an assistant message. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[serde(tag = "type", rename_all = "snake_case")] +#[ts(export)] +pub enum AssistantPart { + Text(TextPart), + File(FilePart), + Reasoning(ReasoningPart), + ToolCall(ToolCallPart), + ToolResult(ToolResultPart), +} + +/// Parts allowed in a tool message. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[serde(tag = "type", rename_all = "snake_case")] +#[ts(export)] +pub enum ToolPart { + ToolResult(ToolResultPart), +} + +impl LanguageModelMessage { + /// User message with a single text part. + #[must_use] + pub fn user_text(text: impl Into) -> Self { + Self::User { + content: vec![UserPart::Text(TextPart { + text: text.into(), + provider_options: None, + })], + provider_options: None, + } + } +} + +/// Map a user-facing part onto the widest provider part (the assistant union); +/// the role then narrows it. +fn to_assistant_part(part: &ContentPart) -> Result { + let file = |data, media_type: &String, filename: &Option, po: &Option<_>| { + AssistantPart::File(FilePart { + data, + media_type: media_type.clone(), + filename: filename.clone(), + provider_options: po.clone(), + }) + }; + Ok(match part { + ContentPart::Text { + text, + provider_options, + } => AssistantPart::Text(TextPart { + text: text.clone(), + provider_options: provider_options.clone(), + }), + ContentPart::Image { + image, + media_type, + provider_options, + } => file( + FileData::Data { + data: FileBytes::Binary(image.clone()), + }, + media_type, + &None, + provider_options, + ), + ContentPart::File { + data, + media_type, + filename, + provider_options, + } => file( + FileData::Data { + data: FileBytes::Binary(data.clone()), + }, + media_type, + filename, + provider_options, + ), + ContentPart::FileBase64 { + data, + media_type, + filename, + provider_options, + } => file( + FileData::Data { + data: FileBytes::Base64(data.clone()), + }, + media_type, + filename, + provider_options, + ), + ContentPart::FileUrl { + url, + media_type, + provider_options, + } => file( + FileData::Url { url: url.clone() }, + media_type, + &None, + provider_options, + ), + ContentPart::FileReference { + media_type, + reference, + filename, + provider_options, + } => file( + FileData::Reference { + reference: serde_json::from_value(reference.clone()).map_err(|e| { + AiMuxError::InvalidPrompt(format!("invalid file reference: {e}")) + })?, + }, + media_type, + filename, + provider_options, + ), + ContentPart::Reasoning { + text, + signature, + provider_options, + } => AssistantPart::Reasoning(ReasoningPart { + text: text.clone(), + signature: signature.clone(), + provider_options: provider_options.clone(), + }), + ContentPart::ToolCall { + tool_call_id, + tool_name, + input, + provider_executed, + thought_signature, + provider_options, + } => AssistantPart::ToolCall(ToolCallPart { + tool_call_id: tool_call_id.clone(), + tool_name: tool_name.clone(), + input: input.clone(), + provider_executed: *provider_executed, + thought_signature: thought_signature.clone(), + provider_options: provider_options.clone(), + }), + ContentPart::ToolResult { + tool_call_id, + result, + tool_name, + is_error, + preliminary, + dynamic, + provider_options, + } => AssistantPart::ToolResult(ToolResultPart { + tool_call_id: tool_call_id.clone(), + tool_name: tool_name.clone(), + result: result.clone(), + is_error: *is_error, + preliminary: *preliminary, + dynamic: *dynamic, + provider_options: provider_options.clone(), + }), + }) +} /// Convert user-facing `ModelMessage`s into a `LanguageModelPrompt`. /// -/// - String content is normalized to a single `ContentPart::Text`. +/// - String content is normalized to a single text part. +/// - The five user-facing file variants become one `FilePart`. /// - If `instructions` is provided, it is prepended as a system message. -#[must_use] +/// +/// # Errors +/// +/// `InvalidPrompt` when a part is not allowed for its role (user: text/file; +/// tool: tool-result), a system message is not plain text, or a file +/// reference is not a `{ provider: id }` map. pub fn convert_to_language_model_prompt( - messages: &[crate::message::ModelMessage], + messages: &[ModelMessage], instructions: Option<&str>, -) -> LanguageModelPrompt { +) -> Result { let mut result = Vec::new(); - - // Prepend system instructions if provided. if let Some(instructions) = instructions { - result.push(LanguageModelPromptMessage { - role: Role::System, - content: vec![ContentPart::text(instructions)], + result.push(LanguageModelMessage::System { + content: instructions.to_string(), provider_options: None, }); } for msg in messages { - let parts = match &msg.content { - crate::message::MessageContent::Text(text) => vec![ContentPart::text(text)], - crate::message::MessageContent::Parts(parts) => parts.clone(), + let bad = |what: &str| AiMuxError::InvalidPrompt(format!("{:?} message: {what}", msg.role)); + if msg.role == Role::System { + match &msg.content { + MessageContent::Text(text) => result.push(LanguageModelMessage::System { + content: text.clone(), + provider_options: None, + }), + _ => return Err(bad("system content must be plain text")), + } + continue; + } + let parts: Cow<'_, [ContentPart]> = match &msg.content { + MessageContent::Text(text) => Cow::Owned(vec![ContentPart::text(text)]), + MessageContent::Parts(parts) => Cow::Borrowed(parts), }; - result.push(LanguageModelPromptMessage { - role: msg.role, - content: parts, - provider_options: None, + let parts = parts + .iter() + .filter(|part| { + !matches!(&msg.content, MessageContent::Parts(_)) + || !matches!(part, ContentPart::Text { text, provider_options } + if text.is_empty() && (msg.role == Role::User + || (msg.role == Role::Assistant && provider_options.is_none()))) + }) + .map(to_assistant_part) + .collect::, _>>()?; + let provider_options = None; + result.push(match msg.role { + Role::System => unreachable!(), + Role::Assistant => LanguageModelMessage::Assistant { + content: parts, + provider_options, + }, + Role::User => LanguageModelMessage::User { + content: parts + .into_iter() + .map(|p| match p { + AssistantPart::Text(t) => Ok(UserPart::Text(t)), + AssistantPart::File(f) => Ok(UserPart::File(f)), + _ => Err(bad("only text and file parts are allowed")), + }) + .collect::>()?, + provider_options, + }, + Role::Tool => LanguageModelMessage::Tool { + content: parts + .into_iter() + .map(|p| match p { + AssistantPart::ToolResult(r) => Ok(ToolPart::ToolResult(r)), + _ => Err(bad("only tool-result parts are allowed")), + }) + .collect::>()?, + provider_options, + }, }); } - result + Ok(result) } diff --git a/aimux-core/src/moa.rs b/aimux-core/src/moa.rs index 24e728a99..7fd0ef267 100644 --- a/aimux-core/src/moa.rs +++ b/aimux-core/src/moa.rs @@ -310,9 +310,7 @@ impl LanguageModel for MoaModel { #[cfg(test)] mod tests { use super::*; - use crate::content::ContentPart; - use crate::language_model_message::LanguageModelPromptMessage; - use crate::message::Role; + use crate::language_model_message::LanguageModelMessage; use crate::result::{GenerateContent, Source}; use crate::stream_part::StreamPart; use crate::tool::RawToolCall; @@ -459,11 +457,7 @@ mod tests { fn opts_with_prompt() -> CallOptions { CallOptions { - prompt: vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("What is Rust?")], - provider_options: None, - }], + prompt: vec![LanguageModelMessage::user_text("What is Rust?")], ..Default::default() } } @@ -648,19 +642,11 @@ mod tests { async fn no_references_does_not_inject_empty_heading() { // S4 regression guard: with 0 references the aggregator prompt is the // original prompt verbatim — no "# Reference model responses" heading. - let prompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("hello")], - provider_options: None, - }]; + let prompt = vec![LanguageModelMessage::user_text("hello")]; let built = build_aggregator_prompt(&prompt, None, &[]); // Still just the single original message; nothing appended. assert_eq!(built.len(), 1); - // Heading must NOT appear. - assert!(!built[0].content.iter().any(|p| matches!( - p, - ContentPart::Text { text, .. } if text.contains("Reference model responses") - ))); + assert_eq!(built, prompt); } /// A mock aggregator whose stream yields TextDelta then a transport Err, diff --git a/aimux-core/src/recording.rs b/aimux-core/src/recording.rs index 516f18f94..efda939ba 100644 --- a/aimux-core/src/recording.rs +++ b/aimux-core/src/recording.rs @@ -1793,7 +1793,8 @@ mod tests { crate::language_model_message::convert_to_language_model_prompt( &[crate::message::ModelMessage::user("ping")], None, - ), + ) + .unwrap(), ) } diff --git a/aimux-core/src/replay.rs b/aimux-core/src/replay.rs index 210bc4f5d..0788c2fd1 100644 --- a/aimux-core/src/replay.rs +++ b/aimux-core/src/replay.rs @@ -18,11 +18,14 @@ use std::sync::Arc; use crate::error::AiMuxError; use crate::generate::{GenerateTextOptions, GenerateTextResult, generate_text}; use crate::language_model::LanguageModel; -use crate::language_model_message::LanguageModelPrompt; -use crate::message::{MessageContent, ModelMessage, ModelPrompt}; +use crate::language_model_message::{ + AssistantPart, FilePart, LanguageModelMessage, LanguageModelPrompt, UserPart, +}; +use crate::message::{ModelMessage, ModelPrompt}; use crate::options::{CallOptions, ToolChoice}; use crate::recording::Recording; use crate::result::{GenerateContent, GenerateResult, StreamResult}; +use crate::shared::{FileBytes, FileData}; use crate::stream_part::StreamPart; use crate::tool::RawToolCall; use crate::types::{FinishReason, FinishReasonUnified, ResponseMetadata, Usage}; @@ -247,10 +250,42 @@ impl ReplayMatcher for ScoreMatcher { } /// 消息的首个文本内容(非文本消息回退到序列化)。 -fn message_text(m: &crate::language_model_message::LanguageModelPromptMessage) -> String { - match m.content.first() { - Some(crate::content::ContentPart::Text { text, .. }) => text.clone(), - _ => serde_json::to_string(&m.content).unwrap_or_default(), +fn message_text(m: &LanguageModelMessage) -> String { + match m { + LanguageModelMessage::System { content, .. } => content.clone(), + LanguageModelMessage::User { content, .. } => match content.first() { + Some(UserPart::Text(part)) => part.text.clone(), + _ => serde_json::to_string(content).unwrap_or_default(), + }, + LanguageModelMessage::Assistant { content, .. } => match content.first() { + Some(AssistantPart::Text(part)) => part.text.clone(), + _ => serde_json::to_string(content).unwrap_or_default(), + }, + LanguageModelMessage::Tool { content, .. } => { + serde_json::to_string(content).unwrap_or_default() + } + } +} + +fn same_message_content(a: &LanguageModelMessage, b: &LanguageModelMessage) -> bool { + match (a, b) { + ( + LanguageModelMessage::System { content: a, .. }, + LanguageModelMessage::System { content: b, .. }, + ) => a == b, + ( + LanguageModelMessage::User { content: a, .. }, + LanguageModelMessage::User { content: b, .. }, + ) => a == b, + ( + LanguageModelMessage::Assistant { content: a, .. }, + LanguageModelMessage::Assistant { content: b, .. }, + ) => a == b, + ( + LanguageModelMessage::Tool { content: a, .. }, + LanguageModelMessage::Tool { content: b, .. }, + ) => a == b, + _ => false, } } @@ -266,7 +301,7 @@ fn match_score(options: &CallOptions, rec: &Recording) -> u64 { .prompt .iter() .zip(rec.input.prompt.iter()) - .take_while(|(a, b)| a.role == b.role && a.content == b.content) + .take_while(|(a, b)| same_message_content(a, b)) .count(); // 字符级 LCP:第一个不同消息(或双方最后一个消息),在文本内容上计算 // (避免 JSON 结构前缀的伪匹配)。任一 prompt 为空时无消息可比,LCP=0 @@ -338,7 +373,7 @@ impl ReplayMatcher for PrefixMatcher { .prompt .iter() .zip(r.input.prompt.iter()) - .take_while(|(a, b)| a.role == b.role && a.content == b.content) + .take_while(|(a, b)| same_message_content(a, b)) .count(); if common < r.input.prompt.len() { continue; // 录制未被输入完整包含,不命中。 @@ -915,8 +950,8 @@ pub struct ReplayOverrides { /// # Errors /// /// Returns `AiMuxError::JsonParse` when the recorded call options cannot be -/// deserialized, and propagates any error from the re-sent `generate_text` -/// call. +/// deserialized, `AiMuxError::InvalidPrompt` for inline text file data without +/// a user-facing representation, and propagates errors from `generate_text`. pub async fn replay_with_model( recording: &Recording, model: &dyn LanguageModel, @@ -940,25 +975,62 @@ pub async fn replay_with_model( } // 3. 转回用户侧类型,经 generate_text 重发。 - let prompt = model_prompt_from_lm(&call_options.prompt); + let prompt = model_prompt_from_lm(&call_options.prompt)?; let options = generate_options_from_call_options(call_options); generate_text(model, prompt, options).await } /// `LanguageModelPrompt`(provider 侧)→ `ModelPrompt`(用户侧)。 /// -/// 逐消息 `provider_options` 丢弃(用户侧无对应字段;语义不丢,仅 cacheControl -/// 之类的 provider 提示不参与重放)。 -fn model_prompt_from_lm(prompt: &LanguageModelPrompt) -> ModelPrompt { - ModelPrompt::Messages( +/// 逐消息 `provider_options` 丢弃(用户侧无对应字段)。 +/// 文件 URL 的 filename 同样没有用户侧字段;inline text 文件返回错误。 +fn model_prompt_from_lm(prompt: &LanguageModelPrompt) -> Result { + let messages = prompt .iter() - .map(|m| ModelMessage { - role: m.role, - content: MessageContent::Parts(m.content.clone()), + .map(|message| { + // Non-file parts share their fields with the user-facing union. + let mut message = serde_json::to_value(message)?; + if let Some(parts) = message["content"].as_array_mut() { + for part in parts { + if part["type"] != "file" { + continue; + } + let file: FilePart = serde_json::from_value(part.clone())?; + part.as_object_mut() + .expect("serialized file part") + .remove("data"); + match file.data { + FileData::Data { + data: FileBytes::Binary(data), + } => { + part["data"] = serde_json::to_value(data)?; + } + FileData::Data { + data: FileBytes::Base64(data), + } => { + part["type"] = "file_base64".into(); + part["data"] = data.into(); + } + FileData::Url { url } => { + part["type"] = "file_url".into(); + part["url"] = url.into(); + } + FileData::Reference { reference } => { + part["type"] = "file_reference".into(); + part["reference"] = serde_json::to_value(reference)?; + } + FileData::Text { .. } => return Err(AiMuxError::InvalidPrompt( + "replay: inline text file data has no user-facing representation" + .into(), + )), + } + } + } + serde_json::from_value::(message).map_err(AiMuxError::from) }) - .collect(), - ) + .collect::, _>>()?; + Ok(ModelPrompt::Messages(messages)) } /// `CallOptions` → `GenerateTextOptions`(record 侧到用户侧的反向映射)。 @@ -1009,14 +1081,7 @@ mod tests { use futures::StreamExt; fn sample_options(text: &str, temperature: Option) -> CallOptions { - let prompt = vec![crate::language_model_message::LanguageModelPromptMessage { - role: crate::message::Role::User, - content: vec![crate::content::ContentPart::Text { - text: text.to_string(), - provider_options: None, - }], - provider_options: None, - }]; + let prompt = vec![LanguageModelMessage::user_text(text.to_string())]; GenerateTextOptions { temperature, ..Default::default() @@ -1027,14 +1092,7 @@ mod tests { /// 构造 OpenAI 格式录制。 fn openai_recording(trace_id: &str, prompt_text: &str, reply: &str, finish: &str) -> Recording { let input_prompt: crate::language_model_message::LanguageModelPrompt = - vec![crate::language_model_message::LanguageModelPromptMessage { - role: crate::message::Role::User, - content: vec![crate::content::ContentPart::Text { - text: prompt_text.to_string(), - provider_options: None, - }], - provider_options: None, - }]; + vec![LanguageModelMessage::user_text(prompt_text.to_string())]; let body = serde_json::json!({ "id": "chatcmpl-mock", "object": "chat.completion", @@ -1363,22 +1421,8 @@ mod tests { let matcher = PrefixMatcher::new("openai", "gpt-4o"); // 输入两条消息 ["hello", "world"]:rec 是消息级前缀 → 命中。 let prompt = vec![ - crate::language_model_message::LanguageModelPromptMessage { - role: crate::message::Role::User, - content: vec![crate::content::ContentPart::Text { - text: "hello".into(), - provider_options: None, - }], - provider_options: None, - }, - crate::language_model_message::LanguageModelPromptMessage { - role: crate::message::Role::User, - content: vec![crate::content::ContentPart::Text { - text: "world".into(), - provider_options: None, - }], - provider_options: None, - }, + LanguageModelMessage::user_text("hello"), + LanguageModelMessage::user_text("world"), ]; let opts = GenerateTextOptions::default().into_call_options(prompt); let recs = [rec.clone()]; @@ -1386,16 +1430,8 @@ mod tests { assert_eq!(hit.call_id, "t1"); // 首消息不同 → 不命中(整条消息级前缀,非字符级)。 - let opts2 = GenerateTextOptions::default().into_call_options(vec![ - crate::language_model_message::LanguageModelPromptMessage { - role: crate::message::Role::User, - content: vec![crate::content::ContentPart::Text { - text: "hi".into(), - provider_options: None, - }], - provider_options: None, - }, - ]); + let opts2 = GenerateTextOptions::default() + .into_call_options(vec![LanguageModelMessage::user_text("hi")]); assert!(matcher.r#match(&opts2, &[rec]).is_err()); } @@ -1404,14 +1440,7 @@ mod tests { // 多消息录制:rec-a = ["hello"],rec-b = ["hello", "world"]。 let mut rec_a = openai_recording("ta", "hello", "a", "stop"); let mut rec_b = openai_recording("tb", "hello world", "b", "stop"); - let mk = |text: &str| crate::language_model_message::LanguageModelPromptMessage { - role: crate::message::Role::User, - content: vec![crate::content::ContentPart::Text { - text: text.into(), - provider_options: None, - }], - provider_options: None, - }; + let mk = |text: &str| LanguageModelMessage::user_text(text); rec_a.input.prompt = vec![mk("hello")]; rec_b.input.prompt = vec![mk("hello"), mk("world")]; @@ -1898,10 +1927,7 @@ mod tests { let text = options .prompt .iter() - .filter_map(|m| match m.content.first() { - Some(crate::content::ContentPart::Text { text, .. }) => Some(text.clone()), - _ => None, - }) + .map(message_text) .collect::>() .join(" "); Ok(GenerateResult { @@ -1950,16 +1976,7 @@ mod tests { let rec = optioned_recording("hello"); let model = EchoModel::new(); let overrides = ReplayOverrides { - prompt: Some(vec![ - crate::language_model_message::LanguageModelPromptMessage { - role: crate::message::Role::User, - content: vec![crate::content::ContentPart::Text { - text: "overridden".into(), - provider_options: None, - }], - provider_options: None, - }, - ]), + prompt: Some(vec![LanguageModelMessage::user_text("overridden")]), ..Default::default() }; let rt = tokio::runtime::Runtime::new().unwrap(); diff --git a/aimux-core/src/session.rs b/aimux-core/src/session.rs index ead68fb6f..c152a16a4 100644 --- a/aimux-core/src/session.rs +++ b/aimux-core/src/session.rs @@ -452,22 +452,13 @@ fn civil_from_days(z: i64) -> (i64, u32, u32) { #[cfg(test)] mod tests { use super::*; - use crate::content::ContentPart; - use crate::language_model_message::LanguageModelPromptMessage; - use crate::message::Role; - - fn msg(text: &str) -> LanguageModelPromptMessage { - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::Text { - text: text.to_string(), - provider_options: None, - }], - provider_options: None, - } + use crate::language_model_message::LanguageModelMessage; + + fn msg(text: &str) -> LanguageModelMessage { + LanguageModelMessage::user_text(text) } - fn prompt(messages: Vec) -> LanguageModelPrompt { + fn prompt(messages: Vec) -> LanguageModelPrompt { messages } From 9804476b06eb23793ba8861c5ad6c0c4a471214d Mon Sep 17 00:00:00 2001 From: chenhaonan Date: Mon, 5 Oct 2026 11:51:21 +0800 Subject: [PATCH 03/10] refactor: follow the role-modelled provider prompt in vendors, tools and tests What: the vendor converters (OpenAI chat and Responses, Open Responses, Anthropic, Bedrock, Google, Cohere, Mistral, xAI, Hugging Face), the replay helpers, the web and replay tools and the remaining tests match on `LanguageModelMessage` and the per-role parts. The five file arms in each converter become one arm that matches `FilePart.data`; arms for role and part combinations the types rule out are deleted. TypeScript types are regenerated. Why: follow-up to the core change. Request JSON for a given prompt is unchanged; the recorded-cassette and upstream-sample tests pass without any change to cassettes or fixtures. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01WGwanvo9WLU9HWD7sRR8TS --- .../tests/http_recording_test.rs | 10 +- aimux-providers/src/anthropic/convert.rs | 610 ++++++++-------- aimux-providers/src/bedrock/convert.rs | 242 ++++--- aimux-providers/src/cohere/convert.rs | 131 ++-- aimux-providers/src/google/convert.rs | 196 ++--- aimux-providers/src/huggingface/responses.rs | 96 ++- aimux-providers/src/mistral/convert.rs | 143 ++-- aimux-providers/src/open_responses.rs | 186 ++--- aimux-providers/src/openai/convert.rs | 639 +++++------------ .../src/openai/responses/convert.rs | 174 ++--- aimux-providers/src/replay.rs | 9 +- aimux-providers/src/xai/convert.rs | 151 ++-- aimux-providers/src/xai/responses/convert.rs | 192 ++--- aimux-providers/tests/alibaba_test.rs | 31 +- aimux-providers/tests/baseten_test.rs | 10 +- .../tests/bedrock_stream_replay_test.rs | 12 +- aimux-providers/tests/cerebras_test.rs | 10 +- aimux-providers/tests/codex_test.rs | 10 +- aimux-providers/tests/conformance_test.rs | 10 +- aimux-providers/tests/convert_golden_test.rs | 10 +- .../tests/data_loss_regression_test.rs | 10 +- aimux-providers/tests/deepinfra_test.rs | 10 +- aimux-providers/tests/deepseek_chat_test.rs | 147 ++-- aimux-providers/tests/fireworks_test.rs | 10 +- aimux-providers/tests/groq_test.rs | 114 +-- .../tests/huggingface_responses_test.rs | 189 +++-- aimux-providers/tests/moonshotai_test.rs | 10 +- aimux-providers/tests/open_responses_test.rs | 667 ++++++++++-------- .../tests/openai_compatible_test.rs | 10 +- aimux-providers/tests/openrouter_test.rs | 10 +- aimux-providers/tests/perplexity_test.rs | 10 +- aimux-providers/tests/provider_error_test.rs | 10 +- aimux-providers/tests/reasoning_map_test.rs | 10 +- .../tests/thin_wrapper_config_test.rs | 10 +- .../tool_result_and_reasoning_fix_test.rs | 170 ++--- bindings/node/src/types.ts | 10 +- bindings/node/src/types/AssistantPart.ts | 11 + bindings/node/src/types/CallOptions.ts | 4 +- bindings/node/src/types/FilePart.ts | 8 + bindings/node/src/types/InputRecord.ts | 4 +- .../node/src/types/LanguageModelMessage.ts | 13 + .../src/types/LanguageModelPromptMessage.ts | 19 - bindings/node/src/types/ReasoningPart.ts | 7 + bindings/node/src/types/TextPart.ts | 7 + bindings/node/src/types/ToolCallPart.ts | 7 + bindings/node/src/types/ToolPart.ts | 7 + bindings/node/src/types/ToolResultPart.ts | 7 + bindings/node/src/types/UserPart.ts | 8 + tools/aimux-replay/src/main.rs | 27 +- tools/aimux-web/examples/gen_demo_fixtures.rs | 51 +- tools/aimux-web/examples/gen_fixture.rs | 17 +- tools/aimux-web/src/api/replay.rs | 2 +- tools/aimux-web/src/wire.rs | 16 +- 53 files changed, 2080 insertions(+), 2404 deletions(-) create mode 100644 bindings/node/src/types/AssistantPart.ts create mode 100644 bindings/node/src/types/FilePart.ts create mode 100644 bindings/node/src/types/LanguageModelMessage.ts delete mode 100644 bindings/node/src/types/LanguageModelPromptMessage.ts create mode 100644 bindings/node/src/types/ReasoningPart.ts create mode 100644 bindings/node/src/types/TextPart.ts create mode 100644 bindings/node/src/types/ToolCallPart.ts create mode 100644 bindings/node/src/types/ToolPart.ts create mode 100644 bindings/node/src/types/ToolResultPart.ts create mode 100644 bindings/node/src/types/UserPart.ts diff --git a/aimux-provider-utils/tests/http_recording_test.rs b/aimux-provider-utils/tests/http_recording_test.rs index f17933fce..4754a6efc 100644 --- a/aimux-provider-utils/tests/http_recording_test.rs +++ b/aimux-provider-utils/tests/http_recording_test.rs @@ -62,18 +62,12 @@ fn request(url: String, call_id: &str, recorder: Arc) -> HttpReque } fn finish_call(recorder: &dyn Recorder, call_id: &str) { - use aimux_core::content::ContentPart; - use aimux_core::language_model_message::LanguageModelPromptMessage; - use aimux_core::message::Role; + use aimux_core::language_model_message::LanguageModelMessage; use aimux_core::options::CallOptions; recorder.record_input( call_id, - &CallOptions::new(vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("hi")], - ..Default::default() - }]), + &CallOptions::new(vec![LanguageModelMessage::user_text("hi")]), "openai", "gpt-test", ); diff --git a/aimux-providers/src/anthropic/convert.rs b/aimux-providers/src/anthropic/convert.rs index e32efa386..29d897175 100644 --- a/aimux-providers/src/anthropic/convert.rs +++ b/aimux-providers/src/anthropic/convert.rs @@ -20,12 +20,13 @@ use std::collections::{BTreeSet, HashSet}; -use aimux_core::content::ContentPart; use aimux_core::error::AiMuxError; -use aimux_core::language_model_message::LanguageModelPrompt; -use aimux_core::message::Role; +use aimux_core::language_model_message::{ + AssistantPart, FilePart, LanguageModelMessage, LanguageModelPrompt, ReasoningPart, TextPart, + ToolCallPart, ToolPart, ToolResultPart, UserPart, +}; use aimux_core::options::{CallOptions, Tool}; -use aimux_core::shared::SharedProviderOptions; +use aimux_core::shared::{FileBytes, FileData, SharedProviderOptions}; use aimux_core::types::{FinishReason, FinishReasonUnified, ReasoningEffort, Warning}; use serde_json::{Map, Value, json}; @@ -133,129 +134,103 @@ pub fn convert_prompt_to_anthropic_full_with_tools( } for msg in prompt { - match msg.role { - Role::System => { - // A system message interrupts any in-flight user/assistant group. + let role = match msg { + LanguageModelMessage::System { .. } => None, + LanguageModelMessage::User { .. } | LanguageModelMessage::Tool { .. } => { + Some(Eff::User) + } + LanguageModelMessage::Assistant { .. } => Some(Eff::Assistant), + }; + if let Some(role) = role { + seen_non_system = true; + if last != Some(role) { flush(&mut messages, &mut last, &mut acc); - let n = msg.content.len(); - let mut blocks: Vec = Vec::new(); - for (idx, p) in msg.content.iter().enumerate() { - if let ContentPart::Text { - text, - provider_options, - } = p - { - let is_last = idx + 1 == n; - // cache_control: part-level first, then message-level - // for the last text part (system content is a string in - // the TS model, so message-level is the primary path). - let cache_control = match validator.get_cache_control( - provider_options.as_ref().and_then(|o| o.get("anthropic")), - "system message part", - true, - ) { - Some(v) => Some(v), - None => { - if is_last { - validator.get_cache_control( - msg.provider_options - .as_ref() - .and_then(|o| o.get("anthropic")), - "system message", - true, - ) - } else { - None - } - } - }; - let mut block = json!({ "type": "text", "text": text }); - if let Some(cc) = cache_control { - block["cache_control"] = cc; - } - blocks.push(block); - } - // Non-text parts on a system message are ignored. - } - + } + last = Some(role); + } + match msg { + LanguageModelMessage::System { + content, + provider_options, + } => { + flush(&mut messages, &mut last, &mut acc); + let cc = validator.get_cache_control( + provider_options.as_ref().and_then(|o| o.get("anthropic")), + "system message", + true, + ); + let blocks = vec![apply_cc(json!({ "type": "text", "text": content }), cc)]; if !seen_non_system { - // Initial system block(s) become the top-level system prompt. system.extend(blocks); } else { - // A mid-conversation system message is emitted inline. messages.push(json!({ "role": "system", "content": blocks })); betas.insert(BETA_MID_CONVERSATION_SYSTEM.to_string()); } } - Role::User | Role::Tool => { - seen_non_system = true; - if last != Some(Eff::User) { - flush(&mut messages, &mut last, &mut acc); + LanguageModelMessage::User { + content, + provider_options, + } => { + for (idx, part) in content.iter().enumerate() { + acc.push(match part { + UserPart::Text(TextPart { + text, + provider_options: part_options, + }) => { + let cc = resolve_cache_control( + &mut validator, + part_options.as_ref(), + provider_options.as_ref(), + idx + 1 == content.len(), + "user message part", + "user message", + ); + apply_cc(json!({ "type": "text", "text": text }), cc) + } + UserPart::File(file) => convert_file_part( + file, + &mut betas, + &mut validator, + provider_options.as_ref(), + idx + 1 == content.len(), + "user message part", + "user message", + )?, + }); + } + } + LanguageModelMessage::Tool { + content, + provider_options, + } => { + for (idx, ToolPart::ToolResult(part)) in content.iter().enumerate() { + acc.push(convert_tool_result( + part, + &mut validator, + idx + 1 == content.len(), + provider_options.as_ref(), + )); } - let (part_ctx, msg_ctx) = match msg.role { - Role::Tool => ("tool result part", "tool result message"), - _ => ("user message part", "user message"), - }; - let n = msg.content.len(); - for (idx, p) in msg.content.iter().enumerate() { - let is_last_part = idx + 1 == n; - if let Some(block) = convert_part_to_anthropic( - p, + } + LanguageModelMessage::Assistant { + content, + provider_options, + } => { + for (idx, part) in content.iter().enumerate() { + if let Some(block) = convert_assistant_part( + part, send_reasoning, tool_names, + &mcp_tool_use_ids, &mut betas, &mut warnings, &mut validator, - is_last_part, - msg.provider_options.as_ref(), - part_ctx, - msg_ctx, + idx + 1 == content.len(), + provider_options.as_ref(), )? { acc.push(block); } } - last = Some(Eff::User); - } - Role::Assistant => { - seen_non_system = true; - if last != Some(Eff::Assistant) { - flush(&mut messages, &mut last, &mut acc); - } - let n = msg.content.len(); - for (idx, p) in msg.content.iter().enumerate() { - let is_last_part = idx + 1 == n; - // Assistant-role tool results are provider-executed results; - // Anthropic only accepts a bare `tool_result` in a *user* - // message, so they take a dedicated path. - let block = if let ContentPart::ToolResult { .. } = p { - convert_assistant_tool_result( - p, - tool_names, - &mcp_tool_use_ids, - &mut warnings, - &mut validator, - is_last_part, - msg.provider_options.as_ref(), - ) - } else { - convert_part_to_anthropic( - p, - send_reasoning, - tool_names, - &mut betas, - &mut warnings, - &mut validator, - is_last_part, - msg.provider_options.as_ref(), - "assistant message part", - "assistant message", - )? - }; - if let Some(block) = block { - acc.push(block); - } - } - last = Some(Eff::Assistant); } } } @@ -341,166 +316,231 @@ fn top_level_media_type(media_type: &str) -> &str { media_type.split('/').next().unwrap_or("") } -/// Convert a single content part into an Anthropic content block. -/// -/// Returns `None` when the part should be omitted entirely (e.g. a reasoning -/// part that is dropped). `betas` and `warnings` are appended to as side -/// effects. -/// -/// `cache_control` is resolved from the part's `provider_options.anthropic -/// .cacheControl`, falling back to the message-level `provider_options` for the -/// last part of a message (mirroring the TS SDK). Thinking blocks reject -/// cache_control (validated with a warning). -#[allow(clippy::too_many_arguments)] -fn convert_part_to_anthropic( - part: &ContentPart, - send_reasoning: bool, - tool_names: &ToolNameMapping, - betas: &mut BTreeSet, - warnings: &mut Vec, +fn resolve_cache_control( validator: &mut CacheControlValidator, + part_options: Option<&SharedProviderOptions>, + message_options: Option<&SharedProviderOptions>, is_last_part: bool, - message_provider_options: Option<&SharedProviderOptions>, - part_context_type: &str, - message_context_type: &str, -) -> Result, AiMuxError> { - // Resolve cache_control = part-level ?? (is_last_part ? message-level). - let resolve_cc = |validator: &mut CacheControlValidator, - part_opts: Option<&SharedProviderOptions>| { - match validator.get_cache_control( - part_opts.and_then(|o| o.get("anthropic")), - part_context_type, + part_context: &str, + message_context: &str, +) -> Option { + validator + .get_cache_control( + part_options.and_then(|o| o.get("anthropic")), + part_context, true, - ) { - Some(v) => Some(v), - None => { - if is_last_part { - validator.get_cache_control( - message_provider_options.and_then(|o| o.get("anthropic")), - message_context_type, - true, - ) - } else { - None - } - } - } - }; - - let apply_cc = |block: Value, cc: Option| -> Value { - match cc { - Some(c) => { - let mut b = block; - b["cache_control"] = c; - b + ) + .or_else(|| { + if is_last_part { + validator.get_cache_control( + message_options.and_then(|o| o.get("anthropic")), + message_context, + true, + ) + } else { + None } - None => block, - } - }; - - Ok(Some(match part { - ContentPart::Text { - text, - provider_options, - } => { - let cc = resolve_cc(validator, provider_options.as_ref()); - apply_cc(json!({ "type": "text", "text": text }), cc) - } + }) +} - ContentPart::Image { - image, - media_type, - provider_options, - } => { - use base64::Engine; - let b64 = base64::engine::general_purpose::STANDARD.encode(image); - let cc = resolve_cc(validator, provider_options.as_ref()); - apply_cc( - json!({ - "type": "image", - "source": { - "type": "base64", - "media_type": media_type, - "data": b64, - } - }), - cc, - ) - } +fn apply_cc(mut block: Value, cc: Option) -> Value { + if let Some(cc) = cc { + block["cache_control"] = cc; + } + block +} - ContentPart::File { - data, - media_type, - filename, - provider_options, +fn convert_file_part( + file: &FilePart, + betas: &mut BTreeSet, + validator: &mut CacheControlValidator, + message_options: Option<&SharedProviderOptions>, + is_last_part: bool, + part_context: &str, + message_context: &str, +) -> Result { + let FilePart { + data, + media_type, + filename, + provider_options, + } = file; + let block = match data { + FileData::Data { + data: FileBytes::Binary(bytes), } => { - let full = resolve_full_media_type(media_type, data)?; - let block = route_file_bytes(&full, data, filename.as_deref(), betas)?; - let cc = resolve_cc(validator, provider_options.as_ref()); - apply_cc(block, cc) + let full = resolve_full_media_type(media_type, bytes)?; + route_file_bytes(&full, bytes, filename.as_deref(), betas)? } - - ContentPart::FileBase64 { - data, - media_type, - filename, - provider_options, + FileData::Data { + data: FileBytes::Base64(data), } => { use base64::Engine; let bytes = base64::engine::general_purpose::STANDARD .decode(data) .unwrap_or_default(); let full = resolve_full_media_type(media_type, &bytes)?; - let block = route_file_base64(&full, data, &bytes, filename.as_deref(), betas)?; - let cc = resolve_cc(validator, provider_options.as_ref()); - apply_cc(block, cc) - } - - ContentPart::FileUrl { - url, - media_type, - provider_options, - } => { - let block = route_file_url(media_type, url, betas)?; - let cc = resolve_cc(validator, provider_options.as_ref()); - apply_cc(block, cc) + route_file_base64(&full, data, &bytes, filename.as_deref(), betas)? } - - ContentPart::FileReference { - media_type, - reference, - provider_options, - .. - } => { + FileData::Url { url } => route_file_url(media_type, url, betas)?, + FileData::Reference { reference } => { let file_id = resolve_anthropic_reference(reference)?; betas.insert(BETA_FILES_API.to_string()); let container_upload = provider_options .as_ref() .and_then(|o| o.get("anthropic")) .and_then(|a| a.get("containerUpload")) - .and_then(serde_json::Value::as_bool) + .and_then(Value::as_bool) .unwrap_or(false); - let block = if container_upload { - json!({ "type": "container_upload", "file_id": file_id }) + if container_upload { + return Ok(json!({ "type": "container_upload", "file_id": file_id })); } else if top_level_media_type(media_type) == "image" { json!({ "type": "image", "source": { "type": "file", "file_id": file_id } }) } else { json!({ "type": "document", "source": { "type": "file", "file_id": file_id } }) - }; - // container_upload blocks do not carry cache_control in the TS SDK. - let cc = if container_upload { - None - } else { - resolve_cc(validator, provider_options.as_ref()) - }; - apply_cc(block, cc) + } + } + FileData::Text { text } => { + let options = provider_options.as_ref().and_then(|o| o.get("anthropic")); + let mut block = json!({ "type": "document", "source": { + "type": "text", "media_type": "text/plain", "data": text, + }}); + if let Some(title) = options + .and_then(|o| o.get("title")) + .and_then(Value::as_str) + .or(filename.as_deref()) + { + block["title"] = json!(title); + } + if let Some(context) = options + .and_then(|o| o.get("context")) + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + { + block["context"] = json!(context); + } + if options + .and_then(|o| o.get("citations")) + .and_then(|c| c.get("enabled")) + .and_then(Value::as_bool) + == Some(true) + { + block["citations"] = json!({ "enabled": true }); + } + block + } + }; + let cc = resolve_cache_control( + validator, + provider_options.as_ref(), + message_options, + is_last_part, + part_context, + message_context, + ); + Ok(apply_cc(block, cc)) +} + +fn convert_tool_result( + part: &ToolResultPart, + validator: &mut CacheControlValidator, + is_last_part: bool, + message_provider_options: Option<&SharedProviderOptions>, +) -> Value { + let ToolResultPart { + tool_call_id, + result, + provider_options, + .. + } = part; + + let (content, is_error) = resolve_tool_result_output(result); + let mut block = json!({ + "type": "tool_result", + "tool_use_id": tool_call_id, + "content": content, + }); + if is_error { + block["is_error"] = json!(true); + } + // cache_control: part ?? output ?? (is_last_part ? message). + let cc = match validator.get_cache_control( + provider_options.as_ref().and_then(|o| o.get("anthropic")), + "tool result part", + true, + ) { + Some(v) => Some(v), + None => match extract_tool_result_output_provider_options(result) { + Some(out_opts) => validator.get_cache_control( + out_opts.get("anthropic").and_then(Value::as_object), + "tool result output", + true, + ), + None => { + if is_last_part { + validator.get_cache_control( + message_provider_options.and_then(|o| o.get("anthropic")), + "tool result message", + true, + ) + } else { + None + } + } + }, + }; + apply_cc(block, cc) +} + +/// Convert an assistant part, preserving reasoning and provider-executed tool results. +/// Part-level cache control falls back to message options on the final part. +#[allow(clippy::too_many_arguments)] +fn convert_assistant_part( + part: &AssistantPart, + send_reasoning: bool, + tool_names: &ToolNameMapping, + mcp_tool_use_ids: &HashSet<&str>, + betas: &mut BTreeSet, + warnings: &mut Vec, + validator: &mut CacheControlValidator, + is_last_part: bool, + message_provider_options: Option<&SharedProviderOptions>, +) -> Result, AiMuxError> { + let resolve_cc = |validator: &mut CacheControlValidator, part_opts| { + resolve_cache_control( + validator, + part_opts, + message_provider_options, + is_last_part, + "assistant message part", + "assistant message", + ) + }; + + Ok(Some(match part { + AssistantPart::Text(TextPart { + text, + provider_options, + }) => { + let cc = resolve_cc(validator, provider_options.as_ref()); + apply_cc(json!({ "type": "text", "text": text }), cc) } - ContentPart::Reasoning { + AssistantPart::File(file) => convert_file_part( + file, + betas, + validator, + message_provider_options, + is_last_part, + "assistant message part", + "assistant message", + )?, + + AssistantPart::Reasoning(ReasoningPart { text, signature, provider_options, - } => { + }) => { return Ok(convert_reasoning_part( text, signature.as_deref(), @@ -511,14 +551,14 @@ fn convert_part_to_anthropic( )); } - ContentPart::ToolCall { + AssistantPart::ToolCall(ToolCallPart { tool_call_id, tool_name, input, provider_executed, provider_options, .. - } => { + }) => { let cc = resolve_cc(validator, provider_options.as_ref()); if *provider_executed == Some(true) { @@ -635,48 +675,16 @@ fn convert_part_to_anthropic( apply_cc(block, cc) } - ContentPart::ToolResult { - tool_call_id, - result, - provider_options, - .. - } => { - let (content, is_error) = resolve_tool_result_output(result); - let mut block = json!({ - "type": "tool_result", - "tool_use_id": tool_call_id, - "content": content, - }); - if is_error { - block["is_error"] = json!(true); - } - // cache_control: part ?? output ?? (is_last_part ? message). - let cc = match validator.get_cache_control( - provider_options.as_ref().and_then(|o| o.get("anthropic")), - part_context_type, - true, - ) { - Some(v) => Some(v), - None => match extract_tool_result_output_provider_options(result) { - Some(out_opts) => validator.get_cache_control( - out_opts.get("anthropic").and_then(Value::as_object), - "tool result output", - true, - ), - None => { - if is_last_part { - validator.get_cache_control( - message_provider_options.and_then(|o| o.get("anthropic")), - message_context_type, - true, - ) - } else { - None - } - } - }, - }; - apply_cc(block, cc) + AssistantPart::ToolResult(part) => { + return Ok(convert_assistant_tool_result( + part, + tool_names, + mcp_tool_use_ids, + warnings, + validator, + is_last_part, + message_provider_options, + )); } })) } @@ -692,15 +700,15 @@ fn convert_part_to_anthropic( fn collect_mcp_tool_use_ids(prompt: &LanguageModelPrompt) -> HashSet<&str> { let mut ids = HashSet::new(); for msg in prompt { - if msg.role != Role::Assistant { + let LanguageModelMessage::Assistant { content, .. } = msg else { continue; - } - for part in &msg.content { - if let ContentPart::ToolCall { + }; + for part in content { + if let AssistantPart::ToolCall(ToolCallPart { tool_call_id, provider_options: Some(opts), .. - } = part + }) = part && opts .get("anthropic") .and_then(|a| a.get("type")) @@ -733,7 +741,7 @@ fn result_error_code(value: &Value, fallback: &str) -> String { .to_string() } -/// Convert an assistant-role `ContentPart::ToolResult` into the matching +/// Convert an assistant-role `ToolResultPart` into the matching /// Anthropic provider-executed result block. /// /// Anthropic only accepts a bare `tool_result` block inside a **user** message; @@ -745,7 +753,7 @@ fn result_error_code(value: &Value, fallback: &str) -> String { /// /// Returns `None` when the part is skipped. fn convert_assistant_tool_result( - part: &ContentPart, + part: &ToolResultPart, tool_names: &ToolNameMapping, mcp_tool_use_ids: &HashSet<&str>, warnings: &mut Vec, @@ -753,17 +761,14 @@ fn convert_assistant_tool_result( is_last_part: bool, message_provider_options: Option<&SharedProviderOptions>, ) -> Option { - let ContentPart::ToolResult { + let ToolResultPart { tool_call_id, tool_name, result, is_error, provider_options, .. - } = part - else { - return None; - }; + } = part; // cache_control: part ?? output ?? (is_last_part ? message) — the same // resolution order the bare `tool_result` path uses. @@ -1302,23 +1307,22 @@ fn convert_reasoning_part( } /// Resolve the Anthropic file id from a provider-reference object, mirroring the -/// TS `resolveProviderReference`. Panics when no `anthropic` key is present, -/// matching the TS `UnsupportedFunctionalityError`. -fn resolve_anthropic_reference(reference: &Value) -> Result { - if let Some(id) = reference.get("anthropic").and_then(|v| v.as_str()) { +/// TS `resolveProviderReference`. Returns an error when the key is missing. +fn resolve_anthropic_reference( + reference: &std::collections::HashMap, +) -> Result { + if let Some(id) = reference.get("anthropic") { return Ok(id.to_string()); } - let providers: Vec<&str> = reference - .as_object() - .map(|o| o.keys().map(String::as_str).collect()) - .unwrap_or_default(); + let mut providers: Vec<&str> = reference.keys().map(String::as_str).collect(); + providers.sort_unstable(); Err(AiMuxError::InvalidArgument(format!( "No provider reference found for provider 'anthropic'. Available providers: {}", providers.join(", ") ))) } -/// Resolve a `ContentPart::ToolResult` `output` value into the Anthropic +/// Resolve a `ToolResultPart` `output` value into the Anthropic /// `tool_result.content` (and whether it is an error), mirroring the TS SDK. /// /// The V4 `tool-result` `output` is a discriminated `{ type, value }` object: diff --git a/aimux-providers/src/bedrock/convert.rs b/aimux-providers/src/bedrock/convert.rs index f01122eea..c73fbd936 100644 --- a/aimux-providers/src/bedrock/convert.rs +++ b/aimux-providers/src/bedrock/convert.rs @@ -14,11 +14,12 @@ //! - Consecutive same-role messages are merged into a single message (matching //! the TS `groupIntoBlocks` behaviour). -use aimux_core::content::ContentPart; -use aimux_core::language_model_message::LanguageModelPrompt; -use aimux_core::message::Role; +use aimux_core::language_model_message::{ + AssistantPart, FilePart, LanguageModelMessage, LanguageModelPrompt, ReasoningPart, TextPart, + ToolCallPart, ToolPart, UserPart, +}; use aimux_core::options::{CallOptions, ToolChoice}; -use aimux_core::shared::SharedProviderOptions; +use aimux_core::shared::{FileBytes, FileData, SharedProviderOptions}; use aimux_core::tool::{FunctionTool, Tool}; use aimux_core::types::{FinishReason, FinishReasonUnified}; use base64::Engine; @@ -47,10 +48,10 @@ pub fn convert_prompt_to_bedrock(prompt: &LanguageModelPrompt) -> (Vec, V // Group consecutive same-block messages (user+tool fold into User). let mut blocks: Vec<(Blk, Vec)> = Vec::new(); for (i, msg) in prompt.iter().enumerate() { - let b = match msg.role { - Role::System => Blk::System, - Role::User | Role::Tool => Blk::User, - Role::Assistant => Blk::Assistant, + let b = match msg { + LanguageModelMessage::System { .. } => Blk::System, + LanguageModelMessage::User { .. } | LanguageModelMessage::Tool { .. } => Blk::User, + LanguageModelMessage::Assistant { .. } => Blk::Assistant, }; if blocks.last().map(|(lb, _)| *lb) == Some(b) { blocks.last_mut().unwrap().1.push(i); @@ -69,26 +70,59 @@ pub fn convert_prompt_to_bedrock(prompt: &LanguageModelPrompt) -> (Vec, V match blk { Blk::System => { for &i in idxs { - let msg = &prompt[i]; - for p in &msg.content { - if let ContentPart::Text { text, .. } = p { - system.push(json!({ "text": text })); - } - if let Some(cp) = part_cache_point(p) { - system.push(cp); - } + let LanguageModelMessage::System { + content, + provider_options, + } = &prompt[i] + else { + unreachable!() + }; + system.push(json!({ "text": content })); + if let Some(cp) = cache_point(provider_options) { + system.push(cp); } } } Blk::User => { let mut content: Vec = Vec::new(); for &i in idxs { - let msg = &prompt[i]; - for p in &msg.content { - push_user_part(p, &mut content, &mut document_counter); - if let Some(cp) = part_cache_point(p) { - content.push(cp); + match &prompt[i] { + LanguageModelMessage::User { content: parts, .. } => { + for part in parts { + let provider_options = match part { + UserPart::Text(part) => { + content.push(json!({ "text": part.text })); + &part.provider_options + } + UserPart::File(file) => { + if !push_file_part( + file, + &mut content, + &mut document_counter, + ) { + continue; + } + &file.provider_options + } + }; + if let Some(cp) = cache_point(provider_options) { + content.push(cp); + } + } + } + LanguageModelMessage::Tool { content: parts, .. } => { + for ToolPart::ToolResult(part) in parts { + let result_content = + resolve_tool_result_output(&part.result, &mut document_counter); + content.push(json!({ + "toolResult": { + "toolUseId": part.tool_call_id, + "content": result_content, + } + })); + } } + _ => unreachable!(), } } messages.push(json!({ "role": "user", "content": content })); @@ -98,20 +132,19 @@ pub fn convert_prompt_to_bedrock(prompt: &LanguageModelPrompt) -> (Vec, V let num_msgs = idxs.len(); for (mj, &i) in idxs.iter().enumerate() { let is_last_message = mj == num_msgs - 1; - let msg = &prompt[i]; - let has_reasoning = msg - .content + let LanguageModelMessage::Assistant { content: parts, .. } = &prompt[i] else { + unreachable!() + }; + let has_reasoning = parts .iter() - .any(|p| matches!(p, ContentPart::Reasoning { .. })); - let num_parts = msg.content.len(); - for (kj, p) in msg.content.iter().enumerate() { + .any(|p| matches!(p, AssistantPart::Reasoning(_))); + let num_parts = parts.len(); + for (kj, p) in parts.iter().enumerate() { let is_last_content_part = kj == num_parts - 1; match p { - ContentPart::Text { text, .. } => { + AssistantPart::Text(TextPart { text, .. }) => { // Skip empty text unless the message has reasoning. - if text.trim().is_empty() && !has_reasoning { - // skipped - } else { + if !text.trim().is_empty() || has_reasoning { let t = if is_last_block && is_last_message && is_last_content_part { @@ -122,11 +155,11 @@ pub fn convert_prompt_to_bedrock(prompt: &LanguageModelPrompt) -> (Vec, V content.push(json!({ "text": t })); } } - ContentPart::Reasoning { + AssistantPart::Reasoning(ReasoningPart { text, signature: Some(sig), .. - } => { + }) => { // Only signed reasoning is replayed; unsigned // reasoning is intentionally omitted. content.push(json!({ @@ -138,13 +171,12 @@ pub fn convert_prompt_to_bedrock(prompt: &LanguageModelPrompt) -> (Vec, V } })); } - ContentPart::Reasoning { .. } => {} - ContentPart::ToolCall { + AssistantPart::ToolCall(ToolCallPart { tool_call_id, tool_name, input, .. - } => { + }) => { let input_val = if input.is_object() { input.clone() } else { @@ -160,7 +192,19 @@ pub fn convert_prompt_to_bedrock(prompt: &LanguageModelPrompt) -> (Vec, V } _ => {} } - if let Some(cp) = part_cache_point(p) { + let provider_options = match p { + AssistantPart::Text(part) => &part.provider_options, + AssistantPart::File(part) + if matches!( + part.data, + FileData::Data { .. } | FileData::Text { .. } + ) => + { + &part.provider_options + } + _ => continue, + }; + if let Some(cp) = cache_point(provider_options) { content.push(cp); } } @@ -173,66 +217,39 @@ pub fn convert_prompt_to_bedrock(prompt: &LanguageModelPrompt) -> (Vec, V (system, messages) } -/// Push a single user/tool content part into a Bedrock content array. -fn push_user_part(part: &ContentPart, content: &mut Vec, doc_counter: &mut u32) { - match part { - ContentPart::Text { text, .. } => { - content.push(json!({ "text": text })); - } - ContentPart::Image { - image, media_type, .. - } => { - let b64 = base64::engine::general_purpose::STANDARD.encode(image); - push_file_block(&b64, media_type, None, &None, content, doc_counter); - } - ContentPart::File { - data, - media_type, - filename, - provider_options, - } => { - let b64 = base64::engine::general_purpose::STANDARD.encode(data); - push_file_block( - &b64, - media_type, - filename.as_deref(), - provider_options, - content, - doc_counter, - ); - } - ContentPart::FileBase64 { - data, - media_type, - filename, - provider_options, - } => { - // `data` is already a base64 string — use verbatim as `bytes`. - push_file_block( - data, - media_type, - filename.as_deref(), - provider_options, - content, - doc_counter, - ); - } - ContentPart::ToolResult { - tool_call_id, - result, - .. - } => { - let result_content = resolve_tool_result_output(result, doc_counter); - content.push(json!({ - "toolResult": { - "toolUseId": tool_call_id, - "content": result_content, - } - })); - } - // Variants not yet modelled for Bedrock; no test exercises these paths. - _ => {} - } +/// Push a supported file into a Bedrock content array. +fn push_file_part(file: &FilePart, content: &mut Vec, doc_counter: &mut u32) -> bool { + let b64 = match &file.data { + FileData::Data { + data: FileBytes::Binary(bytes), + } => base64::engine::general_purpose::STANDARD.encode(bytes), + FileData::Data { + data: FileBytes::Base64(data), + } => data.clone(), + FileData::Text { text } => base64::engine::general_purpose::STANDARD.encode(text), + FileData::Url { .. } | FileData::Reference { .. } => return false, + }; + let is_text = matches!(file.data, FileData::Text { .. }); + let media_type = if is_text + && !file + .media_type + .split_once('/') + .is_some_and(|(_, subtype)| !subtype.is_empty() && subtype != "*") + { + "text/plain" + } else { + &file.media_type + }; + push_file_block( + &b64, + media_type, + file.filename.as_deref(), + &file.provider_options, + is_text, + content, + doc_counter, + ); + true } /// Build a Bedrock `image` or `document` block from an already-base64 `bytes` @@ -245,11 +262,12 @@ fn push_file_block( media_type: &str, filename: Option<&str>, provider_options: &Option, + is_text: bool, content: &mut Vec, doc_counter: &mut u32, ) { let top_level = media_type.split('/').next().unwrap_or(""); - if top_level == "image" { + if top_level == "image" && !is_text { let format = mime_to_image_format(media_type); content.push(json!({ "image": { "format": format, "source": { "bytes": b64 } } @@ -276,22 +294,8 @@ fn push_file_block( /// Extract a `{ cachePoint: {...} }` block from a part's `providerOptions` /// (`bedrock.cachePoint` or `amazonBedrock.cachePoint`), if present. -fn part_cache_point(part: &ContentPart) -> Option { - let po = match part { - ContentPart::Text { - provider_options, .. - } - | ContentPart::Image { - provider_options, .. - } - | ContentPart::File { - provider_options, .. - } - | ContentPart::FileBase64 { - provider_options, .. - } => provider_options.as_ref()?, - _ => return None, - }; +fn cache_point(provider_options: &Option) -> Option { + let po = provider_options.as_ref()?; for key in ["bedrock", "amazonBedrock"] { if let Some(cp) = po.get(key).and_then(|v| v.get("cachePoint")) { return Some(json!({ "cachePoint": cp.clone() })); @@ -368,7 +372,15 @@ fn convert_tool_result_content_part(part: &Value, content: &mut Vec, doc_ .and_then(|d| d.as_str()) .unwrap_or(""); let filename = part.get("filename").and_then(|f| f.as_str()); - push_file_block(b64, media_type, filename, &None, content, doc_counter); + push_file_block( + b64, + media_type, + filename, + &None, + false, + content, + doc_counter, + ); } _ => { content.push(json!({ "text": part.to_string() })); diff --git a/aimux-providers/src/cohere/convert.rs b/aimux-providers/src/cohere/convert.rs index cf546feb4..120986cc5 100644 --- a/aimux-providers/src/cohere/convert.rs +++ b/aimux-providers/src/cohere/convert.rs @@ -3,11 +3,12 @@ //! Mirrors the TS `convert-to-cohere-chat-prompt.ts`, //! `cohere-prepare-tools.ts`, and `map-cohere-finish-reason.ts`. -use aimux_core::content::ContentPart; -use aimux_core::language_model_message::LanguageModelPrompt; -use aimux_core::message::Role; +use aimux_core::language_model_message::{ + AssistantPart, LanguageModelMessage, LanguageModelPrompt, TextPart, ToolCallPart, ToolPart, + ToolResultPart, UserPart, +}; use aimux_core::options::{CallOptions, ResponseFormat, ToolChoice}; -use aimux_core::shared::SharedProviderOptions; +use aimux_core::shared::{FileBytes, FileData, SharedProviderOptions}; use aimux_core::tool::Tool; use aimux_core::types::{FinishReason, FinishReasonUnified, ReasoningEffort, Warning}; use serde_json::{Value, json}; @@ -125,66 +126,60 @@ pub fn convert_prompt_to_cohere(prompt: &LanguageModelPrompt) -> ConvertedPrompt let mut documents = Vec::new(); for msg in prompt { - match msg.role { - Role::System => { - let text = join_text_parts(&msg.content); - messages.push(json!({ "role": "system", "content": text })); + match msg { + LanguageModelMessage::System { content, .. } => { + messages.push(json!({ "role": "system", "content": content })); } - Role::User => { + LanguageModelMessage::User { content, .. } => { let mut parts: Vec = Vec::new(); let mut has_image = false; - for part in &msg.content { + for part in content { match part { - ContentPart::Text { text, .. } => { + UserPart::Text(TextPart { text, .. }) => { if !text.is_empty() { parts.push(json!({ "type": "text", "text": text })); } } - ContentPart::Image { - image, media_type, .. - } => { - has_image = true; + UserPart::File(file) => { use base64::Engine; - let b64 = base64::engine::general_purpose::STANDARD.encode(image); - parts.push(json!({ - "type": "image_url", - "image_url": { - "url": format!("data:{};base64,{}", media_type, b64), - } - })); - } - ContentPart::File { - data, - media_type, - filename, - .. - } => { - let top_level = media_type.split('/').next().unwrap_or(""); - if top_level == "image" { - has_image = true; - use base64::Engine; - let b64 = base64::engine::general_purpose::STANDARD.encode(data); - parts.push(json!({ - "type": "image_url", - "image_url": { - "url": format!("data:{};base64,{}", media_type, b64), + if file.media_type.starts_with("image/") { + let url = match &file.data { + FileData::Data { data } => { + let b64 = match data { + FileBytes::Binary(bytes) => { + base64::engine::general_purpose::STANDARD + .encode(bytes) + } + FileBytes::Base64(data) => data.clone(), + }; + format!("data:{};base64,{}", file.media_type, b64) } - })); + FileData::Url { url } => url.clone(), + FileData::Reference { .. } | FileData::Text { .. } => continue, + }; + has_image = true; + parts.push( + json!({ "type": "image_url", "image_url": { "url": url } }), + ); } else { - // Non-image files become RAG documents. - // Mirrors TS: `documents.push({ data: { text, title: - // part.filename } })` — `title` is only present when a - // filename is supplied. - let text = String::from_utf8_lossy(data).to_string(); + let text = match &file.data { + FileData::Data { + data: FileBytes::Binary(bytes), + } => String::from_utf8_lossy(bytes).into_owned(), + FileData::Data { + data: FileBytes::Base64(data), + } => data.clone(), + FileData::Text { text } => text.clone(), + FileData::Url { .. } | FileData::Reference { .. } => continue, + }; let mut doc_data = json!({ "text": text }); - if let Some(fname) = filename { - doc_data["title"] = json!(fname); + if let Some(filename) = &file.filename { + doc_data["title"] = json!(filename); } documents.push(json!({ "data": doc_data })); } } - _ => {} } } @@ -207,24 +202,22 @@ pub fn convert_prompt_to_cohere(prompt: &LanguageModelPrompt) -> ConvertedPrompt messages.push(json!({ "role": "user", "content": text })); } } - Role::Assistant => { - let text = join_text_parts(&msg.content); - let has_tool_calls = msg - .content + LanguageModelMessage::Assistant { content, .. } => { + let text = join_text_parts(content); + let has_tool_calls = content .iter() - .any(|p| matches!(p, ContentPart::ToolCall { .. })); + .any(|p| matches!(p, AssistantPart::ToolCall(_))); if has_tool_calls { - let tool_calls: Vec = msg - .content + let tool_calls: Vec = content .iter() .filter_map(|p| match p { - ContentPart::ToolCall { + AssistantPart::ToolCall(ToolCallPart { tool_call_id, tool_name, input, .. - } => { + }) => { let arguments = if input.is_null() { "{}".to_string() } else { @@ -250,21 +243,19 @@ pub fn convert_prompt_to_cohere(prompt: &LanguageModelPrompt) -> ConvertedPrompt messages.push(json!({ "role": "assistant", "content": text })); } } - Role::Tool => { - for part in &msg.content { - if let ContentPart::ToolResult { + LanguageModelMessage::Tool { content, .. } => { + for part in content { + let ToolPart::ToolResult(ToolResultPart { tool_call_id, result, .. - } = part - { - let content = tool_result_to_content(result); - messages.push(json!({ - "role": "tool", - "content": content, - "tool_call_id": tool_call_id, - })); - } + }) = part; + let content = tool_result_to_content(result); + messages.push(json!({ + "role": "tool", + "content": content, + "tool_call_id": tool_call_id, + })); } } } @@ -276,11 +267,11 @@ pub fn convert_prompt_to_cohere(prompt: &LanguageModelPrompt) -> ConvertedPrompt } } -fn join_text_parts(content: &[ContentPart]) -> String { +fn join_text_parts(content: &[AssistantPart]) -> String { content .iter() .filter_map(|p| match p { - ContentPart::Text { text, .. } => Some(text.as_str()), + AssistantPart::Text(TextPart { text, .. }) => Some(text.as_str()), _ => None, }) .collect::>() diff --git a/aimux-providers/src/google/convert.rs b/aimux-providers/src/google/convert.rs index 0bdb54b03..b706624a3 100644 --- a/aimux-providers/src/google/convert.rs +++ b/aimux-providers/src/google/convert.rs @@ -18,12 +18,13 @@ //! fields we actually read back are `text`, function tool parts, and native //! provider-executed tool parts. -use aimux_core::content::ContentPart; -use aimux_core::language_model_message::LanguageModelPrompt; -use aimux_core::message::Role; +use aimux_core::language_model_message::{ + AssistantPart, FilePart, LanguageModelMessage, LanguageModelPrompt, ReasoningPart, TextPart, + ToolCallPart, ToolPart, ToolResultPart, UserPart, +}; use aimux_core::options::{CallOptions, ResponseFormat, ToolChoice}; use aimux_core::result::{GenerateContent, Source}; -use aimux_core::shared::{JsonObject, SharedProviderOptions}; +use aimux_core::shared::{FileBytes, FileData, JsonObject, SharedProviderOptions}; use aimux_core::tool::{FunctionTool, Tool}; use aimux_core::types::{FinishReason, FinishReasonUnified, Warning}; use base64::Engine; @@ -101,7 +102,7 @@ fn read_provider_options( /// as a string (JSON-stringified for non-string outputs, matching the TS /// `output.type === 'json'` path). /// -/// Thought signatures: `ContentPart::ToolCall.thought_signature` is echoed +/// Thought signatures: `ToolCallPart.thought_signature` is echoed /// back as a `thoughtSignature` sibling of the `functionCall` part (required /// by Gemini thinking models on follow-up turns). #[must_use] @@ -118,8 +119,8 @@ fn convert_to_google_messages_for_namespace( let mut system_messages_allowed = true; for msg in prompt { - match msg.role { - Role::System => { + match msg { + LanguageModelMessage::System { content, .. } => { if !system_messages_allowed { // The TS SDK throws `UnsupportedFunctionalityError` here: // system messages are only valid at the start of the @@ -131,31 +132,27 @@ fn convert_to_google_messages_for_namespace( // TODO: change the signature to `Result` to match TS semantics. continue; } - for part in &msg.content { - if let ContentPart::Text { text, .. } = part { - system_parts.push(json!({ "text": text })); - } - } + system_parts.push(json!({ "text": content })); } - Role::User => { + LanguageModelMessage::User { content, .. } => { system_messages_allowed = false; - let parts = convert_user_parts(&msg.content); + let parts = convert_user_parts(content, namespace); contents.push(json!({ "role": "user", "parts": parts })); } - Role::Assistant => { + LanguageModelMessage::Assistant { content, .. } => { system_messages_allowed = false; - let parts = convert_assistant_parts(&msg.content, namespace); + let parts = convert_assistant_parts(content, namespace); if !parts.is_empty() { contents.push(json!({ "role": "model", "parts": parts })); } } - Role::Tool => { + LanguageModelMessage::Tool { content, .. } => { system_messages_allowed = false; // Gemini folds tool results into a `user`-role message as // `functionResponse` parts. We emit them as their own user // turn (matching how the TS SDK pushes a new `{role:'user'}` // entry per tool-role message). - let parts = convert_tool_parts(&msg.content); + let parts = convert_tool_parts(content); if !parts.is_empty() { contents.push(json!({ "role": "user", "parts": parts })); } @@ -176,32 +173,53 @@ fn convert_to_google_messages_for_namespace( } /// Convert user-role content parts into Google parts. -fn convert_user_parts(content: &[ContentPart]) -> Vec { +fn convert_user_parts(content: &[UserPart], namespace: ProviderMetadataNamespace) -> Vec { let mut parts = Vec::new(); for part in content { match part { - ContentPart::Text { text, .. } => { + UserPart::Text(TextPart { text, .. }) => { parts.push(json!({ "text": text })); } - ContentPart::Image { - image, media_type, .. - } => { - let b64 = base64::engine::general_purpose::STANDARD.encode(image); - parts.push(json!({ - "inlineData": { "mimeType": media_type, "data": b64 } - })); - } - ContentPart::File { + UserPart::File(FilePart { data, media_type, .. - } => { - let b64 = base64::engine::general_purpose::STANDARD.encode(data); + }) => { + let inline_media_type = + if matches!(data, FileData::Text { .. }) && !media_type.contains('/') { + "text/plain" + } else { + media_type.as_str() + }; + let data = match data { + FileData::Data { + data: FileBytes::Binary(bytes), + } => base64::engine::general_purpose::STANDARD.encode(bytes), + FileData::Text { text } => { + base64::engine::general_purpose::STANDARD.encode(text.as_bytes()) + } + FileData::Data { + data: FileBytes::Base64(data), + } => data.clone(), + FileData::Url { url } => { + parts.push(json!({ + "fileData": { "mimeType": media_type, "fileUri": url } + })); + continue; + } + FileData::Reference { reference } => { + if namespace == ProviderMetadataNamespace::Google + && let Some(reference) = reference.get("google") + { + parts.push(json!({ + "fileData": { "mimeType": media_type, "fileUri": reference } + })); + } + continue; + } + }; parts.push(json!({ - "inlineData": { "mimeType": media_type, "data": b64 } + "inlineData": { "mimeType": inline_media_type, "data": data } })); } - // Tool calls / results inside a user message are unusual; skip - // them rather than trying to stringify a `ContentPart`. - _ => {} } } if parts.is_empty() { @@ -215,16 +233,16 @@ fn convert_user_parts(content: &[ContentPart]) -> Vec { /// - `Text` → `{ text }` (skipped when empty, matching the TS SDK). /// - `ToolCall` → `{ functionCall: { id?, name, args } }`. fn convert_assistant_parts( - content: &[ContentPart], + content: &[AssistantPart], namespace: ProviderMetadataNamespace, ) -> Vec { let mut parts = Vec::new(); for part in content { match part { - ContentPart::Text { + AssistantPart::Text(TextPart { text, provider_options, - } => { + }) => { if !text.is_empty() { let mut p = json!({ "text": text }); // Echo thoughtSignature from provider_options if present @@ -238,11 +256,11 @@ fn convert_assistant_parts( parts.push(p); } } - ContentPart::Reasoning { + AssistantPart::Reasoning(ReasoningPart { text, signature, provider_options, - } => { + }) => { if !text.is_empty() { let mut p = json!({ "text": text, "thought": true }); // Prefer explicit signature field, then fall back to provider_options. @@ -258,14 +276,14 @@ fn convert_assistant_parts( parts.push(p); } } - ContentPart::ToolCall { + AssistantPart::ToolCall(ToolCallPart { tool_call_id, tool_name, input, thought_signature, provider_options, .. - } => { + }) => { let google_options = read_provider_options(provider_options.as_ref(), namespace); let server_tool_call_id = google_options .and_then(|options| options.get("serverToolCallId")) @@ -313,15 +331,11 @@ fn convert_assistant_parts( } parts.push(part_value); } - ContentPart::ToolResult { - tool_call_id: _, - tool_name: _, + AssistantPart::ToolResult(ToolResultPart { result, - is_error: _, - preliminary: _, - dynamic: _, provider_options, - } => { + .. + }) => { // Provider-executed tool result in an assistant message — // upstream convert-to-google-messages.ts:518-540. // If it carries serverToolCallId + serverToolType, emit as @@ -351,9 +365,7 @@ fn convert_assistant_parts( } } } - _ => { - // Files / images in assistant messages: skip. - } + AssistantPart::File(_) => {} } } parts @@ -365,41 +377,39 @@ fn convert_assistant_parts( /// `{ functionResponse: { id?, name, response: { name, content } } }`. /// `content` is the tool's output serialized to a string (string outputs /// pass through; JSON outputs are stringified). -fn convert_tool_parts(content: &[ContentPart]) -> Vec { +fn convert_tool_parts(content: &[ToolPart]) -> Vec { let mut parts = Vec::new(); for part in content { - if let ContentPart::ToolResult { + let ToolPart::ToolResult(ToolResultPart { tool_call_id, tool_name, result, .. - } = part - { - let content_str = match result { - Value::String(s) => s.clone(), - other => other.to_string(), - }; - let mut function_response = Map::new(); - if !tool_call_id.is_empty() { - function_response.insert("id".to_string(), json!(tool_call_id)); - } - // `name` is required by the API and must be the name of the tool - // that was called (Gemini pairs functionResponse with the prior - // functionCall by name; an empty or mismatched name is rejected - // with HTTP 400 on multi-turn tool calls). Prefer the framework - // provided `tool_name`, falling back to the call id for callers - // that never set one. - let name = tool_name - .clone() - .filter(|s| !s.is_empty()) - .unwrap_or_else(|| tool_call_id.clone()); - function_response.insert("name".to_string(), json!(name)); - function_response.insert( - "response".to_string(), - json!({ "name": name, "content": content_str }), - ); - parts.push(json!({ "functionResponse": function_response })); + }) = part; + let content_str = match result { + Value::String(s) => s.clone(), + other => other.to_string(), + }; + let mut function_response = Map::new(); + if !tool_call_id.is_empty() { + function_response.insert("id".to_string(), json!(tool_call_id)); } + // `name` is required by the API and must be the name of the tool + // that was called (Gemini pairs functionResponse with the prior + // functionCall by name; an empty or mismatched name is rejected + // with HTTP 400 on multi-turn tool calls). Prefer the framework + // provided `tool_name`, falling back to the call id for callers + // that never set one. + let name = tool_name + .clone() + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| tool_call_id.clone()); + function_response.insert("name".to_string(), json!(name)); + function_response.insert( + "response".to_string(), + json!({ "name": name, "content": content_str }), + ); + parts.push(json!({ "functionResponse": function_response })); } parts } @@ -1295,12 +1305,10 @@ pub fn extract_sources( #[cfg(test)] mod tests { use super::*; - use aimux_core::language_model_message::LanguageModelPromptMessage; use aimux_core::shared::provider_namespace; - fn assistant_prompt(content: Vec) -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::Assistant, + fn assistant_prompt(content: Vec) -> LanguageModelPrompt { + vec![LanguageModelMessage::Assistant { content, provider_options: None, }] @@ -1320,15 +1328,15 @@ mod tests { fn vertex_replays_native_code_execution_with_multiple_results() { let call_id = "code-1"; let prompt = assistant_prompt(vec![ - ContentPart::ToolCall { + AssistantPart::ToolCall(ToolCallPart { tool_call_id: call_id.to_string(), tool_name: "runCode".to_string(), input: json!({ "language": "PYTHON", "code": "print(2)" }), provider_executed: Some(true), thought_signature: None, provider_options: Some(code_metadata("googleVertex", call_id)), - }, - ContentPart::ToolResult { + }), + AssistantPart::ToolResult(ToolResultPart { tool_call_id: call_id.to_string(), tool_name: Some("runCode".to_string()), result: json!({ "outcome": "OUTCOME_OK", "output": "2" }), @@ -1336,8 +1344,8 @@ mod tests { preliminary: None, dynamic: None, provider_options: Some(code_metadata("googleVertex", call_id)), - }, - ContentPart::ToolResult { + }), + AssistantPart::ToolResult(ToolResultPart { tool_call_id: call_id.to_string(), tool_name: Some("runCode".to_string()), result: json!({ "outcome": "OUTCOME_OK", "output": "still 2" }), @@ -1345,7 +1353,7 @@ mod tests { preliminary: None, dynamic: None, provider_options: Some(code_metadata("googleVertex", call_id)), - }, + }), ]); let converted = @@ -1362,7 +1370,7 @@ mod tests { #[test] fn provider_metadata_namespace_precedence_matches_ai_sdk() { - let content = ContentPart::ToolCall { + let content = AssistantPart::ToolCall(ToolCallPart { tool_call_id: "call-1".to_string(), tool_name: "weather".to_string(), input: json!({}), @@ -1376,7 +1384,7 @@ mod tests { })) .unwrap(), ), - }; + }); let prompt = assistant_prompt(vec![content]); let vertex = @@ -1393,7 +1401,7 @@ mod tests { #[test] fn vertex_reads_google_as_cross_namespace_fallback() { - let prompt = assistant_prompt(vec![ContentPart::ToolCall { + let prompt = assistant_prompt(vec![AssistantPart::ToolCall(ToolCallPart { tool_call_id: "call-1".to_string(), tool_name: "weather".to_string(), input: json!({}), @@ -1405,7 +1413,7 @@ mod tests { "thoughtSignature": "gateway-signature", }), )), - }]); + })]); let converted = convert_to_google_messages_for_namespace(&prompt, ProviderMetadataNamespace::Vertex); diff --git a/aimux-providers/src/huggingface/responses.rs b/aimux-providers/src/huggingface/responses.rs index 60a780cc2..588e0e8b8 100644 --- a/aimux-providers/src/huggingface/responses.rs +++ b/aimux-providers/src/huggingface/responses.rs @@ -23,15 +23,16 @@ use base64::Engine; use futures::StreamExt; use serde_json::{Map, Value, json}; -use aimux_core::content::ContentPart; use aimux_core::error::AiMuxError; use aimux_core::error::ApiCallError; use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::LanguageModelPrompt; -use aimux_core::message::Role; +use aimux_core::language_model_message::{ + AssistantPart, LanguageModelMessage, LanguageModelPrompt, ReasoningPart, TextPart, UserPart, +}; use aimux_core::options::{CallOptions, ResponseFormat, ToolChoice}; use aimux_core::result::{GenerateContent, GenerateResult, ReasoningOutput, Source, StreamResult}; use aimux_core::shared::provider_namespace; +use aimux_core::shared::{FileBytes, FileData}; use aimux_core::stream_part::StreamPart; use aimux_core::tool::Tool; use aimux_core::types::{ @@ -729,74 +730,59 @@ pub fn convert_to_huggingface_responses_messages( let mut warnings: Vec = Vec::new(); for msg in prompt { - match msg.role { - Role::System => { - // System content is a plain string (concatenation of text parts). - let content: String = msg - .content - .iter() - .filter_map(|p| match p { - ContentPart::Text { text, .. } => Some(text.as_str()), - _ => None, - }) - .collect::>() - .join(""); + match msg { + LanguageModelMessage::System { content, .. } => { messages.push(json!({ "role": "system", "content": content })); } - - Role::User => { + LanguageModelMessage::User { content, .. } => { let mut parts: Vec = Vec::new(); - for part in &msg.content { + for part in content { match part { - ContentPart::Text { text, .. } => { + UserPart::Text(TextPart { text, .. }) => { parts.push(json!({ "type": "input_text", "text": text })); } - ContentPart::FileBase64 { - data, media_type, .. - } => { - parts.push(convert_file_part_base64(media_type, data)?); - } - ContentPart::FileUrl { - url, media_type, .. - } => { - parts.push(convert_file_part_url(media_type, url)?); - } - ContentPart::FileReference { .. } => { - return Err(AiMuxError::UnsupportedFunctionality( - "file parts with provider references".into(), - )); - } - ContentPart::Image { - image, media_type, .. - } => { - let encoded = base64::engine::general_purpose::STANDARD.encode(image); - parts.push(convert_file_part_base64(media_type, &encoded)?); - } - ContentPart::File { - data, media_type, .. - } => { - let encoded = base64::engine::general_purpose::STANDARD.encode(data); - parts.push(convert_file_part_base64(media_type, &encoded)?); - } - _ => { - // Other part types (tool-call, tool-result, reasoning) - // are not expected in user messages — skip. + UserPart::File(file) => { + parts.push(match &file.data { + FileData::Data { + data: FileBytes::Binary(bytes), + } => { + let encoded = + base64::engine::general_purpose::STANDARD.encode(bytes); + convert_file_part_base64(&file.media_type, &encoded)? + } + FileData::Data { + data: FileBytes::Base64(data), + } => convert_file_part_base64(&file.media_type, data)?, + FileData::Url { url } => { + convert_file_part_url(&file.media_type, url)? + } + FileData::Reference { .. } => { + return Err(AiMuxError::UnsupportedFunctionality( + "file parts with provider references".into(), + )); + } + FileData::Text { .. } => { + return Err(AiMuxError::UnsupportedFunctionality( + "text file parts".into(), + )); + } + }); } } } messages.push(json!({ "role": "user", "content": parts })); } - Role::Assistant => { - for part in &msg.content { + LanguageModelMessage::Assistant { content, .. } => { + for part in content { match part { - ContentPart::Text { text, .. } => { + AssistantPart::Text(TextPart { text, .. }) => { messages.push(json!({ "role": "assistant", "content": [{ "type": "output_text", "text": text }] })); } - ContentPart::Reasoning { text, .. } => { + AssistantPart::Reasoning(ReasoningPart { text, .. }) => { messages.push(json!({ "role": "assistant", "content": [{ "type": "output_text", "text": text }] @@ -804,13 +790,13 @@ pub fn convert_to_huggingface_responses_messages( } // Tool calls and tool results are handled by the // Responses API — skip (no warning, matching TS). - ContentPart::ToolCall { .. } | ContentPart::ToolResult { .. } => {} + AssistantPart::ToolCall(_) | AssistantPart::ToolResult(_) => {} _ => {} } } } - Role::Tool => { + LanguageModelMessage::Tool { .. } => { warnings.push(Warning::Unsupported { feature: "tool messages".into(), details: None, diff --git a/aimux-providers/src/mistral/convert.rs b/aimux-providers/src/mistral/convert.rs index 88d737c86..589e07cce 100644 --- a/aimux-providers/src/mistral/convert.rs +++ b/aimux-providers/src/mistral/convert.rs @@ -3,10 +3,12 @@ //! Mirrors the TS `convert-to-mistral-chat-messages.ts`, //! `mistral-prepare-tools.ts`, and `map-mistral-finish-reason.ts`. -use aimux_core::content::ContentPart; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; +use aimux_core::language_model_message::{ + AssistantPart, LanguageModelMessage, LanguageModelPrompt, TextPart, ToolCallPart, ToolPart, + ToolResultPart, UserPart, +}; use aimux_core::options::{CallOptions, ResponseFormat, ToolChoice}; +use aimux_core::shared::{FileBytes, FileData}; use aimux_core::tool::{FunctionTool, Tool}; use aimux_core::types::{FinishReason, FinishReasonUnified}; use serde_json::{Value, json}; @@ -114,36 +116,33 @@ pub fn convert_prompt_to_mistral_messages(prompt: &LanguageModelPrompt) -> Vec Vec { - match msg.role { - Role::System => { - let text = join_text_parts(&msg.content); - vec![json!({ "role": "system", "content": text })] +fn convert_message_to_mistral(msg: &LanguageModelMessage, is_last: bool) -> Vec { + match msg { + LanguageModelMessage::System { content, .. } => { + vec![json!({ "role": "system", "content": content })] } - Role::User => { - let parts: Vec = msg.content.iter().map(convert_part_to_mistral).collect(); + LanguageModelMessage::User { content, .. } => { + let parts: Vec = content.iter().map(convert_part_to_mistral).collect(); vec![json!({ "role": "user", "content": parts })] } - Role::Assistant => { - let text = join_text_parts(&msg.content); - let has_tool_calls = msg - .content + LanguageModelMessage::Assistant { content, .. } => { + let text = join_text_parts(content); + let has_tool_calls = content .iter() - .any(|p| matches!(p, ContentPart::ToolCall { .. })); + .any(|p| matches!(p, AssistantPart::ToolCall(_))); let mut msg_json = json!({ "role": "assistant", "content": text }); if has_tool_calls { - let tool_calls: Vec = msg - .content + let tool_calls: Vec = content .iter() .filter_map(|p| match p { - ContentPart::ToolCall { + AssistantPart::ToolCall(ToolCallPart { tool_call_id, tool_name, input, .. - } => { + }) => { let arguments = if input.is_null() { "{}".to_string() } else { @@ -169,33 +168,30 @@ fn convert_message_to_mistral(msg: &LanguageModelPromptMessage, is_last: bool) - } vec![msg_json] } - Role::Tool => msg - .content + LanguageModelMessage::Tool { content, .. } => content .iter() - .filter_map(|part| match part { - ContentPart::ToolResult { + .map(|part| { + let ToolPart::ToolResult(ToolResultPart { tool_call_id, result, .. - } => { - let content = tool_result_to_content(result); - Some(json!({ - "role": "tool", - "tool_call_id": tool_call_id, - "content": content, - })) - } - _ => None, + }) = part; + let content = tool_result_to_content(result); + json!({ + "role": "tool", + "tool_call_id": tool_call_id, + "content": content, + }) }) .collect(), } } -fn join_text_parts(content: &[ContentPart]) -> String { +fn join_text_parts(content: &[AssistantPart]) -> String { content .iter() .filter_map(|p| match p { - ContentPart::Text { text, .. } => Some(text.as_str()), + AssistantPart::Text(TextPart { text, .. }) => Some(text.as_str()), _ => None, }) .collect::>() @@ -209,71 +205,30 @@ fn tool_result_to_content(output: &Value) -> Value { } } -fn convert_part_to_mistral(part: &ContentPart) -> Value { +fn convert_part_to_mistral(part: &UserPart) -> Value { match part { - ContentPart::Text { text, .. } => json!({ "type": "text", "text": text }), - ContentPart::Image { - image, media_type, .. - } => { + UserPart::Text(TextPart { text, .. }) => json!({ "type": "text", "text": text }), + UserPart::File(file) => { use base64::Engine; - let b64 = base64::engine::general_purpose::STANDARD.encode(image); - json!({ - "type": "image_url", - "image_url": format!("data:{};base64,{}", media_type, b64), - }) - } - ContentPart::File { - data, media_type, .. - } => { - use base64::Engine; - let b64 = base64::engine::general_purpose::STANDARD.encode(data); - let top_level = media_type.split('/').next().unwrap_or(""); - if top_level == "image" { - json!({ - "type": "image_url", - "image_url": format!("data:{};base64,{}", media_type, b64), - }) + let url = match &file.data { + FileData::Data { data } => { + let b64 = match data { + FileBytes::Binary(bytes) => { + base64::engine::general_purpose::STANDARD.encode(bytes) + } + FileBytes::Base64(data) => data.clone(), + }; + format!("data:{};base64,{}", file.media_type, b64) + } + FileData::Url { url } => url.clone(), + FileData::Reference { .. } | FileData::Text { .. } => return Value::Null, + }; + if file.media_type.starts_with("image/") { + json!({ "type": "image_url", "image_url": url }) } else { - json!({ - "type": "document_url", - "document_url": format!("data:{};base64,{}", media_type, b64), - }) + json!({ "type": "document_url", "document_url": url }) } } - // These variants are handled by `convert_message_to_mistral` for - // assistant/tool roles. - ContentPart::ToolCall { - tool_call_id, - tool_name, - input, - .. - } => { - json!({ - "type": "tool_call", - "id": tool_call_id, - "function": { - "name": tool_name, - "arguments": input.to_string(), - } - }) - } - ContentPart::ToolResult { - tool_call_id, - result, - .. - } => { - json!({ - "type": "tool_result", - "tool_call_id": tool_call_id, - "content": result, - }) - } - - // Variants not yet modelled for Mistral; no test exercises these paths. - ContentPart::FileBase64 { .. } - | ContentPart::FileUrl { .. } - | ContentPart::FileReference { .. } - | ContentPart::Reasoning { .. } => Value::Null, } } diff --git a/aimux-providers/src/open_responses.rs b/aimux-providers/src/open_responses.rs index 3e8701d81..fc672318f 100644 --- a/aimux-providers/src/open_responses.rs +++ b/aimux-providers/src/open_responses.rs @@ -14,15 +14,17 @@ use async_trait::async_trait; use futures::StreamExt; use serde_json::{Map, Value, json}; -use aimux_core::content::ContentPart; use aimux_core::error::AiMuxError; use aimux_core::error::ApiCallError; use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::LanguageModelPrompt; -use aimux_core::message::Role; +use aimux_core::language_model_message::{ + AssistantPart, FilePart, LanguageModelMessage, LanguageModelPrompt, TextPart, ToolCallPart, + ToolPart, ToolResultPart, UserPart, +}; use aimux_core::options::{CallOptions, ToolChoice}; use aimux_core::provider::Provider; use aimux_core::result::{GenerateContent, GenerateResult, ReasoningOutput, StreamResult}; +use aimux_core::shared::{FileBytes, FileData}; use aimux_core::stream_part::StreamPart; use aimux_core::tool::Tool; use aimux_core::types::{ @@ -1068,40 +1070,36 @@ pub fn convert_to_open_responses_input( let mut system_messages: Vec = Vec::new(); for msg in prompt { - match msg.role { - Role::System => { - for part in &msg.content { - if let ContentPart::Text { text, .. } = part { - system_messages.push(text.clone()); - } - } + match msg { + LanguageModelMessage::System { content, .. } => { + system_messages.push(content.clone()); } - Role::User => { - let user_content = convert_user_content(&msg.content, &mut warnings); + LanguageModelMessage::User { content, .. } => { + let user_content = convert_user_content(content, &mut warnings); input.push(json!({ "type": "message", "role": "user", "content": user_content, })); } - Role::Assistant => { + LanguageModelMessage::Assistant { content, .. } => { let mut assistant_content: Vec = Vec::new(); let mut tool_calls: Vec = Vec::new(); - for part in &msg.content { + for part in content { match part { - ContentPart::Text { text, .. } => { + AssistantPart::Text(TextPart { text, .. }) => { assistant_content.push(json!({ "type": "output_text", "text": text, })); } - ContentPart::ToolCall { + AssistantPart::ToolCall(ToolCallPart { tool_call_id, tool_name, input: tool_input, .. - } => { + }) => { let arguments = match tool_input { Value::String(s) => s.clone(), other => other.to_string(), @@ -1128,21 +1126,19 @@ pub fn convert_to_open_responses_input( input.push(tc); } } - Role::Tool => { - for part in &msg.content { - if let ContentPart::ToolResult { + LanguageModelMessage::Tool { content, .. } => { + for part in content { + let ToolPart::ToolResult(ToolResultPart { tool_call_id, result, .. - } = part - { - let content_value = resolve_tool_result_output(result, &mut warnings); - input.push(json!({ - "type": "function_call_output", - "call_id": tool_call_id, - "output": content_value, - })); - } + }) = part; + let content_value = resolve_tool_result_output(result, &mut warnings); + input.push(json!({ + "type": "function_call_output", + "call_id": tool_call_id, + "output": content_value, + })); } } } @@ -1158,91 +1154,60 @@ pub fn convert_to_open_responses_input( } /// Convert user message content parts to the Open Responses format. -fn convert_user_content(content: &[ContentPart], warnings: &mut Vec) -> Value { +fn convert_user_content(content: &[UserPart], warnings: &mut Vec) -> Value { + use base64::Engine; + let mut parts: Vec = Vec::new(); for part in content { match part { - ContentPart::Text { text, .. } => { + UserPart::Text(TextPart { text, .. }) => { parts.push(json!({ "type": "input_text", "text": text })); } - ContentPart::Image { - image, media_type, .. - } => { - use base64::Engine; - let b64 = base64::engine::general_purpose::STANDARD.encode(image); - parts.push(json!({ - "type": "input_image", - "image_url": format!("data:{};base64,{}", media_type, b64), - })); - } - ContentPart::File { - data, - media_type, - filename, - .. - } => { - let top_level = top_level_media_type(media_type); - use base64::Engine; - let b64 = base64::engine::general_purpose::STANDARD.encode(data); - if top_level == "image" { - parts.push(json!({ - "type": "input_image", - "image_url": format!("data:{};base64,{}", media_type, b64), - })); - } else { - parts.push(json!({ - "type": "input_file", - "filename": filename.as_deref().unwrap_or("data"), - "file_data": format!("data:{};base64,{}", media_type, b64), - })); - } - } - ContentPart::FileBase64 { + UserPart::File(FilePart { data, media_type, filename, .. - } => { - let top_level = top_level_media_type(media_type); - if top_level == "image" { - parts.push(json!({ - "type": "input_image", - "image_url": format!("data:{};base64,{}", media_type, data), - })); - } else { - parts.push(json!({ - "type": "input_file", - "filename": filename.as_deref().unwrap_or("data"), - "file_data": format!("data:{};base64,{}", media_type, data), - })); - } - } - ContentPart::FileUrl { - url, media_type, .. - } => { - let top_level = top_level_media_type(media_type); - if top_level == "image" { - parts.push(json!({ - "type": "input_image", - "image_url": url, - })); - } else { - parts.push(json!({ - "type": "input_file", - "file_url": url, - })); + }) => { + let image = top_level_media_type(media_type) == "image"; + match data { + FileData::Data { data } => { + let b64 = match data { + FileBytes::Binary(bytes) => { + base64::engine::general_purpose::STANDARD.encode(bytes) + } + FileBytes::Base64(data) => data.clone(), + }; + let data_url = format!("data:{media_type};base64,{b64}"); + parts.push(if image { + json!({ "type": "input_image", "image_url": data_url }) + } else { + json!({ + "type": "input_file", + "filename": filename.as_deref().unwrap_or("data"), + "file_data": data_url, + }) + }); + } + FileData::Url { url } => { + parts.push(if image { + json!({ "type": "input_image", "image_url": url }) + } else { + json!({ "type": "input_file", "file_url": url }) + }); + } + FileData::Reference { .. } => { + warnings.push(Warning::Other { + message: "unsupported file part with provider reference".to_string(), + }); + } + FileData::Text { .. } => { + warnings.push(Warning::Other { + message: "unsupported text file part".to_string(), + }); + } } } - ContentPart::FileReference { .. } => { - warnings.push(Warning::Other { - message: "unsupported file part with provider reference".to_string(), - }); - } - _ => { - warnings.push(Warning::Other { - message: format!("unsupported content part type: {}", part_variant_name(part)), - }); - } } } json!(parts) @@ -1341,21 +1306,6 @@ fn top_level_media_type(media_type: &str) -> &str { media_type.split('/').next().unwrap_or("") } -/// Get a human-readable name for a ContentPart variant (for warnings). -fn part_variant_name(part: &ContentPart) -> &'static str { - match part { - ContentPart::Text { .. } => "text", - ContentPart::Image { .. } => "image", - ContentPart::File { .. } => "file", - ContentPart::FileBase64 { .. } => "file-base64", - ContentPart::FileUrl { .. } => "file-url", - ContentPart::FileReference { .. } => "file-reference", - ContentPart::Reasoning { .. } => "reasoning", - ContentPart::ToolCall { .. } => "tool-call", - ContentPart::ToolResult { .. } => "tool-result", - } -} - // == Usage extraction == /// Extract `Usage` from a response body `Value`. diff --git a/aimux-providers/src/openai/convert.rs b/aimux-providers/src/openai/convert.rs index 25222b647..fa477dec8 100644 --- a/aimux-providers/src/openai/convert.rs +++ b/aimux-providers/src/openai/convert.rs @@ -1,11 +1,12 @@ //! Conversion between `LanguageModelPrompt` and OpenAI API format. -use aimux_core::content::ContentPart; use aimux_core::error::AiMuxError; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; +use aimux_core::language_model_message::{ + AssistantPart, FilePart, LanguageModelMessage, LanguageModelPrompt, TextPart, ToolPart, + UserPart, +}; use aimux_core::options::{CallOptions, ResponseFormat, ToolChoice}; -use aimux_core::shared::SharedProviderOptions; +use aimux_core::shared::{FileBytes, FileData, SharedProviderOptions}; use aimux_core::tool::{FunctionTool, Tool}; use aimux_core::types::{FinishReason, FinishReasonUnified, ReasoningEffort, Warning}; use serde::Serialize; @@ -332,48 +333,53 @@ fn resolve_full_media_type(media_type: &str, b64_data: &str) -> String { } /// Resolve a provider reference, throwing if the provider is not found. -fn resolve_provider_reference(reference: &Value, provider: &str) -> Result { - if let Some(val) = reference.get(provider) { - if let Some(s) = val.as_str() { - return Ok(s.to_string()); - } - return Ok(val.to_string()); - } - let available: Vec = reference - .as_object() - .map(|m| m.keys().cloned().collect()) - .unwrap_or_default(); - Err(format!( - "No provider reference found for provider '{}'. Available providers: {}", - provider, - available.join(", ") - )) +fn resolve_provider_reference( + reference: &std::collections::HashMap, + provider: &str, +) -> Result { + reference.get(provider).cloned().ok_or_else(|| { + let mut available: Vec<&str> = reference.keys().map(String::as_str).collect(); + available.sort_unstable(); + format!( + "No provider reference found for provider '{}'. Available providers: {}", + provider, + available.join(", ") + ) + }) } /// Convert a file part to the OpenAI format, handling images, audio, and PDF. -fn convert_file_part_to_openai( - media_type: &str, - data_b64: Option<&str>, - url: Option<&str>, - reference: Option<&Value>, - filename: Option<&str>, - provider_options: &Option, - part_index: usize, -) -> Result { +fn convert_file_part_to_openai(file: &FilePart, part_index: usize) -> Result { + use base64::Engine; + + let FilePart { + data, + media_type, + filename, + provider_options, + } = file; let prompt_cache_breakpoint = get_prompt_cache_breakpoint(provider_options); - - // Reference type - if let Some(ref_val) = reference { - let file_id = resolve_provider_reference(ref_val, "openai")?; - let mut part = json!({ - "type": "file", - "file": { "file_id": file_id } - }); - if let Some(bpt) = prompt_cache_breakpoint { - part["prompt_cache_breakpoint"] = bpt; + let (data_b64, url) = match data { + FileData::Reference { reference } => { + let file_id = resolve_provider_reference(reference, "openai")?; + let mut part = json!({ "type": "file", "file": { "file_id": file_id } }); + if let Some(bpt) = prompt_cache_breakpoint { + part["prompt_cache_breakpoint"] = bpt; + } + return Ok(part); } - return Ok(part); - } + FileData::Text { .. } => return Err("text file parts".to_string()), + FileData::Url { url } => (None, Some(url.as_str())), + FileData::Data { data } => { + let b64 = match data { + FileBytes::Binary(bytes) => base64::engine::general_purpose::STANDARD.encode(bytes), + FileBytes::Base64(data) => data.clone(), + }; + (Some(b64), None) + } + }; + let data_b64 = data_b64.as_deref(); + let filename = filename.as_deref(); let top_level = get_top_level_media_type(media_type); @@ -459,310 +465,157 @@ fn convert_file_part_to_openai( /// Convert a single provider-facing message into one or more OpenAI messages. fn convert_message_to_openai( - msg: &LanguageModelPromptMessage, + msg: &LanguageModelMessage, system_message_mode: SystemMessageMode, provider: &str, ) -> Result, AiMuxError> { - let role = match msg.role { - Role::System => "system", - Role::User => "user", - Role::Assistant => "assistant", - Role::Tool => "tool", - }; - - // System messages: respect systemMessageMode. - if msg.role == Role::System { - match system_message_mode { - SystemMessageMode::Remove => return Ok(vec![]), - SystemMessageMode::Developer => { - return Ok(convert_system_message(msg, "developer")); - } - SystemMessageMode::System => { - return Ok(convert_system_message(msg, "system")); - } + let message = match msg { + LanguageModelMessage::System { + content, + provider_options, + } => { + let role = match system_message_mode { + SystemMessageMode::Remove => return Ok(vec![]), + SystemMessageMode::Developer => "developer", + SystemMessageMode::System => "system", + }; + let content = match get_prompt_cache_breakpoint(provider_options) { + None => json!(content), + Some(bpt) => json!([{ + "type": "text", "text": content, "prompt_cache_breakpoint": bpt, + }]), + }; + json!({ "role": role, "content": content }) } - } - - // Tool-role messages: each ToolResult part becomes its own OpenAI message. - if msg.role == Role::Tool { - let messages = msg - .content - .iter() - .filter_map(|part| match part { - ContentPart::ToolResult { - tool_call_id, - result, - .. - } => { - let content = tool_result_to_content(result); - Some(json!({ + LanguageModelMessage::Tool { content, .. } => { + return Ok(content + .iter() + .map(|part| { + let ToolPart::ToolResult(result) = part; + json!({ "role": "tool", - "content": content, - "tool_call_id": tool_call_id, - })) - } - _ => None, - }) - .collect(); - return Ok(messages); - } - - // Assistant messages with tool calls - let has_tool_calls = msg - .content - .iter() - .any(|p| matches!(p, ContentPart::ToolCall { .. })); - - // Groq: assistant messages always collect text, reasoning, and tool_calls - // together (mirrors TS `convertToGroqChatMessages`). The `reasoning` field - // is added when non-empty, and `content` is "" (not null) when there is no - // text. - if msg.role == Role::Assistant && provider == "groq" { - let text: String = msg - .content - .iter() - .filter_map(|p| match p { - ContentPart::Text { text, .. } => Some(text.as_str()), - _ => None, - }) - .collect::>() - .join(""); - - let reasoning: String = msg - .content - .iter() - .filter_map(|p| match p { - ContentPart::Reasoning { text, .. } => Some(text.as_str()), - _ => None, - }) - .collect::>() - .join(""); - - let tool_calls_json: Vec = msg - .content - .iter() - .filter_map(|p| match p { - ContentPart::ToolCall { - tool_call_id, - tool_name, - input, - .. - } => { - let arguments = if input.is_null() { - "{}".to_string() - } else { - input.to_string() - }; - Some(json!({ - "type": "function", - "id": tool_call_id, - "function": { - "name": tool_name, - "arguments": arguments, - } - })) - } - _ => None, - }) - .collect(); - - let mut msg_obj = json!({ - "role": "assistant", - "content": text, - }); - if !reasoning.is_empty() { - msg_obj["reasoning"] = json!(reasoning); + "content": tool_result_to_content(&result.result), + "tool_call_id": result.tool_call_id, + }) + }) + .collect()); } - if !tool_calls_json.is_empty() { - msg_obj["tool_calls"] = json!(tool_calls_json); + LanguageModelMessage::User { content, .. } => { + let all_plain_text = content.iter().all(|part| { + matches!( + part, + UserPart::Text(TextPart { + provider_options: None, + .. + }) + ) + }); + let content = if all_plain_text { + json!( + content + .iter() + .filter_map(|part| match part { + UserPart::Text(text) => Some(text.text.as_str()), + UserPart::File(_) => None, + }) + .collect::() + ) + } else { + json!( + content + .iter() + .enumerate() + .map(|(index, part)| match part { + UserPart::Text(text) => Ok(convert_text_part_to_openai(text)), + UserPart::File(file) => convert_file_part_to_openai(file, index) + .map_err(AiMuxError::InvalidArgument), + }) + .collect::, _>>()? + ) + }; + json!({ "role": "user", "content": content }) } - return Ok(vec![msg_obj]); - } - - if msg.role == Role::Assistant && has_tool_calls { - let text: String = msg - .content - .iter() - .filter_map(|p| match p { - ContentPart::Text { text, .. } => Some(text.as_str()), - _ => None, - }) - .collect::>() - .join(""); - - // Reasoning / thinking content. DeepSeek V4 thinking mode (and other - // OpenAI-compatible reasoning models such as xAI) require prior - // assistant `reasoning_content` to be replayed on subsequent turns, - // including tool-call turns. Mirrors the Vercel AI SDK - // `openai-compatible` assistant conversion, which emits - // `reasoning_content` whenever a reasoning part is present. Groq uses - // the `reasoning` field name and is handled in its own branch above. - let reasoning: String = collect_reasoning(&msg.content); - - let tool_calls_json: Vec = msg - .content - .iter() - .filter_map(|p| match p { - ContentPart::ToolCall { - tool_call_id, - tool_name, - input, - .. - } => { - let arguments = if input.is_null() { - "{}".to_string() - } else { - input.to_string() - }; - Some(json!({ - "type": "function", - "id": tool_call_id, - "function": { - "name": tool_name, - "arguments": arguments, - } - })) + LanguageModelMessage::Assistant { content, .. } => { + let mut text = String::new(); + let mut reasoning = String::new(); + let mut tool_calls = Vec::new(); + for part in content { + match part { + AssistantPart::Text(part) => text.push_str(&part.text), + AssistantPart::Reasoning(part) => reasoning.push_str(&part.text), + AssistantPart::ToolCall(part) => { + let arguments = if part.input.is_null() { + "{}".to_string() + } else { + part.input.to_string() + }; + tool_calls.push(json!({ + "type": "function", "id": part.tool_call_id, + "function": { "name": part.tool_name, "arguments": arguments }, + })); + } + AssistantPart::File(_) | AssistantPart::ToolResult(_) => {} } - _ => None, - }) - .collect(); - - let content = if text.is_empty() { - Value::Null - } else { - Value::String(text) - }; - - let mut msg_obj = json!({ - "role": "assistant", - "content": content, - "tool_calls": tool_calls_json, - }); - if !reasoning.is_empty() { - msg_obj["reasoning_content"] = json!(reasoning); - } - return Ok(vec![msg_obj]); - } - - // Default path (non-Groq, no tool calls). Assistant reasoning / thinking - // parts are lifted to a top-level `reasoning_content` string (DeepSeek V4 - // thinking mode and other OpenAI-compatible reasoning models require it to - // be replayed on later turns); they are never valid OpenAI content parts, - // so they are excluded from the content shape below. Non-assistant roles do - // not carry reasoning, but the filter is harmless. - let reasoning = if msg.role == Role::Assistant { - collect_reasoning(&msg.content) - } else { - String::new() - }; - let has_reasoning = !reasoning.is_empty(); - - // Consider only non-reasoning parts for the content shape. When they are - // all plain text (without providerOptions), collapse to a string — matching - // the Vercel AI SDK `openai-compatible` assistant conversion - // (`content: toolCalls.length > 0 ? text || null : text`). - // - // Provider-executed tool results are dropped from assistant messages: the - // OpenAI chat wire format has no content part for them, and upstream's - // assistant branch handles only `text` and `tool-call` - // (convert-to-openai-chat-messages.ts:246-300). Emitting one produces a - // part type the API rejects. - let content_parts: Vec<&ContentPart> = msg - .content - .iter() - .filter(|p| match p { - ContentPart::Reasoning { .. } => false, - ContentPart::ToolResult { .. } => msg.role != Role::Assistant, - _ => true, - }) - .collect(); - let all_plain_text = content_parts.iter().all(|p| { - matches!( - p, - ContentPart::Text { - provider_options: None, - .. } - ) - }); - - let mut msg_obj = if all_plain_text { - let text: String = content_parts - .iter() - .filter_map(|p| match p { - ContentPart::Text { text, .. } => Some(text.as_str()), - _ => None, - }) - .collect::>() - .join(""); - json!({ "role": role, "content": text }) - } else { - let parts: Vec = content_parts - .iter() - .enumerate() - .map(|(i, part)| convert_part_to_openai(part, i)) - .collect::, _>>()? - .into_iter() - .filter(|p| !p.is_null()) - .collect(); - json!({ "role": role, "content": parts }) - }; - if has_reasoning { - msg_obj["reasoning_content"] = json!(reasoning); - } - Ok(vec![msg_obj]) -} - -/// Collect and concatenate the `text` of all `ContentPart::Reasoning` parts in -/// `content`, mirroring the Vercel AI SDK assistant-message conversion. Used to -/// build the OpenAI-compatible top-level `reasoning_content` / `reasoning` -/// field that thinking models (DeepSeek V4, xAI, Groq) require to be replayed -/// across turns. -fn collect_reasoning(content: &[ContentPart]) -> String { - content - .iter() - .filter_map(|p| match p { - ContentPart::Reasoning { text, .. } => Some(text.as_str()), - _ => None, - }) - .collect::>() - .join("") -} - -/// Convert a system message, respecting promptCacheBreakpoint. -fn convert_system_message(msg: &LanguageModelPromptMessage, role: &str) -> Vec { - // System messages in the Rust model are always a single text part. - let text = msg - .content - .iter() - .filter_map(|p| match p { - ContentPart::Text { - text, - provider_options, - } => { - let bpt = get_prompt_cache_breakpoint(provider_options); - Some((text.clone(), bpt)) + let mut message = if provider == "groq" || !tool_calls.is_empty() { + let value = if provider != "groq" && text.is_empty() { + Value::Null + } else { + json!(text) + }; + let mut message = json!({ "role": "assistant", "content": value }); + if !tool_calls.is_empty() { + message["tool_calls"] = json!(tool_calls); + } + message + } else { + let all_plain_text = content.iter().all(|part| { + matches!( + part, + AssistantPart::Text(TextPart { + provider_options: None, + .. + }) | AssistantPart::Reasoning(_) + | AssistantPart::ToolResult(_) + ) + }); + let value = if all_plain_text { + json!(text) + } else { + let mut parts = Vec::new(); + for part in content { + match part { + AssistantPart::Text(text) => { + parts.push(convert_text_part_to_openai(text)) + } + AssistantPart::File(file) => parts.push( + convert_file_part_to_openai(file, parts.len()) + .map_err(AiMuxError::InvalidArgument)?, + ), + AssistantPart::Reasoning(_) + | AssistantPart::ToolResult(_) + | AssistantPart::ToolCall(_) => {} + } + } + json!(parts) + }; + json!({ "role": "assistant", "content": value }) + }; + if !reasoning.is_empty() { + let field = if provider == "groq" { + "reasoning" + } else { + "reasoning_content" + }; + message[field] = json!(reasoning); } - _ => None, - }) - .next(); - - match text { - Some((t, None)) => vec![json!({ "role": role, "content": t })], - Some((t, Some(bpt))) => vec![json!({ - "role": role, - "content": [{ - "type": "text", - "text": t, - "prompt_cache_breakpoint": bpt, - }] - })], - None => vec![json!({ "role": role, "content": "" })], - } + message + } + }; + Ok(vec![message]) } -/// Serialize a tool-result `output` value into the OpenAI tool message -/// `content` string. +/// Serialize a tool-result value into the OpenAI tool message content string. fn tool_result_to_content(output: &Value) -> Value { match output { Value::String(s) => Value::String(s.clone()), @@ -770,122 +623,12 @@ fn tool_result_to_content(output: &Value) -> Value { } } -/// Convert a content part to the OpenAI format. -fn convert_part_to_openai(part: &ContentPart, index: usize) -> Result { - match part { - ContentPart::Text { - text, - provider_options, - } => { - let bpt = get_prompt_cache_breakpoint(provider_options); - let mut p = json!({ "type": "text", "text": text }); - if let Some(b) = bpt { - p["prompt_cache_breakpoint"] = b; - } - Ok(p) - } - ContentPart::Image { - image, - media_type, - provider_options, - } => { - use base64::Engine; - let b64 = base64::engine::general_purpose::STANDARD.encode(image); - convert_file_part_to_openai( - media_type, - Some(&b64), - None, - None, - None, - provider_options, - index, - ) - .map_err(AiMuxError::InvalidArgument) - } - ContentPart::File { - data, - media_type, - filename, - provider_options, - } => { - use base64::Engine; - let b64 = base64::engine::general_purpose::STANDARD.encode(data); - convert_file_part_to_openai( - media_type, - Some(&b64), - None, - None, - filename.as_deref(), - provider_options, - index, - ) - .map_err(AiMuxError::InvalidArgument) - } - ContentPart::FileBase64 { - data, - media_type, - filename, - provider_options, - } => convert_file_part_to_openai( - media_type, - Some(data), - None, - None, - filename.as_deref(), - provider_options, - index, - ) - .map_err(AiMuxError::InvalidArgument), - ContentPart::FileUrl { - url, - media_type, - provider_options, - } => convert_file_part_to_openai( - media_type, - None, - Some(url), - None, - None, - provider_options, - index, - ) - .map_err(AiMuxError::InvalidArgument), - ContentPart::FileReference { - media_type, - reference, - filename, - provider_options, - } => convert_file_part_to_openai( - media_type, - None, - None, - Some(reference), - filename.as_deref(), - provider_options, - index, - ) - .map_err(AiMuxError::InvalidArgument), - ContentPart::Reasoning { .. } => Ok(Value::Null), - // These variants are handled by `convert_message_to_openai` for - // assistant/tool roles; kept here as a defensive fallback. - ContentPart::ToolCall { - tool_call_id, - tool_name, - input, - .. - } => Ok(json!({ - "type": "tool_call", - "id": tool_call_id, - "function": { - "name": tool_name, - "arguments": input.to_string(), - } - })), - // Tool results reach the wire as a separate `role: "tool"` message - // (built by `convert_message_to_openai`), never as a content part — - // there is no such part type in the OpenAI chat format. - ContentPart::ToolResult { .. } => Ok(Value::Null), +fn convert_text_part_to_openai(part: &TextPart) -> Value { + let mut value = json!({ "type": "text", "text": part.text }); + if let Some(bpt) = get_prompt_cache_breakpoint(&part.provider_options) { + value["prompt_cache_breakpoint"] = bpt; } + value } // ── Request body ──────────────────────────────────────────────────────────── diff --git a/aimux-providers/src/openai/responses/convert.rs b/aimux-providers/src/openai/responses/convert.rs index 6ae5f8575..2236542fa 100644 --- a/aimux-providers/src/openai/responses/convert.rs +++ b/aimux-providers/src/openai/responses/convert.rs @@ -12,11 +12,12 @@ use serde_json::{Value, json}; -use aimux_core::content::ContentPart; -use aimux_core::language_model_message::LanguageModelPrompt; -use aimux_core::message::Role; +use aimux_core::language_model_message::{ + AssistantPart, FilePart, LanguageModelMessage, LanguageModelPrompt, ReasoningPart, TextPart, + ToolCallPart, ToolPart, ToolResultPart, UserPart, +}; use aimux_core::options::{CallOptions, ResponseFormat, ToolChoice}; -use aimux_core::shared::SharedProviderOptions; +use aimux_core::shared::{FileBytes, FileData, SharedProviderOptions}; use aimux_core::tool::{FunctionTool, Tool}; use aimux_core::types::{FinishReason, FinishReasonUnified, ReasoningEffort, Usage, Warning}; @@ -79,18 +80,18 @@ pub fn convert_to_responses_input( let mut warnings: Vec = Vec::new(); for msg in prompt { - match msg.role { - Role::System => match system_message_mode { + match msg { + LanguageModelMessage::System { content, .. } => match system_message_mode { SystemMessageMode::System => { input.push(json!({ "role": "system", - "content": join_text_parts(&msg.content), + "content": content, })); } SystemMessageMode::Developer => { input.push(json!({ "role": "developer", - "content": join_text_parts(&msg.content), + "content": content, })); } SystemMessageMode::Remove => { @@ -99,17 +100,17 @@ pub fn convert_to_responses_input( }); } }, - Role::User => { - let content: Vec = msg.content.iter().map(convert_user_part).collect(); + LanguageModelMessage::User { content, .. } => { + let content: Vec = content.iter().map(convert_user_part).collect(); input.push(json!({ "role": "user", "content": content })); } - Role::Assistant => { - for part in &msg.content { + LanguageModelMessage::Assistant { content, .. } => { + for part in content { match part { - ContentPart::Text { + AssistantPart::Text(TextPart { text, provider_options, - } => { + }) => { let id = item_id(provider_options); if has_previous_response_id && id.is_some() { continue; @@ -131,13 +132,13 @@ pub fn convert_to_responses_input( } input.push(item); } - ContentPart::ToolCall { + AssistantPart::ToolCall(ToolCallPart { tool_call_id, tool_name, input: tool_input, provider_options, .. - } => { + }) => { let id = item_id(provider_options); if has_previous_response_id && id.is_some() { continue; @@ -154,11 +155,11 @@ pub fn convert_to_responses_input( } input.push(item); } - ContentPart::Reasoning { + AssistantPart::Reasoning(ReasoningPart { text, provider_options, .. - } => { + }) => { let reasoning_id = openai_sub_option(provider_options, "itemId"); if has_previous_response_id && reasoning_id.is_some() { continue; @@ -213,24 +214,22 @@ pub fn convert_to_responses_input( } } } - Role::Tool => { - for part in &msg.content { - if let ContentPart::ToolResult { + LanguageModelMessage::Tool { content, .. } => { + for part in content { + let ToolPart::ToolResult(ToolResultPart { tool_call_id, result, .. - } = part - { - let content_value = match result { - Value::String(s) => Value::String(s.clone()), - other => Value::String(other.to_string()), - }; - input.push(json!({ - "type": "function_call_output", - "call_id": tool_call_id, - "output": content_value, - })); - } + }) = part; + let content_value = match result { + Value::String(s) => Value::String(s.clone()), + other => Value::String(other.to_string()), + }; + input.push(json!({ + "type": "function_call_output", + "call_id": tool_call_id, + "output": content_value, + })); } } } @@ -257,81 +256,56 @@ pub fn convert_to_responses_input( ResponsesInputResult { input, warnings } } -/// Join all text parts of a message into a single string (for system messages). -fn join_text_parts(parts: &[ContentPart]) -> String { - parts - .iter() - .filter_map(|p| match p { - ContentPart::Text { text, .. } => Some(text.as_str()), - _ => None, - }) - .collect::>() - .join("") -} - /// Convert a single user-message content part into the Responses input shape. -fn convert_user_part(part: &ContentPart) -> Value { +fn convert_user_part(part: &UserPart) -> Value { match part { - ContentPart::Text { text, .. } => json!({ "type": "input_text", "text": text }), - ContentPart::Image { - image, - media_type, - provider_options, - } => { - let b64 = { - use base64::Engine; - base64::engine::general_purpose::STANDARD.encode(image) - }; - let detail = openai_sub_option(provider_options, "imageDetail"); - let mut img = json!({ - "type": "input_image", - "image_url": format!("data:{};base64,{}", media_type, b64), - }); - if let Some(d) = detail { - img["detail"] = d; - } - img - } - ContentPart::File { + UserPart::Text(TextPart { text, .. }) => json!({ "type": "input_text", "text": text }), + UserPart::File(FilePart { data, media_type, filename, - .. - } => { - let b64 = { - use base64::Engine; - base64::engine::general_purpose::STANDARD.encode(data) + provider_options, + }) => { + let is_image = media_type.starts_with("image"); + let mut item = match data { + FileData::Data { data } => { + let b64 = match data { + FileBytes::Binary(bytes) => { + use base64::Engine; + base64::engine::general_purpose::STANDARD.encode(bytes) + } + FileBytes::Base64(data) => data.clone(), + }; + let data_url = format!("data:{media_type};base64,{b64}"); + if is_image { + json!({ "type": "input_image", "image_url": data_url }) + } else { + json!({ + "type": "input_file", + "filename": filename.as_deref().unwrap_or("part.pdf"), + "file_data": data_url, + }) + } + } + FileData::Url { url } => { + if is_image { + json!({ "type": "input_image", "image_url": url }) + } else { + json!({ "type": "input_file", "file_url": url }) + } + } + FileData::Reference { .. } | FileData::Text { .. } => { + json!({ "type": "input_text", "text": "" }) + } }; - let fname = filename.clone().unwrap_or_else(|| "part.pdf".to_string()); - json!({ - "type": "input_file", - "filename": fname, - "file_data": format!("data:{};base64,{}", media_type, b64), - }) - } - ContentPart::FileBase64 { - data, - media_type, - filename, - .. - } => { - let fname = filename.clone().unwrap_or_else(|| "part.pdf".to_string()); - json!({ - "type": "input_file", - "filename": fname, - "file_data": format!("data:{};base64,{}", media_type, data), - }) - } - ContentPart::FileUrl { - url, media_type, .. - } => { - if media_type.starts_with("image") { - json!({ "type": "input_image", "image_url": url }) - } else { - json!({ "type": "input_file", "file_url": url }) + if item["type"] == "input_image" + && matches!(data, FileData::Data { .. }) + && let Some(detail) = openai_sub_option(provider_options, "imageDetail") + { + item["detail"] = detail; } + item } - _ => json!({ "type": "input_text", "text": "" }), } } diff --git a/aimux-providers/src/replay.rs b/aimux-providers/src/replay.rs index 1ee92f107..b44ca8b40 100644 --- a/aimux-providers/src/replay.rs +++ b/aimux-providers/src/replay.rs @@ -419,14 +419,7 @@ mod tests { let ring = aimux_core::recording::RingRecorder::with_capacity(8); let options = aimux_core::generate::GenerateTextOptions::default().into_call_options(vec![ - aimux_core::language_model_message::LanguageModelPromptMessage { - role: aimux_core::message::Role::User, - content: vec![aimux_core::content::ContentPart::Text { - text: "ping".into(), - provider_options: None, - }], - provider_options: None, - }, + aimux_core::language_model_message::LanguageModelMessage::user_text("ping"), ]); ring.record_input("c1", &options, "openai", "gpt-4o"); ring.record_provider("c1", &snap); diff --git a/aimux-providers/src/xai/convert.rs b/aimux-providers/src/xai/convert.rs index 4bd0b7f56..24ad1716a 100644 --- a/aimux-providers/src/xai/convert.rs +++ b/aimux-providers/src/xai/convert.rs @@ -5,12 +5,13 @@ //! `supports-reasoning-effort.ts`, `map-xai-finish-reason.ts`, and //! `remove-additional-properties.ts`. -use aimux_core::content::ContentPart; use aimux_core::error::AiMuxError; -use aimux_core::language_model_message::LanguageModelPrompt; -use aimux_core::message::Role; +use aimux_core::language_model_message::{ + AssistantPart, LanguageModelMessage, LanguageModelPrompt, TextPart, ToolCallPart, ToolPart, + ToolResultPart, UserPart, +}; use aimux_core::options::{CallOptions, ResponseFormat, ToolChoice}; -use aimux_core::shared::SharedProviderOptions; +use aimux_core::shared::{FileBytes, FileData, SharedProviderOptions}; use aimux_core::tool::Tool; use aimux_core::types::{FinishReason, FinishReasonUnified, ReasoningEffort, Warning}; @@ -258,17 +259,14 @@ pub fn resolve_full_media_type(media_type: &str, b64_data: &str) -> String { /// /// Returns a `String` listing the available providers when the requested key /// is absent. -pub fn resolve_provider_reference(reference: &Value, provider: &str) -> Result { - if let Some(val) = reference.get(provider) { - if let Some(s) = val.as_str() { - return Ok(s.to_string()); - } - return Ok(val.to_string()); - } - let available: Vec = reference - .as_object() - .map(|m| m.keys().cloned().collect()) - .unwrap_or_default(); +pub fn resolve_provider_reference( + reference: &std::collections::HashMap, + provider: &str, +) -> Result { + if let Some(value) = reference.get(provider) { + return Ok(value.clone()); + } + let available: Vec = reference.keys().cloned().collect(); Err(format!( "No provider reference found for provider '{}'. Available providers: {}", provider, @@ -291,44 +289,35 @@ pub fn convert_to_xai_messages( let warnings: Vec = Vec::new(); for msg in prompt { - match msg.role { - Role::System => { - let content: String = msg - .content - .iter() - .filter_map(|p| match p { - ContentPart::Text { text, .. } => Some(text.as_str()), - _ => None, - }) - .collect::>() - .join(""); + match msg { + LanguageModelMessage::System { content, .. } => { messages.push(json!({ "role": "system", "content": content })); } - Role::User => { - if msg.content.len() == 1 - && let ContentPart::Text { text, .. } = &msg.content[0] + LanguageModelMessage::User { content, .. } => { + if content.len() == 1 + && let UserPart::Text(TextPart { text, .. }) = &content[0] { messages.push(json!({ "role": "user", "content": text })); continue; } let mut user_content = Vec::new(); - for (i, part) in msg.content.iter().enumerate() { + for (i, part) in content.iter().enumerate() { user_content.push(convert_user_part(part, i)?); } messages.push(json!({ "role": "user", "content": user_content })); } - Role::Assistant => { + LanguageModelMessage::Assistant { content, .. } => { let mut text = String::new(); let mut tool_calls: Vec = Vec::new(); - for part in &msg.content { + for part in content { match part { - ContentPart::Text { text: t, .. } => text.push_str(t), - ContentPart::ToolCall { + AssistantPart::Text(TextPart { text: t, .. }) => text.push_str(t), + AssistantPart::ToolCall(ToolCallPart { tool_call_id, tool_name, input, .. - } => { + }) => { let arguments = if input.is_null() { "{}".to_string() } else { @@ -345,20 +334,20 @@ pub fn convert_to_xai_messages( } messages.push(msg_obj); } - Role::Tool => { - for part in &msg.content { - if let ContentPart::ToolResult { + LanguageModelMessage::Tool { content, .. } => { + for part in content { + let ToolPart::ToolResult(ToolResultPart { tool_call_id, result, .. - } = part - { - let content = match result { - Value::String(s) => s.clone(), - other => other.to_string(), - }; - messages.push(json!({ "role": "tool", "tool_call_id": tool_call_id, "content": content })); - } + }) = part; + let content = match result { + Value::String(s) => s.clone(), + other => other.to_string(), + }; + messages.push( + json!({ "role": "tool", "tool_call_id": tool_call_id, "content": content }), + ); } } } @@ -366,52 +355,32 @@ pub fn convert_to_xai_messages( Ok((messages, warnings)) } -fn convert_user_part(part: &ContentPart, _index: usize) -> Result { +fn convert_user_part(part: &UserPart, _index: usize) -> Result { match part { - ContentPart::Text { text, .. } => Ok(json!({ "type": "text", "text": text })), - ContentPart::Image { - image, - media_type, - provider_options, - } => { - use base64::Engine; - let b64 = base64::engine::general_purpose::STANDARD.encode(image); - convert_image_part(media_type, Some(&b64), None, provider_options) - } - ContentPart::File { - data, - media_type, - provider_options, - .. - } => { - use base64::Engine; - let b64 = base64::engine::general_purpose::STANDARD.encode(data); - convert_image_part(media_type, Some(&b64), None, provider_options) - } - ContentPart::FileBase64 { - data, - media_type, - provider_options, - .. - } => convert_image_part(media_type, Some(data), None, provider_options), - ContentPart::FileUrl { - url, - media_type, - provider_options, - } => convert_image_part(media_type, None, Some(url), provider_options), - ContentPart::FileReference { - media_type, - reference, - provider_options, - .. - } => { - let _ = media_type; - let _ = provider_options; - let file_id = resolve_provider_reference(reference, "xai") - .map_err(AiMuxError::InvalidArgument)?; - Ok(json!({ "type": "file", "file": { "file_id": file_id } })) - } - _ => Ok(Value::Null), + UserPart::Text(TextPart { text, .. }) => Ok(json!({ "type": "text", "text": text })), + UserPart::File(file) => match &file.data { + FileData::Data { + data: FileBytes::Binary(bytes), + } => { + use base64::Engine; + let b64 = base64::engine::general_purpose::STANDARD.encode(bytes); + convert_image_part(&file.media_type, Some(&b64), None, &file.provider_options) + } + FileData::Data { + data: FileBytes::Base64(data), + } => convert_image_part(&file.media_type, Some(data), None, &file.provider_options), + FileData::Url { url } => { + convert_image_part(&file.media_type, None, Some(url), &file.provider_options) + } + FileData::Reference { reference } => { + let file_id = resolve_provider_reference(reference, "xai") + .map_err(AiMuxError::InvalidArgument)?; + Ok(json!({ "type": "file", "file": { "file_id": file_id } })) + } + FileData::Text { .. } => Err(AiMuxError::UnsupportedFunctionality( + "text file parts".into(), + )), + }, } } diff --git a/aimux-providers/src/xai/responses/convert.rs b/aimux-providers/src/xai/responses/convert.rs index 9297d9f40..0ba00160c 100644 --- a/aimux-providers/src/xai/responses/convert.rs +++ b/aimux-providers/src/xai/responses/convert.rs @@ -6,12 +6,13 @@ //! - `map-xai-responses-finish-reason.ts` //! - `xai-responses-prepare-tools.ts` -use aimux_core::content::ContentPart; use aimux_core::error::AiMuxError; -use aimux_core::language_model_message::LanguageModelPrompt; -use aimux_core::message::Role; +use aimux_core::language_model_message::{ + AssistantPart, LanguageModelMessage, LanguageModelPrompt, ReasoningPart, TextPart, + ToolCallPart, ToolPart, ToolResultPart, UserPart, +}; use aimux_core::options::{CallOptions, ResponseFormat, ToolChoice}; -use aimux_core::shared::SharedProviderOptions; +use aimux_core::shared::{FileBytes, FileData, SharedProviderOptions}; use aimux_core::tool::Tool; use aimux_core::types::{FinishReasonUnified, ReasoningEffort, Warning}; @@ -295,114 +296,77 @@ pub fn convert_to_xai_responses_input( let mut warnings: Vec = Vec::new(); for msg in prompt { - match msg.role { - Role::System => { - let content: String = msg - .content - .iter() - .filter_map(|p| match p { - ContentPart::Text { text, .. } => Some(text.as_str()), - _ => None, - }) - .collect::>() - .join(""); + match msg { + LanguageModelMessage::System { content, .. } => { input.push(json!({ "role": "system", "content": content })); } - Role::User => { + LanguageModelMessage::User { content, .. } => { let mut content_parts = Vec::new(); - for part in &msg.content { + for part in content { match part { - ContentPart::Text { text, .. } => { + UserPart::Text(TextPart { text, .. }) => { content_parts.push(json!({ "type": "input_text", "text": text })); } - ContentPart::Image { - image, - media_type, - provider_options, - } => { - use base64::Engine; - let b64 = base64::engine::general_purpose::STANDARD.encode(image); - content_parts.push(convert_image_part( - media_type, - Some(&b64), - None, - provider_options, - )); - } - ContentPart::File { - data, - media_type, - provider_options, - .. - } => { - use base64::Engine; - let b64 = base64::engine::general_purpose::STANDARD.encode(data); - content_parts.push(convert_image_part( - media_type, - Some(&b64), - None, - provider_options, - )); - } - ContentPart::FileBase64 { - data, - media_type, - provider_options, - .. - } => { - content_parts.push(convert_image_part( - media_type, - Some(data), - None, - provider_options, - )); - } - ContentPart::FileUrl { - url, - media_type, - provider_options, - } => { - let top_level = media_type.split('/').next().unwrap_or(""); - if top_level == "image" { - content_parts.push(convert_image_part( - media_type, + UserPart::File(file) => { + content_parts.push(match &file.data { + FileData::Data { + data: FileBytes::Binary(bytes), + } => { + use base64::Engine; + let b64 = + base64::engine::general_purpose::STANDARD.encode(bytes); + convert_image_part( + &file.media_type, + Some(&b64), + None, + &file.provider_options, + ) + } + FileData::Data { + data: FileBytes::Base64(data), + } => convert_image_part( + &file.media_type, + Some(data), None, - Some(url), - provider_options, - )); - } else { - content_parts - .push(json!({ "type": "input_file", "file_url": url })); - } - } - ContentPart::FileReference { - media_type, - reference, - provider_options, - .. - } => { - let _ = (media_type, provider_options); - let file_id = resolve_provider_reference(reference, "xai") - .map_err(AiMuxError::InvalidArgument)?; - content_parts.push(json!({ "type": "input_file", "file_id": file_id })); - } - _ => { - warnings.push(Warning::Other { - message: "xAI Responses API does not support this content type in user messages".to_string(), + &file.provider_options, + ), + FileData::Url { url } + if file.media_type.split('/').next() == Some("image") => + { + convert_image_part( + &file.media_type, + None, + Some(url), + &file.provider_options, + ) + } + FileData::Url { url } => { + json!({ "type": "input_file", "file_url": url }) + } + FileData::Reference { reference } => { + let file_id = resolve_provider_reference(reference, "xai") + .map_err(AiMuxError::InvalidArgument)?; + json!({ "type": "input_file", "file_id": file_id }) + } + FileData::Text { .. } => { + return Err(AiMuxError::UnsupportedFunctionality( + "text file parts".into(), + )); + } }); } } } input.push(json!({ "role": "user", "content": content_parts })); } - Role::Assistant => { - for part in &msg.content { + LanguageModelMessage::Assistant { content, .. } => { + for part in content { match part { - ContentPart::Text { + AssistantPart::Text(TextPart { text, provider_options, .. - } => { + }) => { let id = provider_options .as_ref() .and_then(|po| po.get("xai")) @@ -415,14 +379,14 @@ pub fn convert_to_xai_responses_input( } input.push(msg_obj); } - ContentPart::ToolCall { + AssistantPart::ToolCall(ToolCallPart { tool_call_id, tool_name, input: tool_input, provider_executed, provider_options, .. - } => { + }) => { // Skip provider-executed tool calls. // The standardized field is authoritative. Fall back // to the legacy provider option only when it is absent. @@ -458,12 +422,12 @@ pub fn convert_to_xai_responses_input( "status": "completed" })); } - ContentPart::ToolResult { .. } => {} - ContentPart::Reasoning { + AssistantPart::ToolResult(_) => {} + AssistantPart::Reasoning(ReasoningPart { text, provider_options, .. - } => { + }) => { let item_id = provider_options .as_ref() .and_then(|po| po.get("xai")) @@ -506,24 +470,22 @@ pub fn convert_to_xai_responses_input( } } } - Role::Tool => { - for part in &msg.content { - if let ContentPart::ToolResult { + LanguageModelMessage::Tool { content, .. } => { + for part in content { + let ToolPart::ToolResult(ToolResultPart { tool_call_id, result, .. - } = part - { - let output_value = match result { - Value::String(s) => s.clone(), - other => other.to_string(), - }; - input.push(json!({ - "type": "function_call_output", - "call_id": tool_call_id, - "output": output_value - })); - } + }) = part; + let output_value = match result { + Value::String(s) => s.clone(), + other => other.to_string(), + }; + input.push(json!({ + "type": "function_call_output", + "call_id": tool_call_id, + "output": output_value + })); } } } diff --git a/aimux-providers/tests/alibaba_test.rs b/aimux-providers/tests/alibaba_test.rs index 7afe919c2..92532542d 100644 --- a/aimux-providers/tests/alibaba_test.rs +++ b/aimux-providers/tests/alibaba_test.rs @@ -31,11 +31,12 @@ use serial_test::serial; use wiremock::matchers::{header, method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; -use aimux_core::content::ContentPart; use aimux_core::error::AiMuxError; use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; +use aimux_core::language_model_message::{ + AssistantPart, LanguageModelMessage, LanguageModelPrompt, ToolCallPart, ToolPart, + ToolResultPart, +}; use aimux_core::options::CallOptions; use aimux_core::result::GenerateContent; use aimux_core::stream_part::StreamPart; @@ -47,11 +48,7 @@ use aimux_providers::{ProviderOptions, provider, provider_from_env}; /// TS `TEST_PROMPT`: a single user text message "Hello". fn test_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] + vec![LanguageModelMessage::user_text("Hello")] } fn default_options(prompt: LanguageModelPrompt) -> CallOptions { @@ -299,21 +296,19 @@ async fn converts_tool_call_and_tool_result_messages() { let model = make_provider(&server); let prompt: LanguageModelPrompt = vec![ - LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::ToolCall { + LanguageModelMessage::Assistant { + content: vec![AssistantPart::ToolCall(ToolCallPart { tool_call_id: "call-1".to_string(), tool_name: "get_weather".to_string(), input: json!({"location": "SF"}), provider_executed: None, thought_signature: None, provider_options: None, - }], - ..Default::default() + })], + provider_options: None, }, - LanguageModelPromptMessage { - role: Role::Tool, - content: vec![ContentPart::ToolResult { + LanguageModelMessage::Tool { + content: vec![ToolPart::ToolResult(ToolResultPart { tool_call_id: "call-1".to_string(), result: json!({"temp": 72}), tool_name: None, @@ -321,8 +316,8 @@ async fn converts_tool_call_and_tool_result_messages() { preliminary: None, dynamic: None, provider_options: None, - }], - ..Default::default() + })], + provider_options: None, }, ]; let _ = model.do_generate(&default_options(prompt)).await.unwrap(); diff --git a/aimux-providers/tests/baseten_test.rs b/aimux-providers/tests/baseten_test.rs index a1bb20359..7e902bf24 100644 --- a/aimux-providers/tests/baseten_test.rs +++ b/aimux-providers/tests/baseten_test.rs @@ -26,20 +26,14 @@ use serial_test::serial; use wiremock::matchers::{header, method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; -use aimux_core::content::ContentPart; use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; +use aimux_core::language_model_message::{LanguageModelMessage, LanguageModelPrompt}; use aimux_core::options::CallOptions; use aimux_providers::{ProviderOptions, provider, provider_from_env}; fn test_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] + vec![LanguageModelMessage::user_text("Hello")] } fn default_options(prompt: LanguageModelPrompt) -> CallOptions { diff --git a/aimux-providers/tests/bedrock_stream_replay_test.rs b/aimux-providers/tests/bedrock_stream_replay_test.rs index 880761d37..5fe4eafa6 100644 --- a/aimux-providers/tests/bedrock_stream_replay_test.rs +++ b/aimux-providers/tests/bedrock_stream_replay_test.rs @@ -24,10 +24,8 @@ use common::replay::mount_cassettes; use futures::StreamExt; use wiremock::MockServer; -use aimux_core::content::ContentPart; use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; +use aimux_core::language_model_message::{LanguageModelMessage, LanguageModelPrompt}; use aimux_core::options::CallOptions; use aimux_core::stream_part::StreamPart; use aimux_providers::{BedrockProvider, BedrockProviderConfig}; @@ -38,11 +36,9 @@ use aimux_providers::{BedrockProvider, BedrockProviderConfig}; const MODEL_ID: &str = "us.anthropic.claude-sonnet-4-5-20250929-v1:0"; fn prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("What is the capital of France?")], - ..Default::default() - }] + vec![LanguageModelMessage::user_text( + "What is the capital of France?", + )] } /// Decoded frames must reach the parser and come out as text plus a finish. diff --git a/aimux-providers/tests/cerebras_test.rs b/aimux-providers/tests/cerebras_test.rs index 77188c825..b58d5ad16 100644 --- a/aimux-providers/tests/cerebras_test.rs +++ b/aimux-providers/tests/cerebras_test.rs @@ -25,20 +25,14 @@ use serial_test::serial; use wiremock::matchers::{header, method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; -use aimux_core::content::ContentPart; use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; +use aimux_core::language_model_message::{LanguageModelMessage, LanguageModelPrompt}; use aimux_core::options::CallOptions; use aimux_providers::{ProviderOptions, provider, provider_from_env}; fn test_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] + vec![LanguageModelMessage::user_text("Hello")] } fn default_options(prompt: LanguageModelPrompt) -> CallOptions { diff --git a/aimux-providers/tests/codex_test.rs b/aimux-providers/tests/codex_test.rs index fd3a33547..9716439ad 100644 --- a/aimux-providers/tests/codex_test.rs +++ b/aimux-providers/tests/codex_test.rs @@ -14,11 +14,9 @@ use serde_json::{Value, json}; use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; -use aimux_core::content::ContentPart; use aimux_core::error::AiMuxError; use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; +use aimux_core::language_model_message::{LanguageModelMessage, LanguageModelPrompt}; use aimux_core::options::{CallOptions, Tool, ToolChoice}; use aimux_core::result::{GenerateContent, StreamResult}; use aimux_core::stream_part::StreamPart; @@ -30,11 +28,7 @@ use aimux_providers::{CODEX_OAUTH_TOKEN_URL, CodexConfig, CodexProvider, codex_r // helpers fn test_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] + vec![LanguageModelMessage::user_text("Hello")] } fn default_options(prompt: LanguageModelPrompt) -> CallOptions { diff --git a/aimux-providers/tests/conformance_test.rs b/aimux-providers/tests/conformance_test.rs index 42f571ffe..5fe52f920 100644 --- a/aimux-providers/tests/conformance_test.rs +++ b/aimux-providers/tests/conformance_test.rs @@ -15,10 +15,8 @@ use common::replay::mount_cassettes; use futures::StreamExt; use wiremock::MockServer; -use aimux_core::content::ContentPart; use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; +use aimux_core::language_model_message::{LanguageModelMessage, LanguageModelPrompt}; use aimux_core::options::CallOptions; use aimux_core::result::GenerateContent; use aimux_core::stream_part::StreamPart; @@ -35,11 +33,7 @@ use aimux_providers::{ // 閳光偓閳光偓 helpers 閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓 fn test_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] + vec![LanguageModelMessage::user_text("Hello")] } fn default_options(prompt: LanguageModelPrompt) -> CallOptions { diff --git a/aimux-providers/tests/convert_golden_test.rs b/aimux-providers/tests/convert_golden_test.rs index 0a3ae341e..dc6adeabe 100644 --- a/aimux-providers/tests/convert_golden_test.rs +++ b/aimux-providers/tests/convert_golden_test.rs @@ -8,9 +8,7 @@ use serde_json::json; -use aimux_core::content::ContentPart; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; +use aimux_core::language_model_message::{LanguageModelMessage, LanguageModelPrompt}; use aimux_core::options::{CallOptions, ResponseFormat, ToolChoice}; use aimux_core::tool::{FunctionTool, Tool}; @@ -19,11 +17,7 @@ use aimux_providers::openai::convert::build_request_body_with_warnings as openai use aimux_providers::openai::responses::convert::build_responses_request_body; fn user_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] + vec![LanguageModelMessage::user_text("Hello")] } fn weather_tool() -> FunctionTool { diff --git a/aimux-providers/tests/data_loss_regression_test.rs b/aimux-providers/tests/data_loss_regression_test.rs index e9c35c5cd..73e64c2b0 100644 --- a/aimux-providers/tests/data_loss_regression_test.rs +++ b/aimux-providers/tests/data_loss_regression_test.rs @@ -32,10 +32,8 @@ use serde_json::{Value, json}; use wiremock::matchers::{method, path as path_matcher, query_param}; use wiremock::{Mock, MockServer, ResponseTemplate}; -use aimux_core::content::ContentPart; use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; +use aimux_core::language_model_message::{LanguageModelMessage, LanguageModelPrompt}; use aimux_core::options::CallOptions; use aimux_core::result::{GenerateContent, GeneratedFile, ReasoningOutput, Source, StreamResult}; use aimux_core::shared::{FileBytes, FileData}; @@ -121,11 +119,7 @@ fn cassette_json(rel: &str) -> Value { // ═══════════════════════════════════════════════════════════════════════════ fn user_prompt(text: &str) -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text(text)], - ..Default::default() - }] + vec![LanguageModelMessage::user_text(text)] } fn opts() -> CallOptions { diff --git a/aimux-providers/tests/deepinfra_test.rs b/aimux-providers/tests/deepinfra_test.rs index abdbce70c..a1d6e3799 100644 --- a/aimux-providers/tests/deepinfra_test.rs +++ b/aimux-providers/tests/deepinfra_test.rs @@ -24,20 +24,14 @@ use serial_test::serial; use wiremock::matchers::{header, method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; -use aimux_core::content::ContentPart; use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; +use aimux_core::language_model_message::{LanguageModelMessage, LanguageModelPrompt}; use aimux_core::options::CallOptions; use aimux_providers::{ProviderOptions, provider, provider_from_env}; fn test_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] + vec![LanguageModelMessage::user_text("Hello")] } fn default_options(prompt: LanguageModelPrompt) -> CallOptions { diff --git a/aimux-providers/tests/deepseek_chat_test.rs b/aimux-providers/tests/deepseek_chat_test.rs index 166fd70b8..418e66bc3 100644 --- a/aimux-providers/tests/deepseek_chat_test.rs +++ b/aimux-providers/tests/deepseek_chat_test.rs @@ -34,12 +34,14 @@ use serde_json::{Value, json}; use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; -use aimux_core::content::ContentPart; use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; +use aimux_core::language_model_message::{ + AssistantPart, FilePart, LanguageModelMessage, LanguageModelPrompt, TextPart, ToolCallPart, + ToolPart, ToolResultPart, UserPart, +}; use aimux_core::options::{CallOptions, ResponseFormat, Tool}; use aimux_core::result::GenerateContent; +use aimux_core::shared::{FileBytes, FileData}; use aimux_core::stream_part::StreamPart; use aimux_core::tool::FunctionTool; use aimux_core::types::FinishReasonUnified; @@ -54,11 +56,7 @@ use aimux_providers::{ProviderOptions, provider}; /// The TS `TEST_PROMPT`: a single user text message "Hello". fn test_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] + vec![LanguageModelMessage::user_text("Hello")] } /// `CallOptions` with only `prompt` set (everything else default/None). @@ -273,16 +271,11 @@ async fn should_send_correct_text_request_body() { let model = make_provider(&server, "deepseek-chat"); let prompt = vec![ - LanguageModelPromptMessage { - role: Role::System, - content: vec![ContentPart::text("You are a helpful assistant.")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() + LanguageModelMessage::System { + content: "You are a helpful assistant.".into(), + provider_options: None, }, + LanguageModelMessage::user_text("Hello"), ]; let mut options = default_options(prompt); options.temperature = Some(0.5); @@ -574,16 +567,11 @@ async fn should_send_correct_stream_request_body() { let model = make_provider(&server, "deepseek-chat"); let prompt = vec![ - LanguageModelPromptMessage { - role: Role::System, - content: vec![ContentPart::text("You are a helpful assistant.")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() + LanguageModelMessage::System { + content: "You are a helpful assistant.".into(), + provider_options: None, }, + LanguageModelMessage::user_text("Hello"), ]; let mut options = default_options(prompt); options.temperature = Some(0.5); @@ -804,13 +792,22 @@ async fn should_convert_user_text_part_to_string_content() { /// TS: file parts with image media type are converted to image_url (OpenAI format). #[tokio::test] async fn should_convert_image_file_parts_to_image_url() { - let prompt = vec![LanguageModelPromptMessage { - role: Role::User, + let prompt = vec![LanguageModelMessage::User { content: vec![ - ContentPart::text("Hello"), - ContentPart::file_base64("AAECAw==", "image/png"), + UserPart::Text(TextPart { + text: "Hello".into(), + provider_options: None, + }), + UserPart::File(FilePart { + data: FileData::Data { + data: FileBytes::Base64("AAECAw==".into()), + }, + media_type: "image/png".into(), + filename: None, + provider_options: None, + }), ], - ..Default::default() + provider_options: None, }]; let options = default_options(prompt); let result = build_request_body_with_warnings( @@ -842,13 +839,22 @@ async fn should_convert_image_file_parts_to_image_url() { /// NOT include any image/mediaType data in the output. #[tokio::test] async fn should_accept_top_level_only_mediatype_without_error() { - let prompt = vec![LanguageModelPromptMessage { - role: Role::User, + let prompt = vec![LanguageModelMessage::User { content: vec![ - ContentPart::text("Hello"), - ContentPart::file_base64("AAECAw==", "image"), + UserPart::Text(TextPart { + text: "Hello".into(), + provider_options: None, + }), + UserPart::File(FilePart { + data: FileData::Data { + data: FileBytes::Base64("AAECAw==".into()), + }, + media_type: "image".into(), + filename: None, + provider_options: None, + }), ], - ..Default::default() + provider_options: None, }]; let options = default_options(prompt); let result = build_request_body_with_warnings( @@ -877,19 +883,28 @@ async fn should_accept_top_level_only_mediatype_without_error() { #[tokio::test] async fn should_stringify_arguments_to_tool_calls() { let prompt = vec![ - LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::tool_call( - "quux", - "thwomp", - json!({ "foo": "bar123" }), - )], - ..Default::default() + LanguageModelMessage::Assistant { + content: vec![AssistantPart::ToolCall(ToolCallPart { + tool_call_id: "quux".into(), + tool_name: "thwomp".into(), + input: json!({ "foo": "bar123" }), + provider_executed: None, + thought_signature: None, + provider_options: None, + })], + provider_options: None, }, - LanguageModelPromptMessage { - role: Role::Tool, - content: vec![ContentPart::tool_result("quux", json!({ "oof": "321rab" }))], - ..Default::default() + LanguageModelMessage::Tool { + content: vec![ToolPart::ToolResult(ToolResultPart { + tool_call_id: "quux".into(), + result: json!({ "oof": "321rab" }), + tool_name: None, + is_error: None, + preliminary: None, + dynamic: None, + provider_options: None, + })], + provider_options: None, }, ]; let options = default_options(prompt); @@ -936,22 +951,28 @@ async fn should_stringify_arguments_to_tool_calls() { #[tokio::test] async fn should_handle_text_output_type_in_tool_results() { let prompt = vec![ - LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::tool_call( - "call-1", - "getWeather", - json!({ "query": "weather" }), - )], - ..Default::default() + LanguageModelMessage::Assistant { + content: vec![AssistantPart::ToolCall(ToolCallPart { + tool_call_id: "call-1".into(), + tool_name: "getWeather".into(), + input: json!({ "query": "weather" }), + provider_executed: None, + thought_signature: None, + provider_options: None, + })], + provider_options: None, }, - LanguageModelPromptMessage { - role: Role::Tool, - content: vec![ContentPart::tool_result( - "call-1", - json!("It is sunny today"), - )], - ..Default::default() + LanguageModelMessage::Tool { + content: vec![ToolPart::ToolResult(ToolResultPart { + tool_call_id: "call-1".into(), + result: json!("It is sunny today"), + tool_name: None, + is_error: None, + preliminary: None, + dynamic: None, + provider_options: None, + })], + provider_options: None, }, ]; let options = default_options(prompt); diff --git a/aimux-providers/tests/fireworks_test.rs b/aimux-providers/tests/fireworks_test.rs index f927626f1..7261fd73c 100644 --- a/aimux-providers/tests/fireworks_test.rs +++ b/aimux-providers/tests/fireworks_test.rs @@ -28,20 +28,14 @@ use serial_test::serial; use wiremock::matchers::{header, method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; -use aimux_core::content::ContentPart; use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; +use aimux_core::language_model_message::{LanguageModelMessage, LanguageModelPrompt}; use aimux_core::options::CallOptions; use aimux_providers::{ProviderOptions, provider, provider_from_env}; fn test_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] + vec![LanguageModelMessage::user_text("Hello")] } fn default_options(prompt: LanguageModelPrompt) -> CallOptions { diff --git a/aimux-providers/tests/groq_test.rs b/aimux-providers/tests/groq_test.rs index 326409b88..1a5026946 100644 --- a/aimux-providers/tests/groq_test.rs +++ b/aimux-providers/tests/groq_test.rs @@ -24,13 +24,14 @@ use serde_json::{Value, json}; use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; -use aimux_core::content::ContentPart; use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; +use aimux_core::language_model_message::{ + AssistantPart, FilePart, LanguageModelMessage, LanguageModelPrompt, ReasoningPart, TextPart, + ToolCallPart, ToolPart, ToolResultPart, UserPart, +}; use aimux_core::options::{CallOptions, ResponseFormat, Tool, ToolChoice}; use aimux_core::result::{GenerateContent, ReasoningOutput}; -use aimux_core::shared::provider_namespace; +use aimux_core::shared::{FileBytes, FileData, provider_namespace}; use aimux_core::stream_part::StreamPart; use aimux_core::tool::FunctionTool; use aimux_core::types::{FinishReasonUnified, ReasoningEffort}; @@ -41,11 +42,7 @@ use aimux_providers::{ProviderOptions, provider}; /// The TS `TEST_PROMPT`: a single user text message "Hello". fn test_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] + vec![LanguageModelMessage::user_text("Hello")] } /// `CallOptions` with only `prompt` set. @@ -237,13 +234,22 @@ mod convert_messages { let model = make_provider(&server); - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::User, + let prompt: LanguageModelPrompt = vec![LanguageModelMessage::User { content: vec![ - ContentPart::text("Hello"), - ContentPart::file_base64("AAECAw==", "image/png"), + UserPart::Text(TextPart { + text: "Hello".into(), + provider_options: None, + }), + UserPart::File(FilePart { + data: FileData::Data { + data: FileBytes::Base64("AAECAw==".into()), + }, + media_type: "image/png".into(), + filename: None, + provider_options: None, + }), ], - ..Default::default() + provider_options: None, }]; model.do_generate(&default_options(prompt)).await.unwrap(); @@ -288,19 +294,28 @@ mod convert_messages { let model = make_provider(&server); let prompt: LanguageModelPrompt = vec![ - LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::tool_call( - "quux", - "thwomp", - json!({"foo":"bar123"}), - )], - ..Default::default() + LanguageModelMessage::Assistant { + content: vec![AssistantPart::ToolCall(ToolCallPart { + tool_call_id: "quux".into(), + tool_name: "thwomp".into(), + input: json!({"foo":"bar123"}), + provider_executed: None, + thought_signature: None, + provider_options: None, + })], + provider_options: None, }, - LanguageModelPromptMessage { - role: Role::Tool, - content: vec![ContentPart::tool_result("quux", json!({"oof":"321rab"}))], - ..Default::default() + LanguageModelMessage::Tool { + content: vec![ToolPart::ToolResult(ToolResultPart { + tool_call_id: "quux".into(), + result: json!({"oof":"321rab"}), + tool_name: None, + is_error: None, + preliminary: None, + dynamic: None, + provider_options: None, + })], + provider_options: None, }, ]; model.do_generate(&default_options(prompt)).await.unwrap(); @@ -333,13 +348,23 @@ mod convert_messages { let model = make_provider(&server); - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::Assistant, + let prompt: LanguageModelPrompt = vec![LanguageModelMessage::Assistant { content: vec![ - ContentPart::reasoning("I think the tool will return the correct value."), - ContentPart::tool_call("quux", "thwomp", json!({"foo":"bar123"})), + AssistantPart::Reasoning(ReasoningPart { + text: "I think the tool will return the correct value.".into(), + signature: None, + provider_options: None, + }), + AssistantPart::ToolCall(ToolCallPart { + tool_call_id: "quux".into(), + tool_name: "thwomp".into(), + input: json!({"foo":"bar123"}), + provider_executed: None, + thought_signature: None, + provider_options: None, + }), ], - ..Default::default() + provider_options: None, }]; model.do_generate(&default_options(prompt)).await.unwrap(); @@ -362,10 +387,12 @@ mod convert_messages { let model = make_provider(&server); - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::text("Hello, how can I help you?")], - ..Default::default() + let prompt: LanguageModelPrompt = vec![LanguageModelMessage::Assistant { + content: vec![AssistantPart::Text(TextPart { + text: "Hello, how can I help you?".into(), + provider_options: None, + })], + provider_options: None, }]; model.do_generate(&default_options(prompt)).await.unwrap(); @@ -385,13 +412,16 @@ mod convert_messages { let model = make_provider(&server); - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::file_reference( - "image/png", - json!({"groq": "file-ref-123"}), - )], - ..Default::default() + let prompt: LanguageModelPrompt = vec![LanguageModelMessage::User { + content: vec![UserPart::File(FilePart { + data: FileData::Reference { + reference: [("groq".into(), "file-ref-123".into())].into(), + }, + media_type: "image/png".into(), + filename: None, + provider_options: None, + })], + provider_options: None, }]; // Groq references don't contain an "openai" key, so the converter // returns an InvalidArgument error instead of panicking. diff --git a/aimux-providers/tests/huggingface_responses_test.rs b/aimux-providers/tests/huggingface_responses_test.rs index 190613acf..ae236cd06 100644 --- a/aimux-providers/tests/huggingface_responses_test.rs +++ b/aimux-providers/tests/huggingface_responses_test.rs @@ -24,14 +24,16 @@ use serde_json::{Value, json}; use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; -use aimux_core::content::ContentPart; use aimux_core::error::AiMuxError; use aimux_core::generate::{GenerateTextOptions, generate_text}; use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; +use aimux_core::language_model_message::{ + AssistantPart, FilePart, LanguageModelMessage, LanguageModelPrompt, ReasoningPart, TextPart, + ToolCallPart, ToolPart, ToolResultPart, UserPart, +}; use aimux_core::options::{CallOptions, ResponseFormat, Tool, ToolChoice}; use aimux_core::result::{GenerateContent, ReasoningOutput, Source}; +use aimux_core::shared::{FileBytes, FileData}; use aimux_core::stream_part::StreamPart; use aimux_core::tool::FunctionTool; use aimux_core::types::{FinishReasonUnified, ResponseMetadata}; @@ -45,11 +47,7 @@ use aimux_providers::{HuggingFaceConfig, HuggingFaceProvider}; /// The TS `TEST_PROMPT`: a single user text message "Hello". fn test_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] + vec![LanguageModelMessage::user_text("Hello")] } /// `CallOptions` with only `prompt` set (everything else default/None). @@ -317,16 +315,11 @@ async fn should_send_model_id_settings_and_input() { let model = provider.responses_model("deepseek-ai/DeepSeek-V3-0324"); let prompt = vec![ - LanguageModelPromptMessage { - role: Role::System, - content: vec![ContentPart::text("You are a helpful assistant.")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() + LanguageModelMessage::System { + content: "You are a helpful assistant.".into(), + provider_options: None, }, + LanguageModelMessage::user_text("Hello"), ]; let mut options = default_options(prompt); options.temperature = Some(0.5); @@ -879,13 +872,22 @@ async fn should_convert_user_messages_with_images() { let provider = make_provider(&server); let model = provider.responses_model("deepseek-ai/DeepSeek-V3-0324"); - let prompt = vec![LanguageModelPromptMessage { - role: Role::User, + let prompt = vec![LanguageModelMessage::User { content: vec![ - ContentPart::text("What do you see?"), - ContentPart::file_base64("AQIDBA==", "image/jpeg"), + 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, + }), ], - ..Default::default() + provider_options: None, }]; let result = model @@ -913,13 +915,16 @@ async fn should_throw_for_file_parts_with_provider_references() { let provider = make_provider(&server); let model = provider.responses_model("Qwen/Qwen2.5-VL-32B-Instruct"); - let prompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::file_reference( - "image/jpeg", - json!({ "huggingface": "file-ref-123" }), - )], - ..Default::default() + let prompt = vec![LanguageModelMessage::User { + content: vec![UserPart::File(FilePart { + data: FileData::Reference { + reference: [("huggingface".into(), "file-ref-123".into())].into(), + }, + media_type: "image/jpeg".into(), + filename: None, + provider_options: None, + })], + provider_options: None, }]; let result = model.do_generate(&default_options(prompt)).await; @@ -943,21 +948,15 @@ async fn should_handle_assistant_messages() { let model = provider.responses_model("deepseek-ai/DeepSeek-V3-0324"); let prompt = vec![ - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::text("Hi there!")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("How are you?")], - ..Default::default() + 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 @@ -987,14 +986,32 @@ async fn should_not_warn_about_assistant_content_types() { let provider = make_provider(&server); let model = provider.responses_model("deepseek-ai/DeepSeek-V3-0324"); - let prompt = vec![LanguageModelPromptMessage { - role: Role::Assistant, + let prompt = vec![LanguageModelMessage::Assistant { content: vec![ - ContentPart::tool_call("test", "test", json!({})), - ContentPart::tool_result("test", json!({ "type": "text", "value": "test" })), - ContentPart::reasoning("thinking..."), + AssistantPart::ToolCall(ToolCallPart { + tool_call_id: "test".into(), + tool_name: "test".into(), + input: json!({}), + provider_executed: None, + thought_signature: None, + provider_options: None, + }), + AssistantPart::ToolResult(ToolResultPart { + tool_call_id: "test".into(), + tool_name: None, + result: json!({ "type": "text", "value": "test" }), + is_error: None, + preliminary: None, + dynamic: None, + provider_options: None, + }), + AssistantPart::Reasoning(ReasoningPart { + text: "thinking...".into(), + signature: None, + provider_options: None, + }), ], - ..Default::default() + provider_options: None, }]; let result = model @@ -1020,13 +1037,17 @@ async fn should_warn_about_tool_messages() { let provider = make_provider(&server); let model = provider.responses_model("deepseek-ai/DeepSeek-V3-0324"); - let prompt = vec![LanguageModelPromptMessage { - role: Role::Tool, - content: vec![ContentPart::tool_result( - "test", - json!({ "type": "text", "value": "test" }), - )], - ..Default::default() + let prompt = vec![LanguageModelMessage::Tool { + content: vec![ToolPart::ToolResult(ToolResultPart { + tool_call_id: "test".into(), + tool_name: None, + result: json!({ "type": "text", "value": "test" }), + is_error: None, + preliminary: None, + dynamic: None, + provider_options: None, + })], + provider_options: None, }]; let result = model @@ -1776,10 +1797,16 @@ 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![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::file_base64(PNG_BASE64, "image/png")], - ..Default::default() + 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) = @@ -1799,10 +1826,16 @@ fn passes_full_image_png_through_unchanged_for_inline_data() { /// 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![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::file_base64(PNG_BASE64, "image")], - ..Default::default() + 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) = @@ -1822,10 +1855,16 @@ fn detects_image_subtype_from_inline_bytes_for_top_level_image() { /// TS: "passes through URL source for top-level-only image" #[test] fn passes_through_url_source_for_top_level_only_image() { - let prompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::file_url("https://example.com/x.png", "image")], - ..Default::default() + let prompt = vec![LanguageModelMessage::User { + content: vec![UserPart::File(FilePart { + data: FileData::Url { + url: "https://example.com/x.png".into(), + }, + media_type: "image".into(), + filename: None, + provider_options: None, + })], + provider_options: None, }]; let (input, warnings) = @@ -1845,10 +1884,16 @@ fn passes_through_url_source_for_top_level_only_image() { /// TS: "normalizes image/* wildcard via detection" #[test] fn normalizes_image_wildcard_via_detection() { - let prompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::file_base64(PNG_BASE64, "image/*")], - ..Default::default() + 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) = diff --git a/aimux-providers/tests/moonshotai_test.rs b/aimux-providers/tests/moonshotai_test.rs index 8b6bbb8ad..0760eb048 100644 --- a/aimux-providers/tests/moonshotai_test.rs +++ b/aimux-providers/tests/moonshotai_test.rs @@ -23,10 +23,8 @@ use serial_test::serial; use wiremock::matchers::{header, method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; -use aimux_core::content::ContentPart; use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; +use aimux_core::language_model_message::{LanguageModelMessage, LanguageModelPrompt}; use aimux_core::options::CallOptions; use aimux_core::result::GenerateContent; use aimux_core::stream_part::StreamPart; @@ -37,11 +35,7 @@ use aimux_providers::{ProviderOptions, provider, provider_from_env}; // ── shared helpers ─────────────────────────────────────────────────────────── fn test_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] + vec![LanguageModelMessage::user_text("Hello")] } fn default_options(prompt: LanguageModelPrompt) -> CallOptions { diff --git a/aimux-providers/tests/open_responses_test.rs b/aimux-providers/tests/open_responses_test.rs index 576f3a911..ddd1333be 100644 --- a/aimux-providers/tests/open_responses_test.rs +++ b/aimux-providers/tests/open_responses_test.rs @@ -17,12 +17,14 @@ use serde_json::{Value, json}; use wiremock::matchers::method; use wiremock::{Mock, MockServer, ResponseTemplate}; -use aimux_core::content::ContentPart; use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; +use aimux_core::language_model_message::{ + AssistantPart, FilePart, LanguageModelMessage, LanguageModelPrompt, TextPart, ToolCallPart, + ToolPart, ToolResultPart, UserPart, +}; use aimux_core::options::{CallOptions, ResponseFormat, Tool, ToolChoice}; use aimux_core::result::{GenerateContent, ReasoningOutput}; +use aimux_core::shared::{FileBytes, FileData}; use aimux_core::stream_part::StreamPart; use aimux_core::tool::FunctionTool; use aimux_core::types::{FinishReasonUnified, ReasoningEffort}; @@ -38,11 +40,7 @@ use aimux_providers::open_responses::{ /// The TS `TEST_PROMPT`: a single user text message "Hello". fn test_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] + vec![LanguageModelMessage::user_text("Hello")] } /// `CallOptions` with only `prompt` set (everything else default/None). @@ -326,10 +324,9 @@ mod convert_tests { #[test] fn convert_single_system_message_to_instructions() { - let prompt = vec![LanguageModelPromptMessage { - role: Role::System, - content: vec![ContentPart::text("You are a helpful assistant.")], - ..Default::default() + let prompt = vec![LanguageModelMessage::System { + content: "You are a helpful assistant.".into(), + provider_options: None, }]; let (input, instructions, warnings) = convert_to_open_responses_input(&prompt); assert_eq!( @@ -343,15 +340,13 @@ mod convert_tests { #[test] fn convert_multiple_system_messages_joined_with_newlines() { let prompt = vec![ - LanguageModelPromptMessage { - role: Role::System, - content: vec![ContentPart::text("You are a helpful assistant.")], - ..Default::default() + LanguageModelMessage::System { + content: "You are a helpful assistant.".into(), + provider_options: None, }, - LanguageModelPromptMessage { - role: Role::System, - content: vec![ContentPart::text("Always be concise.")], - ..Default::default() + LanguageModelMessage::System { + content: "Always be concise.".into(), + provider_options: None, }, ]; let (_, instructions, _) = convert_to_open_responses_input(&prompt); @@ -371,20 +366,17 @@ mod convert_tests { #[test] fn convert_system_message_with_user_and_assistant() { let prompt = vec![ - LanguageModelPromptMessage { - role: Role::System, - content: vec![ContentPart::text("You are a helpful assistant.")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() + LanguageModelMessage::System { + content: "You are a helpful assistant.".into(), + provider_options: None, }, - LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::text("Hi there!")], - ..Default::default() + LanguageModelMessage::user_text("Hello"), + LanguageModelMessage::Assistant { + content: vec![AssistantPart::Text(TextPart { + text: "Hi there!".into(), + provider_options: None, + })], + provider_options: None, }, ]; let (input, instructions, _) = convert_to_open_responses_input(&prompt); @@ -427,10 +419,16 @@ mod convert_tests { #[test] fn convert_image_file_base64_to_input_image() { - let prompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::file_base64("ZmFrZS1kYXRh", "image/png")], - ..Default::default() + let prompt = vec![LanguageModelMessage::User { + content: vec![UserPart::File(FilePart { + data: FileData::Data { + data: FileBytes::Base64("ZmFrZS1kYXRh".into()), + }, + media_type: "image/png".into(), + filename: None, + provider_options: None, + })], + provider_options: None, }]; let (input, _, warnings) = convert_to_open_responses_input(&prompt); assert_eq!( @@ -446,13 +444,16 @@ mod convert_tests { #[test] fn convert_image_file_url_to_input_image() { - let prompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::file_url( - "https://example.com/image.png", - "image/png", - )], - ..Default::default() + let prompt = vec![LanguageModelMessage::User { + content: vec![UserPart::File(FilePart { + data: FileData::Url { + url: "https://example.com/image.png".into(), + }, + media_type: "image/png".into(), + filename: None, + provider_options: None, + })], + provider_options: None, }]; let (input, _, _) = convert_to_open_responses_input(&prompt); assert_eq!( @@ -467,13 +468,22 @@ mod convert_tests { #[test] fn convert_pdf_file_base64_to_input_file() { - let prompt = vec![LanguageModelPromptMessage { - role: Role::User, + let prompt = vec![LanguageModelMessage::User { content: vec![ - ContentPart::text("What does this PDF say?"), - ContentPart::file_base64("UERGREFUQQ==", "application/pdf"), + UserPart::Text(TextPart { + text: "What does this PDF say?".into(), + provider_options: None, + }), + UserPart::File(FilePart { + data: FileData::Data { + data: FileBytes::Base64("UERGREFUQQ==".into()), + }, + media_type: "application/pdf".into(), + filename: None, + provider_options: None, + }), ], - ..Default::default() + provider_options: None, }]; let (input, _, warnings) = convert_to_open_responses_input(&prompt); assert_eq!( @@ -492,13 +502,16 @@ mod convert_tests { #[test] fn convert_pdf_file_url_to_input_file() { - let prompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::file_url( - "https://example.com/document.pdf", - "application/pdf", - )], - ..Default::default() + let prompt = vec![LanguageModelMessage::User { + content: vec![UserPart::File(FilePart { + data: FileData::Url { + url: "https://example.com/document.pdf".into(), + }, + media_type: "application/pdf".into(), + filename: None, + provider_options: None, + })], + provider_options: None, }]; let (input, _, warnings) = convert_to_open_responses_input(&prompt); assert_eq!( @@ -514,15 +527,16 @@ mod convert_tests { #[test] fn convert_file_with_custom_filename() { - let prompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::FileBase64 { - data: "UERGREFUQQ==".to_string(), + let prompt = vec![LanguageModelMessage::User { + content: vec![UserPart::File(FilePart { + data: FileData::Data { + data: FileBytes::Base64("UERGREFUQQ==".to_string()), + }, media_type: "application/pdf".to_string(), filename: Some("report.pdf".to_string()), provider_options: None, - }], - ..Default::default() + })], + provider_options: None, }]; let (input, _, _) = convert_to_open_responses_input(&prompt); assert_eq!( @@ -543,10 +557,12 @@ mod convert_tests { #[test] fn convert_assistant_text_part() { - let prompt = vec![LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::text("Hello from assistant")], - ..Default::default() + let prompt = vec![LanguageModelMessage::Assistant { + content: vec![AssistantPart::Text(TextPart { + text: "Hello from assistant".into(), + provider_options: None, + })], + provider_options: None, }]; let (input, _, _) = convert_to_open_responses_input(&prompt); assert_eq!( @@ -561,13 +577,18 @@ mod convert_tests { #[test] fn convert_assistant_multiple_text_parts() { - let prompt = vec![LanguageModelPromptMessage { - role: Role::Assistant, + let prompt = vec![LanguageModelMessage::Assistant { content: vec![ - ContentPart::text("First part"), - ContentPart::text("Second part"), + AssistantPart::Text(TextPart { + text: "First part".into(), + provider_options: None, + }), + AssistantPart::Text(TextPart { + text: "Second part".into(), + provider_options: None, + }), ], - ..Default::default() + provider_options: None, }]; let (input, _, _) = convert_to_open_responses_input(&prompt); assert_eq!( @@ -587,14 +608,16 @@ mod convert_tests { #[test] fn convert_assistant_single_tool_call() { - let prompt = vec![LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::tool_call( - "call_123", - "get_weather", - json!({"location": "San Francisco"}), - )], - ..Default::default() + let prompt = vec![LanguageModelMessage::Assistant { + content: vec![AssistantPart::ToolCall(ToolCallPart { + tool_call_id: "call_123".into(), + tool_name: "get_weather".into(), + input: json!({"location": "San Francisco"}), + provider_executed: None, + thought_signature: None, + provider_options: None, + })], + provider_options: None, }]; let (input, _, _) = convert_to_open_responses_input(&prompt); assert_eq!( @@ -610,14 +633,16 @@ mod convert_tests { #[test] fn convert_assistant_tool_call_string_input() { - let prompt = vec![LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::tool_call( - "call_124", - "get_weather", - Value::String("{\"location\":\"Berlin\"}".to_string()), - )], - ..Default::default() + let prompt = vec![LanguageModelMessage::Assistant { + content: vec![AssistantPart::ToolCall(ToolCallPart { + tool_call_id: "call_124".into(), + tool_name: "get_weather".into(), + input: Value::String("{\"location\":\"Berlin\"}".to_string()), + provider_executed: None, + thought_signature: None, + provider_options: None, + })], + provider_options: None, }]; let (input, _, _) = convert_to_open_responses_input(&prompt); assert_eq!( @@ -633,13 +658,22 @@ mod convert_tests { #[test] fn convert_assistant_text_and_tool_call() { - let prompt = vec![LanguageModelPromptMessage { - role: Role::Assistant, + let prompt = vec![LanguageModelMessage::Assistant { content: vec![ - ContentPart::text("Let me check the weather for you."), - ContentPart::tool_call("call_456", "get_weather", json!({"location": "New York"})), + AssistantPart::Text(TextPart { + text: "Let me check the weather for you.".into(), + provider_options: None, + }), + AssistantPart::ToolCall(ToolCallPart { + tool_call_id: "call_456".into(), + tool_name: "get_weather".into(), + input: json!({"location": "New York"}), + provider_executed: None, + thought_signature: None, + provider_options: None, + }), ], - ..Default::default() + provider_options: None, }]; let (input, _, _) = convert_to_open_responses_input(&prompt); assert_eq!( @@ -662,13 +696,26 @@ mod convert_tests { #[test] fn convert_assistant_multiple_tool_calls() { - let prompt = vec![LanguageModelPromptMessage { - role: Role::Assistant, + let prompt = vec![LanguageModelMessage::Assistant { content: vec![ - ContentPart::tool_call("call_001", "get_weather", json!({"location": "Paris"})), - ContentPart::tool_call("call_002", "get_time", json!({"timezone": "Europe/Paris"})), + AssistantPart::ToolCall(ToolCallPart { + tool_call_id: "call_001".into(), + tool_name: "get_weather".into(), + input: json!({"location": "Paris"}), + provider_executed: None, + thought_signature: None, + provider_options: None, + }), + AssistantPart::ToolCall(ToolCallPart { + tool_call_id: "call_002".into(), + tool_name: "get_time".into(), + input: json!({"timezone": "Europe/Paris"}), + provider_executed: None, + thought_signature: None, + provider_options: None, + }), ], - ..Default::default() + provider_options: None, }]; let (input, _, _) = convert_to_open_responses_input(&prompt); assert_eq!( @@ -694,13 +741,17 @@ mod convert_tests { #[test] fn convert_tool_message_json_output() { - let prompt = vec![LanguageModelPromptMessage { - role: Role::Tool, - content: vec![ContentPart::tool_result( - "call_123", - json!({"type": "json", "value": {"temperature": 72, "condition": "sunny"}}), - )], - ..Default::default() + let prompt = vec![LanguageModelMessage::Tool { + content: vec![ToolPart::ToolResult(ToolResultPart { + tool_call_id: "call_123".into(), + result: json!({"type": "json", "value": {"temperature": 72, "condition": "sunny"}}), + tool_name: None, + is_error: None, + preliminary: None, + dynamic: None, + provider_options: None, + })], + provider_options: None, }]; let (input, _, _) = convert_to_open_responses_input(&prompt); assert_eq!( @@ -715,13 +766,17 @@ mod convert_tests { #[test] fn convert_tool_message_text_output() { - let prompt = vec![LanguageModelPromptMessage { - role: Role::Tool, - content: vec![ContentPart::tool_result( - "call_456", - json!({"type": "text", "value": "Search results: Found 5 items"}), - )], - ..Default::default() + let prompt = vec![LanguageModelMessage::Tool { + content: vec![ToolPart::ToolResult(ToolResultPart { + tool_call_id: "call_456".into(), + result: json!({"type": "text", "value": "Search results: Found 5 items"}), + tool_name: None, + is_error: None, + preliminary: None, + dynamic: None, + provider_options: None, + })], + provider_options: None, }]; let (input, _, _) = convert_to_open_responses_input(&prompt); assert_eq!( @@ -736,13 +791,17 @@ mod convert_tests { #[test] fn convert_tool_message_error_text_output() { - let prompt = vec![LanguageModelPromptMessage { - role: Role::Tool, - content: vec![ContentPart::tool_result( - "call_789", - json!({"type": "error-text", "value": "API request failed: timeout"}), - )], - ..Default::default() + let prompt = vec![LanguageModelMessage::Tool { + content: vec![ToolPart::ToolResult(ToolResultPart { + tool_call_id: "call_789".into(), + result: json!({"type": "error-text", "value": "API request failed: timeout"}), + tool_name: None, + is_error: None, + preliminary: None, + dynamic: None, + provider_options: None, + })], + provider_options: None, }]; let (input, _, _) = convert_to_open_responses_input(&prompt); assert_eq!( @@ -757,13 +816,17 @@ mod convert_tests { #[test] fn convert_tool_message_execution_denied_output() { - let prompt = vec![LanguageModelPromptMessage { - role: Role::Tool, - content: vec![ContentPart::tool_result( - "call_denied", - json!({"type": "execution-denied", "reason": "User declined the action"}), - )], - ..Default::default() + let prompt = vec![LanguageModelMessage::Tool { + content: vec![ToolPart::ToolResult(ToolResultPart { + tool_call_id: "call_denied".into(), + result: json!({"type": "execution-denied", "reason": "User declined the action"}), + tool_name: None, + is_error: None, + preliminary: None, + dynamic: None, + provider_options: None, + })], + provider_options: None, }]; let (input, _, _) = convert_to_open_responses_input(&prompt); assert_eq!( @@ -778,19 +841,23 @@ mod convert_tests { #[test] fn convert_tool_message_content_output_text() { - let prompt = vec![LanguageModelPromptMessage { - role: Role::Tool, - content: vec![ContentPart::tool_result( - "call_content", - json!({ + let prompt = vec![LanguageModelMessage::Tool { + content: vec![ToolPart::ToolResult(ToolResultPart { + tool_call_id: "call_content".into(), + result: json!({ "type": "content", "value": [ {"type": "text", "text": "First result"}, {"type": "text", "text": "Second result"} ] }), - )], - ..Default::default() + tool_name: None, + is_error: None, + preliminary: None, + dynamic: None, + provider_options: None, + })], + provider_options: None, }]; let (input, _, _) = convert_to_open_responses_input(&prompt); assert_eq!( @@ -808,16 +875,28 @@ mod convert_tests { #[test] fn convert_tool_message_multiple_results() { - let prompt = vec![LanguageModelPromptMessage { - role: Role::Tool, + let prompt = vec![LanguageModelMessage::Tool { content: vec![ - ContentPart::tool_result( - "call_001", - json!({"type": "json", "value": {"temp": 72}}), - ), - ContentPart::tool_result("call_002", json!({"type": "text", "value": "3:00 PM"})), + ToolPart::ToolResult(ToolResultPart { + tool_call_id: "call_001".into(), + result: json!({"type": "json", "value": {"temp": 72}}), + tool_name: None, + is_error: None, + preliminary: None, + dynamic: None, + provider_options: None, + }), + ToolPart::ToolResult(ToolResultPart { + tool_call_id: "call_002".into(), + result: json!({"type": "text", "value": "3:00 PM"}), + tool_name: None, + is_error: None, + preliminary: None, + dynamic: None, + provider_options: None, + }), ], - ..Default::default() + provider_options: None, }]; let (input, _, _) = convert_to_open_responses_input(&prompt); assert_eq!( @@ -842,21 +921,15 @@ mod convert_tests { #[test] fn convert_user_assistant_user_chain() { let prompt = vec![ - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("What is the capital of France?")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::text("The capital of France is Paris.")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("And what about Germany?")], - ..Default::default() + LanguageModelMessage::user_text("What is the capital of France?"), + LanguageModelMessage::Assistant { + content: vec![AssistantPart::Text(TextPart { + text: "The capital of France is Paris.".into(), + provider_options: None, + })], + provider_options: None, }, + LanguageModelMessage::user_text("And what about Germany?"), ]; let (input, _, _) = convert_to_open_responses_input(&prompt); assert_eq!( @@ -872,27 +945,29 @@ mod convert_tests { #[test] fn convert_user_assistant_tool_tool_chain() { let prompt = vec![ - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("What is the weather in Tokyo?")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::tool_call( - "call_weather", - "get_weather", - json!({"location": "Tokyo"}), - )], - ..Default::default() + LanguageModelMessage::user_text("What is the weather in Tokyo?"), + LanguageModelMessage::Assistant { + content: vec![AssistantPart::ToolCall(ToolCallPart { + tool_call_id: "call_weather".into(), + tool_name: "get_weather".into(), + input: json!({"location": "Tokyo"}), + provider_executed: None, + thought_signature: None, + provider_options: None, + })], + provider_options: None, }, - LanguageModelPromptMessage { - role: Role::Tool, - content: vec![ContentPart::tool_result( - "call_weather", - json!({"type": "json", "value": {"temperature": 25, "condition": "cloudy"}}), - )], - ..Default::default() + LanguageModelMessage::Tool { + content: vec![ToolPart::ToolResult(ToolResultPart { + tool_call_id: "call_weather".into(), + result: json!({"type": "json", "value": {"temperature": 25, "condition": "cloudy"}}), + tool_name: None, + is_error: None, + preliminary: None, + dynamic: None, + provider_options: None, + })], + provider_options: None, }, ]; let (input, _, _) = convert_to_open_responses_input(&prompt); @@ -909,32 +984,36 @@ mod convert_tests { #[test] fn convert_tool_roundtrip_with_followup_assistant() { let prompt = vec![ - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("What is the weather in Tokyo?")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::tool_call( - "call_weather", - "get_weather", - Value::String("{\"location\":\"Tokyo\"}".to_string()), - )], - ..Default::default() + LanguageModelMessage::user_text("What is the weather in Tokyo?"), + LanguageModelMessage::Assistant { + content: vec![AssistantPart::ToolCall(ToolCallPart { + tool_call_id: "call_weather".into(), + tool_name: "get_weather".into(), + input: Value::String("{\"location\":\"Tokyo\"}".to_string()), + provider_executed: None, + thought_signature: None, + provider_options: None, + })], + provider_options: None, }, - LanguageModelPromptMessage { - role: Role::Tool, - content: vec![ContentPart::tool_result( - "call_weather", - json!({"type": "json", "value": {"temperature": 25, "condition": "cloudy"}}), - )], - ..Default::default() + LanguageModelMessage::Tool { + content: vec![ToolPart::ToolResult(ToolResultPart { + tool_call_id: "call_weather".into(), + result: json!({"type": "json", "value": {"temperature": 25, "condition": "cloudy"}}), + tool_name: None, + is_error: None, + preliminary: None, + dynamic: None, + provider_options: None, + })], + provider_options: None, }, - LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::text("It is 25 C and cloudy in Tokyo.")], - ..Default::default() + LanguageModelMessage::Assistant { + content: vec![AssistantPart::Text(TextPart { + text: "It is 25 C and cloudy in Tokyo.".into(), + provider_options: None, + })], + provider_options: None, }, ]; let (input, _, _) = convert_to_open_responses_input(&prompt); @@ -953,13 +1032,18 @@ mod convert_tests { #[test] fn convert_file_reference_produces_warning() { - let prompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::file_reference( - "image/png", - json!({"openResponses": "file-ref-123"}), - )], - ..Default::default() + let prompt = vec![LanguageModelMessage::User { + content: vec![UserPart::File(FilePart { + data: FileData::Reference { + reference: [("openResponses".into(), "file-ref-123".into())] + .into_iter() + .collect(), + }, + media_type: "image/png".into(), + filename: None, + provider_options: None, + })], + provider_options: None, }]; let (input, _, warnings) = convert_to_open_responses_input(&prompt); // The reference part is skipped, so user content is empty array. @@ -1601,16 +1685,11 @@ mod do_generate_tests { let model = make_model(&server, "gemma-7b-it"); let prompt = vec![ - LanguageModelPromptMessage { - role: Role::System, - content: vec![ContentPart::text("You are a helpful assistant.")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() + LanguageModelMessage::System { + content: "You are a helpful assistant.".into(), + provider_options: None, }, + LanguageModelMessage::user_text("Hello"), ]; model.do_generate(&default_options(prompt)).await.unwrap(); @@ -1626,21 +1705,15 @@ mod do_generate_tests { let model = make_model(&server, "gemma-7b-it"); let prompt = vec![ - LanguageModelPromptMessage { - role: Role::System, - content: vec![ContentPart::text("You are a helpful assistant.")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::System, - content: vec![ContentPart::text("Always be concise.")], - ..Default::default() + LanguageModelMessage::System { + content: "You are a helpful assistant.".into(), + provider_options: None, }, - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() + LanguageModelMessage::System { + content: "Always be concise.".into(), + provider_options: None, }, + LanguageModelMessage::user_text("Hello"), ]; model.do_generate(&default_options(prompt)).await.unwrap(); @@ -1661,30 +1734,32 @@ mod do_generate_tests { let model = make_model(&server, "gemma-7b-it"); let prompt = vec![ - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("What is the weather in Tokyo?")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::tool_call( - "call_weather_123", - "get_weather", - json!({"location": "Tokyo"}), - )], - ..Default::default() + LanguageModelMessage::user_text("What is the weather in Tokyo?"), + LanguageModelMessage::Assistant { + content: vec![AssistantPart::ToolCall(ToolCallPart { + tool_call_id: "call_weather_123".into(), + tool_name: "get_weather".into(), + input: json!({"location": "Tokyo"}), + provider_executed: None, + thought_signature: None, + provider_options: None, + })], + provider_options: None, }, - LanguageModelPromptMessage { - role: Role::Tool, - content: vec![ContentPart::tool_result( - "call_weather_123", - json!({ + LanguageModelMessage::Tool { + content: vec![ToolPart::ToolResult(ToolResultPart { + tool_call_id: "call_weather_123".into(), + result: json!({ "type": "json", "value": {"temperature": 22, "condition": "sunny", "humidity": 65} }), - )], - ..Default::default() + tool_name: None, + is_error: None, + preliminary: None, + dynamic: None, + provider_options: None, + })], + provider_options: None, }, ]; let options = CallOptions { @@ -1732,18 +1807,22 @@ mod do_generate_tests { mock_json(&server, openai_pdf_json()).await; let model = make_model(&server, "gpt-4.1-nano"); - let prompt = vec![LanguageModelPromptMessage { - role: Role::User, + let prompt = vec![LanguageModelMessage::User { content: vec![ - ContentPart::text( - "What text does this PDF contain? Reply with just the text content, nothing else.", - ), - ContentPart::file_url( - "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf", - "application/pdf", - ), - ], - ..Default::default() +UserPart::Text(TextPart { +text: "What text does this PDF contain? Reply with just the text content, nothing else.".into(), +provider_options: None +}), +UserPart::File(FilePart { +data: FileData::Url { +url: "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf".into() +}, +media_type: "application/pdf".into(), +filename: None, +provider_options: None +}) +], + provider_options: None, }]; model.do_generate(&default_options(prompt)).await.unwrap(); @@ -1771,16 +1850,22 @@ mod do_generate_tests { mock_json(&server, openai_pdf_json()).await; let model = make_model(&server, "gpt-4.1-nano"); - let prompt = vec![LanguageModelPromptMessage { - role: Role::User, + let prompt = vec![LanguageModelMessage::User { content: vec![ - ContentPart::text("What text does this PDF contain?"), - ContentPart::file_url( - "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf", - "application/pdf", - ), - ], - ..Default::default() +UserPart::Text(TextPart { +text: "What text does this PDF contain?".into(), +provider_options: None +}), +UserPart::File(FilePart { +data: FileData::Url { +url: "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf".into() +}, +media_type: "application/pdf".into(), +filename: None, +provider_options: None +}) +], + provider_options: None, }]; let result = model.do_generate(&default_options(prompt)).await.unwrap(); @@ -1800,16 +1885,22 @@ mod do_generate_tests { mock_json(&server, openai_pdf_json()).await; let model = make_model(&server, "gpt-4.1-nano"); - let prompt = vec![LanguageModelPromptMessage { - role: Role::User, + let prompt = vec![LanguageModelMessage::User { content: vec![ - ContentPart::text("What text does this PDF contain?"), - ContentPart::file_url( - "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf", - "application/pdf", - ), - ], - ..Default::default() +UserPart::Text(TextPart { +text: "What text does this PDF contain?".into(), +provider_options: None +}), +UserPart::File(FilePart { +data: FileData::Url { +url: "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf".into() +}, +media_type: "application/pdf".into(), +filename: None, +provider_options: None +}) +], + provider_options: None, }]; let result = model.do_generate(&default_options(prompt)).await.unwrap(); @@ -2077,16 +2168,22 @@ mod do_stream_tests { mock_sse(&server, body).await; let model = make_model(&server, "gpt-4.1-nano"); - let prompt = vec![LanguageModelPromptMessage { - role: Role::User, + let prompt = vec![LanguageModelMessage::User { content: vec![ - ContentPart::text("What text does this PDF contain?"), - ContentPart::file_url( - "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf", - "application/pdf", - ), - ], - ..Default::default() +UserPart::Text(TextPart { +text: "What text does this PDF contain?".into(), +provider_options: None +}), +UserPart::File(FilePart { +data: FileData::Url { +url: "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf".into() +}, +media_type: "application/pdf".into(), +filename: None, +provider_options: None +}) +], + provider_options: None, }]; let result = model.do_stream(&default_options(prompt)).await.unwrap(); let parts = collect_stream(result).await; diff --git a/aimux-providers/tests/openai_compatible_test.rs b/aimux-providers/tests/openai_compatible_test.rs index e554f4287..244f9dca6 100644 --- a/aimux-providers/tests/openai_compatible_test.rs +++ b/aimux-providers/tests/openai_compatible_test.rs @@ -21,11 +21,9 @@ use serde_json::{Value, json}; use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; -use aimux_core::content::ContentPart; use aimux_core::error::AiMuxError; use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; +use aimux_core::language_model_message::{LanguageModelMessage, LanguageModelPrompt}; use aimux_core::options::CallOptions; use aimux_core::result::GenerateContent; use aimux_core::stream_part::StreamPart; @@ -60,11 +58,7 @@ use aimux_providers::{ /// The TS `TEST_PROMPT`: a single user text message "Hello". fn test_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] + vec![LanguageModelMessage::user_text("Hello")] } /// `CallOptions` with only `prompt` set (everything else default/None). diff --git a/aimux-providers/tests/openrouter_test.rs b/aimux-providers/tests/openrouter_test.rs index f9944fa1f..48bca11da 100644 --- a/aimux-providers/tests/openrouter_test.rs +++ b/aimux-providers/tests/openrouter_test.rs @@ -24,11 +24,9 @@ use serial_test::serial; use wiremock::matchers::{header, method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; -use aimux_core::content::ContentPart; use aimux_core::error::AiMuxError; use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; +use aimux_core::language_model_message::{LanguageModelMessage, LanguageModelPrompt}; use aimux_core::options::{CallOptions, Tool, ToolChoice}; use aimux_core::provider::Provider; use aimux_core::result::GenerateContent; @@ -45,11 +43,7 @@ mod common; // ── shared helpers ─────────────────────────────────────────────────────────── fn test_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] + vec![LanguageModelMessage::user_text("Hello")] } fn default_options(prompt: LanguageModelPrompt) -> CallOptions { diff --git a/aimux-providers/tests/perplexity_test.rs b/aimux-providers/tests/perplexity_test.rs index 4dd6ac16c..1744f753b 100644 --- a/aimux-providers/tests/perplexity_test.rs +++ b/aimux-providers/tests/perplexity_test.rs @@ -26,11 +26,9 @@ use serial_test::serial; use wiremock::matchers::{header, method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; -use aimux_core::content::ContentPart; use aimux_core::error::AiMuxError; use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; +use aimux_core::language_model_message::{LanguageModelMessage, LanguageModelPrompt}; use aimux_core::options::CallOptions; use aimux_core::result::GenerateContent; use aimux_core::stream_part::StreamPart; @@ -41,11 +39,7 @@ use aimux_providers::{ProviderOptions, provider, provider_from_env}; // ── shared helpers ─────────────────────────────────────────────────────────── fn test_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] + vec![LanguageModelMessage::user_text("Hello")] } fn default_options(prompt: LanguageModelPrompt) -> CallOptions { diff --git a/aimux-providers/tests/provider_error_test.rs b/aimux-providers/tests/provider_error_test.rs index a4a2857cc..fad95c2d0 100644 --- a/aimux-providers/tests/provider_error_test.rs +++ b/aimux-providers/tests/provider_error_test.rs @@ -23,11 +23,9 @@ //! yields `AiMuxError::ApiCall` with the observed code in `status_code`; //! 408/409/429/5xx are stored retryable (`is_retryable`), other 4xx are not. -use aimux_core::content::ContentPart; use aimux_core::error::AiMuxError; use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; +use aimux_core::language_model_message::{LanguageModelMessage, LanguageModelPrompt}; use aimux_core::options::CallOptions; use aimux_core::stream_part::StreamPart; use aimux_provider_utils::RetryConfig; @@ -41,11 +39,7 @@ use wiremock::{Mock, MockServer, ResponseTemplate}; /// The TS `TEST_PROMPT`: a single user text message "Hello". fn test_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] + vec![LanguageModelMessage::user_text("Hello")] } /// Build `CallOptions` with everything unset except `prompt`. diff --git a/aimux-providers/tests/reasoning_map_test.rs b/aimux-providers/tests/reasoning_map_test.rs index 68ffcb0c7..0f9bdc6d1 100644 --- a/aimux-providers/tests/reasoning_map_test.rs +++ b/aimux-providers/tests/reasoning_map_test.rs @@ -14,9 +14,7 @@ //! - **reasoning_effort 直传**:7 档无归一化(none/minimal/low/medium/high/xhigh 原样 //! 透传;provider-default 不发字段)。 -use aimux_core::content::ContentPart; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; +use aimux_core::language_model_message::{LanguageModelMessage, LanguageModelPrompt}; use aimux_core::options::CallOptions; use aimux_core::types::{ReasoningEffort, Warning}; use aimux_providers::openai::OpenAICompatProfile; @@ -25,11 +23,7 @@ use aimux_providers::provider_registry_entry; use serde_json::json; fn user_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] + vec![LanguageModelMessage::user_text("Hello")] } fn opts_with_max_tokens(n: u32) -> CallOptions { diff --git a/aimux-providers/tests/thin_wrapper_config_test.rs b/aimux-providers/tests/thin_wrapper_config_test.rs index 7e5624f2f..5eb2a5c71 100644 --- a/aimux-providers/tests/thin_wrapper_config_test.rs +++ b/aimux-providers/tests/thin_wrapper_config_test.rs @@ -21,10 +21,8 @@ use serde_json::{Value, json}; use wiremock::matchers::{header, method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; -use aimux_core::content::ContentPart; use aimux_core::language_model::LanguageModel; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; -use aimux_core::message::Role; +use aimux_core::language_model_message::{LanguageModelMessage, LanguageModelPrompt}; use aimux_core::options::CallOptions; use aimux_core::provider::Provider; @@ -35,11 +33,7 @@ use aimux_providers::{ // ── shared helpers ─────────────────────────────────────────────────────────── fn test_prompt() -> LanguageModelPrompt { - vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("Hello")], - ..Default::default() - }] + vec![LanguageModelMessage::user_text("Hello")] } fn default_options(prompt: LanguageModelPrompt) -> CallOptions { diff --git a/aimux-providers/tests/tool_result_and_reasoning_fix_test.rs b/aimux-providers/tests/tool_result_and_reasoning_fix_test.rs index f9807c573..ea009c503 100644 --- a/aimux-providers/tests/tool_result_and_reasoning_fix_test.rs +++ b/aimux-providers/tests/tool_result_and_reasoning_fix_test.rs @@ -22,7 +22,10 @@ //! `convert_prompt_to_openai_messages` (no network). use aimux_core::content::ContentPart; -use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromptMessage}; +use aimux_core::language_model_message::{ + AssistantPart, LanguageModelMessage, LanguageModelPrompt, ReasoningPart, TextPart, + ToolCallPart, ToolPart, ToolResultPart, convert_to_language_model_prompt, +}; use aimux_core::message::{ModelMessage, ModelPrompt, Role}; use aimux_providers::openai::convert::convert_prompt_to_openai_messages; use serde_json::{Value, json}; @@ -95,17 +98,7 @@ fn full_tool_round_trip_from_json_converts_with_tool_call_id() { _ => unreachable!(), }; // Convert to provider-facing prompt then to OpenAI messages. - let provider_prompt: LanguageModelPrompt = msgs - .iter() - .map(|m| LanguageModelPromptMessage { - role: m.role, - content: match &m.content { - aimux_core::message::MessageContent::Text(t) => vec![ContentPart::text(t)], - aimux_core::message::MessageContent::Parts(p) => p.clone(), - }, - provider_options: None, - }) - .collect(); + let provider_prompt = convert_to_language_model_prompt(&msgs, None).unwrap(); let out = convert_prompt_to_openai_messages(&provider_prompt); assert_eq!(out.len(), 3); // The tool message must carry tool_call_id (the core of issue 1). @@ -126,30 +119,36 @@ fn full_tool_round_trip_from_json_converts_with_tool_call_id() { #[test] fn assistant_reasoning_with_tool_call_emits_reasoning_content() { let prompt: LanguageModelPrompt = vec![ - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("inspect the repo")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::Assistant, + LanguageModelMessage::user_text("inspect the repo"), + LanguageModelMessage::Assistant { content: vec![ - ContentPart::reasoning("I need to inspect files before answering."), - ContentPart::tool_call( - "call_1".to_string(), - "read_file".to_string(), - json!({ "path": "README.md" }), - ), + AssistantPart::Reasoning(ReasoningPart { + text: "I need to inspect files before answering.".into(), + signature: None, + provider_options: None, + }), + AssistantPart::ToolCall(ToolCallPart { + tool_call_id: "call_1".to_string(), + tool_name: "read_file".to_string(), + input: json!({ "path": "README.md" }), + provider_executed: None, + thought_signature: None, + provider_options: None, + }), ], - ..Default::default() + provider_options: None, }, - LanguageModelPromptMessage { - role: Role::Tool, - content: vec![ContentPart::tool_result( - "call_1".to_string(), - json!("contents"), - )], - ..Default::default() + LanguageModelMessage::Tool { + content: vec![ToolPart::ToolResult(ToolResultPart { + tool_call_id: "call_1".to_string(), + tool_name: None, + result: json!("contents"), + is_error: None, + preliminary: None, + dynamic: None, + provider_options: None, + })], + provider_options: None, }, ]; let out = convert_prompt_to_openai_messages(&prompt); @@ -181,24 +180,22 @@ fn assistant_reasoning_with_tool_call_emits_reasoning_content() { #[test] fn assistant_reasoning_with_text_emits_reasoning_content() { let prompt: LanguageModelPrompt = vec![ - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("What is 2+2?")], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::Assistant, + LanguageModelMessage::user_text("What is 2+2?"), + LanguageModelMessage::Assistant { content: vec![ - ContentPart::reasoning("2 plus 2 equals 4."), - ContentPart::text("4"), + AssistantPart::Reasoning(ReasoningPart { + text: "2 plus 2 equals 4.".into(), + signature: None, + provider_options: None, + }), + AssistantPart::Text(TextPart { + text: "4".into(), + provider_options: None, + }), ], - ..Default::default() - }, - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("thanks")], - ..Default::default() + provider_options: None, }, + LanguageModelMessage::user_text("thanks"), ]; let out = convert_prompt_to_openai_messages(&prompt); let assistant = &out[1]; @@ -217,14 +214,16 @@ fn assistant_reasoning_with_text_emits_reasoning_content() { /// `reasoning.length > 0` guard). #[test] fn assistant_without_reasoning_omits_reasoning_content() { - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::Assistant, - content: vec![ContentPart::tool_call( - "call_1".to_string(), - "write_file".to_string(), - json!({ "path": "/tmp/x" }), - )], - ..Default::default() + let prompt: LanguageModelPrompt = vec![LanguageModelMessage::Assistant { + content: vec![AssistantPart::ToolCall(ToolCallPart { + tool_call_id: "call_1".to_string(), + tool_name: "write_file".to_string(), + input: json!({ "path": "/tmp/x" }), + provider_executed: None, + thought_signature: None, + provider_options: None, + })], + provider_options: None, }]; let out = convert_prompt_to_openai_messages(&prompt); let assistant = &out[0]; @@ -237,14 +236,24 @@ fn assistant_without_reasoning_omits_reasoning_content() { /// Multiple reasoning parts are concatenated (mirrors the SDK join behaviour). #[test] fn multiple_reasoning_parts_are_concatenated() { - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::Assistant, + let prompt: LanguageModelPrompt = vec![LanguageModelMessage::Assistant { content: vec![ - ContentPart::reasoning("part one. "), - ContentPart::reasoning("part two."), - ContentPart::text("answer"), + AssistantPart::Reasoning(ReasoningPart { + text: "part one. ".into(), + signature: None, + provider_options: None, + }), + AssistantPart::Reasoning(ReasoningPart { + text: "part two.".into(), + signature: None, + provider_options: None, + }), + AssistantPart::Text(TextPart { + text: "answer".into(), + provider_options: None, + }), ], - ..Default::default() + provider_options: None, }]; let out = convert_prompt_to_openai_messages(&prompt); let assistant = &out[0]; @@ -252,31 +261,6 @@ fn multiple_reasoning_parts_are_concatenated() { assert_eq!(assistant["reasoning_content"], json!("part one. part two.")); } -/// A user message never carries reasoning, so `reasoning_content` is never -/// emitted for non-assistant roles even if a reasoning part somehow appears. -/// The reasoning part is still dropped from the content shape, and the -/// remaining text part collapses to a string (matching the plain-text path). -#[test] -fn non_assistant_role_never_emits_reasoning_content() { - let prompt: LanguageModelPrompt = vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ - ContentPart::reasoning("should be ignored"), - ContentPart::text("hello"), - ], - ..Default::default() - }]; - let out = convert_prompt_to_openai_messages(&prompt); - let user = &out[0]; - assert_eq!(user["role"], json!("user")); - assert!( - user.get("reasoning_content").is_none(), - "user messages must not carry reasoning_content" - ); - // The text part collapses to a plain string; reasoning is dropped entirely. - assert_eq!(user["content"], json!("hello")); -} - /// The user-facing `ModelMessage` + `ModelPrompt` path (not just the /// provider-facing prompt) also surfaces reasoning_content, exercising the /// `convert_to_language_model_prompt` → `convert_prompt_to_openai_messages` @@ -297,17 +281,7 @@ fn model_prompt_path_emits_reasoning_content() { ModelPrompt::Messages(m) => m, _ => unreachable!(), }; - let provider_prompt: LanguageModelPrompt = msgs - .iter() - .map(|m| LanguageModelPromptMessage { - role: m.role, - content: match &m.content { - aimux_core::message::MessageContent::Text(t) => vec![ContentPart::text(t)], - aimux_core::message::MessageContent::Parts(p) => p.clone(), - }, - provider_options: None, - }) - .collect(); + let provider_prompt = convert_to_language_model_prompt(&msgs, None).unwrap(); let out = convert_prompt_to_openai_messages(&provider_prompt); let assistant = &out[1]; assert_eq!(assistant["reasoning_content"], json!("thinking about hi")); diff --git a/bindings/node/src/types.ts b/bindings/node/src/types.ts index a6211810e..7e9cb7a8c 100644 --- a/bindings/node/src/types.ts +++ b/bindings/node/src/types.ts @@ -5,6 +5,7 @@ export type * from './types/AiMuxError' export type * from './types/ApiCallError' export type * from './types/AspectRatio' +export type * from './types/AssistantPart' export type * from './types/AudioChunk' export type * from './types/AudioData' export type * from './types/AudioInput' @@ -30,6 +31,7 @@ export type * from './types/EmbeddingResult' export type * from './types/EmbeddingUsage' export type * from './types/FileBytes' export type * from './types/FileData' +export type * from './types/FilePart' export type * from './types/Fingerprint' export type * from './types/FinishReason' export type * from './types/FinishReasonUnified' @@ -51,7 +53,7 @@ export type * from './types/ImageResult' export type * from './types/ImageUsage' export type * from './types/InputAudioFormat' export type * from './types/InputRecord' -export type * from './types/LanguageModelPromptMessage' +export type * from './types/LanguageModelMessage' export type * from './types/MessageContent' export type * from './types/Modality' export type * from './types/ModelCapabilities' @@ -75,6 +77,7 @@ export type * from './types/RawToolCall' export type * from './types/ReasoningEffort' export type * from './types/ReasoningMode' export type * from './types/ReasoningOutput' +export type * from './types/ReasoningPart' export type * from './types/ReasoningSpec' export type * from './types/ReasoningVisibility' export type * from './types/Recording' @@ -110,14 +113,18 @@ export type * from './types/SpeechResponse' export type * from './types/SpeechResult' export type * from './types/StreamPart' export type * from './types/StreamTextResultAggregated' +export type * from './types/TextPart' export type * from './types/TimeoutConfiguration' export type * from './types/TimingRecord' export type * from './types/TokenUsage' export type * from './types/Tool' export type * from './types/ToolCall' +export type * from './types/ToolCallPart' export type * from './types/ToolCallRepairReply' export type * from './types/ToolChoice' +export type * from './types/ToolPart' export type * from './types/ToolResult' +export type * from './types/ToolResultPart' export type * from './types/TraceFilter' export type * from './types/TraceRecord' export type * from './types/TraceStats' @@ -132,6 +139,7 @@ export type * from './types/UploadFileData' export type * from './types/UploadFileResult' export type * from './types/Usage' export type * from './types/UsageSnapshot' +export type * from './types/UserPart' export type * from './types/Verdict' export type * from './types/VerdictConfidence' export type * from './types/VerdictKind' diff --git a/bindings/node/src/types/AssistantPart.ts b/bindings/node/src/types/AssistantPart.ts new file mode 100644 index 000000000..b9c90bbf4 --- /dev/null +++ b/bindings/node/src/types/AssistantPart.ts @@ -0,0 +1,11 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { FilePart } from "./FilePart"; +import type { ReasoningPart } from "./ReasoningPart"; +import type { TextPart } from "./TextPart"; +import type { ToolCallPart } from "./ToolCallPart"; +import type { ToolResultPart } from "./ToolResultPart"; + +/** + * Parts allowed in an assistant message. + */ +export type AssistantPart = { "type": "text" } & TextPart | { "type": "file" } & FilePart | { "type": "reasoning" } & ReasoningPart | { "type": "tool_call" } & ToolCallPart | { "type": "tool_result" } & ToolResultPart; diff --git a/bindings/node/src/types/CallOptions.ts b/bindings/node/src/types/CallOptions.ts index fdc56c0e7..eaf11c664 100644 --- a/bindings/node/src/types/CallOptions.ts +++ b/bindings/node/src/types/CallOptions.ts @@ -1,5 +1,5 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { LanguageModelPromptMessage } from "./LanguageModelPromptMessage"; +import type { LanguageModelMessage } from "./LanguageModelMessage"; import type { ReasoningEffort } from "./ReasoningEffort"; import type { ResponseFormat } from "./ResponseFormat"; import type { TimeoutConfiguration } from "./TimeoutConfiguration"; @@ -18,7 +18,7 @@ export type CallOptions = { /** * The standardized prompt (message array). Required. */ -prompt: Array, +prompt: Array, /** * Maximum tokens to generate. */ diff --git a/bindings/node/src/types/FilePart.ts b/bindings/node/src/types/FilePart.ts new file mode 100644 index 000000000..e412d1c0a --- /dev/null +++ b/bindings/node/src/types/FilePart.ts @@ -0,0 +1,8 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { FileData } from "./FileData"; +import type { JsonValue } from "./serde_json/JsonValue"; + +/** + * File content part (the only file part; `data` is a tagged `FileData`). + */ +export type FilePart = { data: FileData, media_type: string, filename?: string | null, provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, }; diff --git a/bindings/node/src/types/InputRecord.ts b/bindings/node/src/types/InputRecord.ts index fb203c0ba..d72c47b5f 100644 --- a/bindings/node/src/types/InputRecord.ts +++ b/bindings/node/src/types/InputRecord.ts @@ -1,5 +1,5 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { LanguageModelPromptMessage } from "./LanguageModelPromptMessage"; +import type { LanguageModelMessage } from "./LanguageModelMessage"; import type { JsonValue } from "./serde_json/JsonValue"; /** @@ -9,7 +9,7 @@ export type InputRecord = { /** * 完整 prompt(消息数组,含 ContentPart::Image 等多模态)。 */ -prompt: Array, +prompt: Array, /** * 序列化的 CallOptions(abort_signal/call_id 已 serde skip); * headers/provider_options/body_overrides 已递归脱敏。 diff --git a/bindings/node/src/types/LanguageModelMessage.ts b/bindings/node/src/types/LanguageModelMessage.ts new file mode 100644 index 000000000..a7052a024 --- /dev/null +++ b/bindings/node/src/types/LanguageModelMessage.ts @@ -0,0 +1,13 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AssistantPart } from "./AssistantPart"; +import type { ToolPart } from "./ToolPart"; +import type { UserPart } from "./UserPart"; +import type { JsonValue } from "./serde_json/JsonValue"; + +/** + * A single provider-facing message, a union by role. + * + * `provider_options` is message-level (e.g. `anthropic.cacheControl`), + * mirroring `LanguageModelV4Message.providerOptions`. + */ +export type LanguageModelMessage = { "role": "system", content: string, provider_options: { [key in string]: { [key in string]: JsonValue } } | null, } | { "role": "user", content: Array, provider_options: { [key in string]: { [key in string]: JsonValue } } | null, } | { "role": "assistant", content: Array, provider_options: { [key in string]: { [key in string]: JsonValue } } | null, } | { "role": "tool", content: Array, provider_options: { [key in string]: { [key in string]: JsonValue } } | null, }; diff --git a/bindings/node/src/types/LanguageModelPromptMessage.ts b/bindings/node/src/types/LanguageModelPromptMessage.ts deleted file mode 100644 index 459e4fd5b..000000000 --- a/bindings/node/src/types/LanguageModelPromptMessage.ts +++ /dev/null @@ -1,19 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { ContentPart } from "./ContentPart"; -import type { Role } from "./Role"; -import type { JsonValue } from "./serde_json/JsonValue"; - -/** - * A single provider-facing message. - * - * Unlike `ModelMessage` (which can have string content), this always has - * `content: Vec` — strings are normalized to `ContentPart::Text`. - */ -export type LanguageModelPromptMessage = { role: Role, content: Array, -/** - * Message-level provider-specific options (e.g. - * `anthropic.cacheControl`), applied to the last part of the message when - * converting to a provider prompt. Mirrors the Vercel AI SDK - * `LanguageModelV4Message.providerOptions`. - */ -provider_options: { [key in string]: { [key in string]: JsonValue } } | null, }; diff --git a/bindings/node/src/types/ReasoningPart.ts b/bindings/node/src/types/ReasoningPart.ts new file mode 100644 index 000000000..ff9187a2e --- /dev/null +++ b/bindings/node/src/types/ReasoningPart.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { JsonValue } from "./serde_json/JsonValue"; + +/** + * Reasoning / thinking content part. + */ +export type ReasoningPart = { text: string, signature: string | null, provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, }; diff --git a/bindings/node/src/types/TextPart.ts b/bindings/node/src/types/TextPart.ts new file mode 100644 index 000000000..2e01804cf --- /dev/null +++ b/bindings/node/src/types/TextPart.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { JsonValue } from "./serde_json/JsonValue"; + +/** + * Text content part. + */ +export type TextPart = { text: string, provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, }; diff --git a/bindings/node/src/types/ToolCallPart.ts b/bindings/node/src/types/ToolCallPart.ts new file mode 100644 index 000000000..a09f7413f --- /dev/null +++ b/bindings/node/src/types/ToolCallPart.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { JsonValue } from "./serde_json/JsonValue"; + +/** + * Tool call content part. + */ +export type ToolCallPart = { tool_call_id: string, tool_name: string, input: JsonValue, provider_executed?: boolean | null, thought_signature?: string | null, provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, }; diff --git a/bindings/node/src/types/ToolPart.ts b/bindings/node/src/types/ToolPart.ts new file mode 100644 index 000000000..b53fbf0b0 --- /dev/null +++ b/bindings/node/src/types/ToolPart.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ToolResultPart } from "./ToolResultPart"; + +/** + * Parts allowed in a tool message. + */ +export type ToolPart = { "type": "tool_result" } & ToolResultPart; diff --git a/bindings/node/src/types/ToolResultPart.ts b/bindings/node/src/types/ToolResultPart.ts new file mode 100644 index 000000000..90b285cdd --- /dev/null +++ b/bindings/node/src/types/ToolResultPart.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { JsonValue } from "./serde_json/JsonValue"; + +/** + * Tool result content part. + */ +export type ToolResultPart = { tool_call_id: string, tool_name?: string | null, result: JsonValue, is_error?: boolean | null, preliminary?: boolean | null, dynamic?: boolean | null, provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, }; diff --git a/bindings/node/src/types/UserPart.ts b/bindings/node/src/types/UserPart.ts new file mode 100644 index 000000000..beecc56e5 --- /dev/null +++ b/bindings/node/src/types/UserPart.ts @@ -0,0 +1,8 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { FilePart } from "./FilePart"; +import type { TextPart } from "./TextPart"; + +/** + * Parts allowed in a user message. + */ +export type UserPart = { "type": "text" } & TextPart | { "type": "file" } & FilePart; diff --git a/tools/aimux-replay/src/main.rs b/tools/aimux-replay/src/main.rs index 0eafdd62e..6e0e85b81 100644 --- a/tools/aimux-replay/src/main.rs +++ b/tools/aimux-replay/src/main.rs @@ -17,6 +17,7 @@ use anyhow::{Context, Result, bail}; use clap::Parser; use tokio::runtime::Runtime; +use aimux_core::language_model_message::{AssistantPart, LanguageModelMessage, UserPart}; use aimux_core::recording::Recording; use aimux_core::replay::{ReplayOverrides, replay_with_model}; use aimux_providers::rebuild_provider; @@ -116,12 +117,18 @@ fn run_dry(rec: &Recording) -> Result<()> { } /// 取消息首个文本内容(截断展示用)。 -fn prompt_text( - m: &aimux_core::language_model_message::LanguageModelPromptMessage, -) -> Option { - match m.content.first() { - Some(aimux_core::content::ContentPart::Text { text, .. }) => Some(text.clone()), - _ => None, +fn prompt_text(m: &LanguageModelMessage) -> Option { + match m { + LanguageModelMessage::System { content, .. } => Some(content.clone()), + LanguageModelMessage::User { content, .. } => match content.first() { + Some(UserPart::Text(part)) => Some(part.text.clone()), + _ => None, + }, + LanguageModelMessage::Assistant { content, .. } => match content.first() { + Some(AssistantPart::Text(part)) => Some(part.text.clone()), + _ => None, + }, + LanguageModelMessage::Tool { .. } => None, } } @@ -131,13 +138,7 @@ fn run_replay(rec: &Recording, api_key: Option<&str>, prompt: Option<&str>) -> R .with_context(|| format!("rebuild provider for '{}'", rec.provider.provider))?; let overrides = prompt.map(|text| ReplayOverrides { - prompt: Some(vec![ - aimux_core::language_model_message::LanguageModelPromptMessage { - role: aimux_core::message::Role::User, - content: vec![aimux_core::content::ContentPart::text(text)], - provider_options: None, - }, - ]), + prompt: Some(vec![LanguageModelMessage::user_text(text)]), ..Default::default() }); diff --git a/tools/aimux-web/examples/gen_demo_fixtures.rs b/tools/aimux-web/examples/gen_demo_fixtures.rs index 7a0792c9e..11eb965d1 100644 --- a/tools/aimux-web/examples/gen_demo_fixtures.rs +++ b/tools/aimux-web/examples/gen_demo_fixtures.rs @@ -9,9 +9,7 @@ //! //! The rest only enrich the Traces / Sessions listing. -use aimux_core::content::ContentPart; -use aimux_core::language_model_message::LanguageModelPromptMessage; -use aimux_core::message::Role; +use aimux_core::language_model_message::{AssistantPart, LanguageModelMessage, TextPart}; use aimux_core::options::CallOptions; use aimux_core::recording::{ HttpExchange, HttpRecord, InputRecord, OutcomeRecord, OutcomeStatus, ProviderRecord, Recording, @@ -21,21 +19,13 @@ use aimux_core::recording::{ const AGENT_SYSTEM: &str = "You are a helpful assistant. Always use the calculator tool for arithmetic, then answer."; -fn msg(role: Role, text: &str) -> LanguageModelPromptMessage { - LanguageModelPromptMessage { - role, - content: vec![ContentPart::text(text)], - provider_options: None, - } -} - #[allow(clippy::too_many_arguments)] fn recording( call_id: &str, recorded_at: &str, provider: &str, model_id: &str, - prompt: Vec, + prompt: Vec, session: Option<&str>, step: Option, status: OutcomeStatus, @@ -138,7 +128,7 @@ data: [DONE]"#; "2026-08-14T09:31:10.000Z", "openai", "gpt-4o", - vec![msg(Role::User, "what is 1 + 1?")], + vec![LanguageModelMessage::user_text("what is 1 + 1?")], Some("sess-playground"), Some(0), OutcomeStatus::Success, @@ -157,8 +147,11 @@ data: [DONE]"#; "openai", "gpt-4o", vec![ - msg(Role::System, AGENT_SYSTEM), - msg(Role::User, "what is 17 * 19?"), + LanguageModelMessage::System { + content: AGENT_SYSTEM.into(), + provider_options: None, + }, + LanguageModelMessage::user_text("what is 17 * 19?"), ], Some("sess-agent"), Some(0), @@ -178,8 +171,11 @@ data: [DONE]"#; "deepseek", "deepseek-chat", vec![ - msg(Role::System, "You are a careful technical analyst."), - msg(Role::User, "Explain how prefix caching works."), + LanguageModelMessage::System { + content: "You are a careful technical analyst.".into(), + provider_options: None, + }, + LanguageModelMessage::user_text("Explain how prefix caching works."), ], Some("sess-deepseek"), Some(0), @@ -198,10 +194,19 @@ data: [DONE]"#; "deepseek", "deepseek-chat", vec![ - msg(Role::System, "You are a careful technical analyst."), - msg(Role::User, "Explain how prefix caching works."), - msg(Role::Assistant, "Prefix caching reuses the KV cache..."), - msg(Role::User, "What is the hit rate formula?"), + LanguageModelMessage::System { + content: "You are a careful technical analyst.".into(), + provider_options: None, + }, + LanguageModelMessage::user_text("Explain how prefix caching works."), + LanguageModelMessage::Assistant { + content: vec![AssistantPart::Text(TextPart { + text: "Prefix caching reuses the KV cache...".into(), + provider_options: None, + })], + provider_options: None, + }, + LanguageModelMessage::user_text("What is the hit rate formula?"), ], Some("sess-deepseek"), Some(1), @@ -220,7 +225,7 @@ data: [DONE]"#; "2026-08-14T09:25:15.000Z", "groq", "llama-3.3-70b-versatile", - vec![msg(Role::User, "hi")], + vec![LanguageModelMessage::user_text("hi")], Some("sess-err"), Some(0), OutcomeStatus::Error, @@ -235,7 +240,7 @@ data: [DONE]"#; "2026-08-14T09:10:05.000Z", "anthropic", "claude-3-5-sonnet-latest", - vec![msg(Role::User, "Write a haiku about Rust.")], + vec![LanguageModelMessage::user_text("Write a haiku about Rust.")], None, None, OutcomeStatus::Success, diff --git a/tools/aimux-web/examples/gen_fixture.rs b/tools/aimux-web/examples/gen_fixture.rs index 55e0bc558..0b1e799d8 100644 --- a/tools/aimux-web/examples/gen_fixture.rs +++ b/tools/aimux-web/examples/gen_fixture.rs @@ -3,9 +3,7 @@ //! //! Usage: `cargo run -p aimux-web --example gen_fixture [out.jsonl]` -use aimux_core::content::ContentPart; -use aimux_core::language_model_message::LanguageModelPromptMessage; -use aimux_core::message::Role; +use aimux_core::language_model_message::LanguageModelMessage; use aimux_core::options::CallOptions; use aimux_core::recording::{ HttpExchange, HttpRecord, InputRecord, OutcomeRecord, OutcomeStatus, ProviderRecord, Recording, @@ -18,18 +16,11 @@ fn main() { .unwrap_or_else(|| "fixture.jsonl".to_string()); let prompt = vec![ - LanguageModelPromptMessage { - role: Role::System, - content: vec![ContentPart::text( - "You are a helpful assistant. Always use the calculator tool.", - )], - provider_options: None, - }, - LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("what is 1 + 1?")], + LanguageModelMessage::System { + content: "You are a helpful assistant. Always use the calculator tool.".into(), provider_options: None, }, + LanguageModelMessage::user_text("what is 1 + 1?"), ]; let options = CallOptions { diff --git a/tools/aimux-web/src/api/replay.rs b/tools/aimux-web/src/api/replay.rs index 776685f07..beb2a9d10 100644 --- a/tools/aimux-web/src/api/replay.rs +++ b/tools/aimux-web/src/api/replay.rs @@ -100,7 +100,7 @@ async fn run_inner(state: AppState, req: ReplayRequest) -> Result Result>(); - out.push(ModelMessage { - role, - content: MessageContent::Parts(parts), - }); + let content = if role == Role::System { + let [WireContentPart::Text { text }] = m.content.as_slice() else { + return Err(AiMuxError::InvalidPrompt( + "system content must be plain text".into(), + )); + }; + MessageContent::Text(text.clone()) + } else { + MessageContent::Parts(m.content.iter().map(to_content_part).collect()) + }; + out.push(ModelMessage { role, content }); } Ok(ModelPrompt::Messages(out)) } From 020a599d73d630215f20782e1a96a418adfac52e Mon Sep 17 00:00:00 2001 From: chenhaonan Date: Mon, 5 Oct 2026 12:26:24 +0800 Subject: [PATCH 04/10] docs(changelog): record the role-modelled provider prompt as breaking The changelog is what binding authors read before upgrading; the prompt type change alters the provider trait input and the recorded prompt JSON. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01WGwanvo9WLU9HWD7sRR8TS --- CHANGELOG.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 40b2215f7..a21cd6802 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Breaking +- The prompt a provider receives is modelled by role (`aimux-core`), as the AI + SDK's `LanguageModelV4Message`. `LanguageModelPrompt` is + `Vec`: `System { content: String }`, + `User { content: Vec }`, `Assistant { content: Vec }`, + `Tool { content: Vec }`. The parts are `TextPart`, `FilePart`, + `ReasoningPart`, `ToolCallPart`, `ToolResultPart`. A provider sees one file + part, `FilePart { data: FileData, media_type, filename }`; the five + user-facing file variants are folded into it by + `convert_to_language_model_prompt`, which now returns `Result` and rejects a + part its role does not allow. `LanguageModelPromptMessage` is removed. The + user-facing `ModelMessage` and `ContentPart` are unchanged. Recordings store + the new prompt JSON: a system message's `content` is a string and every file + input is a `file` part. + - Provider options and provider metadata have one typed shape, the AI SDK's `Record`: `SharedProviderOptions` and `SharedProviderMetadata` (`ProviderMetadata` is an alias) are From 9d2dca5fc25552189b223fe0617bbaa1f6231594 Mon Sep 17 00:00:00 2001 From: chenhaonan Date: Mon, 5 Oct 2026 20:07:38 +0800 Subject: [PATCH 05/10] fix(providers): file parts keep upstream media-type and text-data rules What: Cohere and Mistral decide image versus document by the top-level media type, so a bare "image" is an image again. Inline text file data is rejected where upstream rejects it (OpenAI Responses, Mistral, Cohere images) and Google falls back to text/plain for an incomplete media type. The provider tool-result part no longer accepts "output" as a second name for "result". Why: an independent review found that the move to one file part changed these three behaviours; the alias gave one field two spellings. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01WGwanvo9WLU9HWD7sRR8TS --- aimux-core/src/language_model_message.rs | 1 - aimux-providers/src/azure/responses.rs | 4 +- aimux-providers/src/codex.rs | 2 +- aimux-providers/src/cohere/convert.rs | 32 +++++++---- aimux-providers/src/cohere/model.rs | 4 +- aimux-providers/src/google/convert.rs | 15 +++-- aimux-providers/src/mistral/convert.rs | 56 +++++++++++++------ aimux-providers/src/mistral/model.rs | 4 +- .../src/openai/responses/convert.rs | 36 ++++++++---- aimux-providers/src/openai/responses/mod.rs | 4 +- aimux-providers/tests/convert_golden_test.rs | 2 +- 11 files changed, 105 insertions(+), 55 deletions(-) diff --git a/aimux-core/src/language_model_message.rs b/aimux-core/src/language_model_message.rs index 7cfb22cf6..f942b5eab 100644 --- a/aimux-core/src/language_model_message.rs +++ b/aimux-core/src/language_model_message.rs @@ -98,7 +98,6 @@ pub struct ToolResultPart { pub tool_call_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub tool_name: Option, - #[serde(alias = "output")] pub result: Value, #[serde(default, skip_serializing_if = "Option::is_none")] pub is_error: Option, diff --git a/aimux-providers/src/azure/responses.rs b/aimux-providers/src/azure/responses.rs index 17926a4ff..bba89273f 100644 --- a/aimux-providers/src/azure/responses.rs +++ b/aimux-providers/src/azure/responses.rs @@ -263,7 +263,7 @@ impl LanguageModel for AzureResponsesModel { async fn do_generate(&self, options: &CallOptions) -> Result { let headers = self.build_headers(options.headers.as_ref()).await?; - let request_result = build_responses_request_body(&self.deployment, options, false); + let request_result = build_responses_request_body(&self.deployment, options, false)?; let mut body = request_result.body; // Apply Azure assistant- file ID prefix passthrough. @@ -300,7 +300,7 @@ impl LanguageModel for AzureResponsesModel { async fn do_stream(&self, options: &CallOptions) -> Result { let headers = self.build_headers(options.headers.as_ref()).await?; - let request_result = build_responses_request_body(&self.deployment, options, true); + let request_result = build_responses_request_body(&self.deployment, options, true)?; let mut body = request_result.body; let warnings = request_result.warnings; let provider_key = provider_key().to_string(); diff --git a/aimux-providers/src/codex.rs b/aimux-providers/src/codex.rs index ccf99d3e1..8c25cd2ef 100644 --- a/aimux-providers/src/codex.rs +++ b/aimux-providers/src/codex.rs @@ -258,7 +258,7 @@ impl CodexModel { options: &CallOptions, stream: bool, ) -> Result<(Value, Vec), AiMuxError> { - let mut result = build_responses_request_body(&self.model_id, options, stream); + let mut result = build_responses_request_body(&self.model_id, options, stream)?; if self.config.mode == CodexMode::Subscription { result.body["store"] = Value::Bool(false); } diff --git a/aimux-providers/src/cohere/convert.rs b/aimux-providers/src/cohere/convert.rs index 120986cc5..b92bfeec9 100644 --- a/aimux-providers/src/cohere/convert.rs +++ b/aimux-providers/src/cohere/convert.rs @@ -3,6 +3,7 @@ //! Mirrors the TS `convert-to-cohere-chat-prompt.ts`, //! `cohere-prepare-tools.ts`, and `map-cohere-finish-reason.ts`. +use aimux_core::error::AiMuxError; use aimux_core::language_model_message::{ AssistantPart, LanguageModelMessage, LanguageModelPrompt, TextPart, ToolCallPart, ToolPart, ToolResultPart, UserPart, @@ -120,8 +121,12 @@ pub struct ConvertedPrompt { /// - Non-image file parts are extracted as `documents` (RAG), not in the message. /// - Assistant content is a plain string; with tool calls, `content` is omitted. /// - Tool messages are flat `{role:"tool", content, tool_call_id}`. -#[must_use] -pub fn convert_prompt_to_cohere(prompt: &LanguageModelPrompt) -> ConvertedPrompt { +/// +/// # Errors +/// Returns an error for unsupported text file data. +pub fn convert_prompt_to_cohere( + prompt: &LanguageModelPrompt, +) -> Result { let mut messages = Vec::new(); let mut documents = Vec::new(); @@ -143,7 +148,7 @@ pub fn convert_prompt_to_cohere(prompt: &LanguageModelPrompt) -> ConvertedPrompt } UserPart::File(file) => { use base64::Engine; - if file.media_type.starts_with("image/") { + if file.media_type.split('/').next() == Some("image") { let url = match &file.data { FileData::Data { data } => { let b64 = match data { @@ -156,7 +161,12 @@ pub fn convert_prompt_to_cohere(prompt: &LanguageModelPrompt) -> ConvertedPrompt format!("data:{};base64,{}", file.media_type, b64) } FileData::Url { url } => url.clone(), - FileData::Reference { .. } | FileData::Text { .. } => continue, + FileData::Reference { .. } => continue, + FileData::Text { .. } => { + return Err(AiMuxError::UnsupportedFunctionality( + "image file parts with text data".to_string(), + )); + } }; has_image = true; parts.push( @@ -261,10 +271,10 @@ pub fn convert_prompt_to_cohere(prompt: &LanguageModelPrompt) -> ConvertedPrompt } } - ConvertedPrompt { + Ok(ConvertedPrompt { messages, documents, - } + }) } fn join_text_parts(content: &[AssistantPart]) -> String { @@ -300,13 +310,15 @@ pub struct RequestBodyResult { /// Mirrors the TS `getArgs` in `cohere-chat-language-model.ts`: assembles the /// model id, messages, documents, sampling settings, response format, tools, /// and the `thinking` config resolved from `reasoning` / provider options. -#[must_use] +/// +/// # Errors +/// Returns an error for unsupported text file data. pub fn build_request_body( model_id: &str, options: &CallOptions, stream: bool, -) -> RequestBodyResult { - let converted = convert_prompt_to_cohere(&options.prompt); +) -> Result { + let converted = convert_prompt_to_cohere(&options.prompt)?; let mut warnings: Vec = Vec::new(); let mut body = json!({ @@ -375,7 +387,7 @@ pub fn build_request_body( body["thinking"] = thinking; } - RequestBodyResult { body, warnings } + Ok(RequestBodyResult { body, warnings }) } // ── Reasoning / thinking ──────────────────────────────────────────────────── diff --git a/aimux-providers/src/cohere/model.rs b/aimux-providers/src/cohere/model.rs index adf22b789..1a8e1efad 100644 --- a/aimux-providers/src/cohere/model.rs +++ b/aimux-providers/src/cohere/model.rs @@ -121,7 +121,7 @@ impl LanguageModel for CohereModel { } async fn do_generate(&self, options: &CallOptions) -> Result { - let body_result = build_request_body(&self.model_id, options, false); + let body_result = build_request_body(&self.model_id, options, false)?; let body = body_result.body.clone(); let headers = self.build_headers(options.headers.as_ref()); let resp = aimux_provider_utils::post_json_to_api( @@ -261,7 +261,7 @@ impl LanguageModel for CohereModel { } async fn do_stream(&self, options: &CallOptions) -> Result { - let body_result = build_request_body(&self.model_id, options, true); + let body_result = build_request_body(&self.model_id, options, true)?; let body = body_result.body.clone(); let headers = self.build_headers(options.headers.as_ref()); let endpoint = self.endpoint(); diff --git a/aimux-providers/src/google/convert.rs b/aimux-providers/src/google/convert.rs index b706624a3..f6c6a57a0 100644 --- a/aimux-providers/src/google/convert.rs +++ b/aimux-providers/src/google/convert.rs @@ -183,12 +183,15 @@ fn convert_user_parts(content: &[UserPart], namespace: ProviderMetadataNamespace UserPart::File(FilePart { data, media_type, .. }) => { - let inline_media_type = - if matches!(data, FileData::Text { .. }) && !media_type.contains('/') { - "text/plain" - } else { - media_type.as_str() - }; + let inline_media_type = if matches!(data, FileData::Text { .. }) + && !media_type + .split_once('/') + .is_some_and(|(_, subtype)| !subtype.is_empty() && subtype != "*") + { + "text/plain" + } else { + media_type.as_str() + }; let data = match data { FileData::Data { data: FileBytes::Binary(bytes), diff --git a/aimux-providers/src/mistral/convert.rs b/aimux-providers/src/mistral/convert.rs index 589e07cce..12b5c0431 100644 --- a/aimux-providers/src/mistral/convert.rs +++ b/aimux-providers/src/mistral/convert.rs @@ -3,6 +3,7 @@ //! Mirrors the TS `convert-to-mistral-chat-messages.ts`, //! `mistral-prepare-tools.ts`, and `map-mistral-finish-reason.ts`. +use aimux_core::error::AiMuxError; use aimux_core::language_model_message::{ AssistantPart, LanguageModelMessage, LanguageModelPrompt, TextPart, ToolCallPart, ToolPart, ToolResultPart, UserPart, @@ -103,26 +104,36 @@ pub fn prepare_tools( /// message if it is an assistant message (continuation mode). /// - Tool messages include `tool_call_id` (no `name` — the Rust data model /// does not carry the tool name on `ToolResult` parts). -#[must_use] -pub fn convert_prompt_to_mistral_messages(prompt: &LanguageModelPrompt) -> Vec { +/// +/// # Errors +/// Returns an error for unsupported text file data. +pub fn convert_prompt_to_mistral_messages( + prompt: &LanguageModelPrompt, +) -> Result, AiMuxError> { let mut result = Vec::new(); let last_idx = prompt.len().saturating_sub(1); for (i, msg) in prompt.iter().enumerate() { let is_last = i == last_idx; - for value in convert_message_to_mistral(msg, is_last) { + for value in convert_message_to_mistral(msg, is_last)? { result.push(value); } } - result + Ok(result) } -fn convert_message_to_mistral(msg: &LanguageModelMessage, is_last: bool) -> Vec { - match msg { +fn convert_message_to_mistral( + msg: &LanguageModelMessage, + is_last: bool, +) -> Result, AiMuxError> { + Ok(match msg { LanguageModelMessage::System { content, .. } => { vec![json!({ "role": "system", "content": content })] } LanguageModelMessage::User { content, .. } => { - let parts: Vec = content.iter().map(convert_part_to_mistral).collect(); + let parts: Vec = content + .iter() + .map(convert_part_to_mistral) + .collect::>()?; vec![json!({ "role": "user", "content": parts })] } LanguageModelMessage::Assistant { content, .. } => { @@ -184,7 +195,7 @@ fn convert_message_to_mistral(msg: &LanguageModelMessage, is_last: bool) -> Vec< }) }) .collect(), - } + }) } fn join_text_parts(content: &[AssistantPart]) -> String { @@ -205,8 +216,8 @@ fn tool_result_to_content(output: &Value) -> Value { } } -fn convert_part_to_mistral(part: &UserPart) -> Value { - match part { +fn convert_part_to_mistral(part: &UserPart) -> Result { + Ok(match part { UserPart::Text(TextPart { text, .. }) => json!({ "type": "text", "text": text }), UserPart::File(file) => { use base64::Engine; @@ -221,23 +232,34 @@ fn convert_part_to_mistral(part: &UserPart) -> Value { format!("data:{};base64,{}", file.media_type, b64) } FileData::Url { url } => url.clone(), - FileData::Reference { .. } | FileData::Text { .. } => return Value::Null, + FileData::Reference { .. } => return Ok(Value::Null), + FileData::Text { .. } => { + return Err(AiMuxError::UnsupportedFunctionality( + "text file parts".to_string(), + )); + } }; - if file.media_type.starts_with("image/") { + if file.media_type.split('/').next() == Some("image") { json!({ "type": "image_url", "image_url": url }) } else { json!({ "type": "document_url", "document_url": url }) } } - } + }) } // ── Request body ──────────────────────────────────────────────────────────── /// Convert `CallOptions` to a Mistral request body. -#[must_use] -pub fn build_request_body(model_id: &str, options: &CallOptions, stream: bool) -> Value { - let messages = convert_prompt_to_mistral_messages(&options.prompt); +/// +/// # Errors +/// Returns an error for unsupported text file data. +pub fn build_request_body( + model_id: &str, + options: &CallOptions, + stream: bool, +) -> Result { + let messages = convert_prompt_to_mistral_messages(&options.prompt)?; let mut body = json!({ "model": model_id, @@ -319,7 +341,7 @@ pub fn build_request_body(model_id: &str, options: &CallOptions, stream: bool) - } } - body + Ok(body) } /// Parse Mistral finish reason string into `FinishReason`. diff --git a/aimux-providers/src/mistral/model.rs b/aimux-providers/src/mistral/model.rs index 1561a4f3e..38268d0a8 100644 --- a/aimux-providers/src/mistral/model.rs +++ b/aimux-providers/src/mistral/model.rs @@ -224,7 +224,7 @@ impl LanguageModel for MistralModel { } async fn do_generate(&self, options: &CallOptions) -> Result { - let body = build_request_body(&self.model_id, options, false); + let body = build_request_body(&self.model_id, options, false)?; let headers = self.build_headers(options.headers.as_ref()); let resp = aimux_provider_utils::post_json_to_api( HttpRequest::new( @@ -320,7 +320,7 @@ impl LanguageModel for MistralModel { } async fn do_stream(&self, options: &CallOptions) -> Result { - let body = build_request_body(&self.model_id, options, true); + let body = build_request_body(&self.model_id, options, true)?; let headers = self.build_headers(options.headers.as_ref()); let resp = aimux_provider_utils::post_json_to_api( HttpRequest::new( diff --git a/aimux-providers/src/openai/responses/convert.rs b/aimux-providers/src/openai/responses/convert.rs index 2236542fa..2091c3e9f 100644 --- a/aimux-providers/src/openai/responses/convert.rs +++ b/aimux-providers/src/openai/responses/convert.rs @@ -12,6 +12,7 @@ use serde_json::{Value, json}; +use aimux_core::error::AiMuxError; use aimux_core::language_model_message::{ AssistantPart, FilePart, LanguageModelMessage, LanguageModelPrompt, ReasoningPart, TextPart, ToolCallPart, ToolPart, ToolResultPart, UserPart, @@ -70,12 +71,15 @@ pub struct ResponsesInputResult { /// `has_previous_response_id` is true, assistant reasoning/function-call items /// that already carry an `itemId` are skipped (they live in the previous /// response chain). +/// +/// # Errors +/// Returns an unsupported-functionality error for text file parts. pub fn convert_to_responses_input( prompt: &LanguageModelPrompt, system_message_mode: SystemMessageMode, store: bool, has_previous_response_id: bool, -) -> ResponsesInputResult { +) -> Result { let mut input: Vec = Vec::new(); let mut warnings: Vec = Vec::new(); @@ -101,7 +105,10 @@ pub fn convert_to_responses_input( } }, LanguageModelMessage::User { content, .. } => { - let content: Vec = content.iter().map(convert_user_part).collect(); + let content: Vec = content + .iter() + .map(convert_user_part) + .collect::>()?; input.push(json!({ "role": "user", "content": content })); } LanguageModelMessage::Assistant { content, .. } => { @@ -253,12 +260,12 @@ pub fn convert_to_responses_input( }); } - ResponsesInputResult { input, warnings } + Ok(ResponsesInputResult { input, warnings }) } /// Convert a single user-message content part into the Responses input shape. -fn convert_user_part(part: &UserPart) -> Value { - match part { +fn convert_user_part(part: &UserPart) -> Result { + Ok(match part { UserPart::Text(TextPart { text, .. }) => json!({ "type": "input_text", "text": text }), UserPart::File(FilePart { data, @@ -294,9 +301,14 @@ fn convert_user_part(part: &UserPart) -> Value { json!({ "type": "input_file", "file_url": url }) } } - FileData::Reference { .. } | FileData::Text { .. } => { + FileData::Reference { .. } => { json!({ "type": "input_text", "text": "" }) } + FileData::Text { .. } => { + return Err(AiMuxError::UnsupportedFunctionality( + "text file parts".into(), + )); + } }; if item["type"] == "input_image" && matches!(data, FileData::Data { .. }) @@ -306,7 +318,7 @@ fn convert_user_part(part: &UserPart) -> Value { } item } - } + }) } /// Serialize tool-call arguments: null/empty -> `"{}"`, objects -> JSON string. @@ -805,12 +817,14 @@ fn apply_responses_reasoning_block( /// /// Splits the original ~380-line function into focused helpers (issue M11); /// behavior is unchanged. -#[must_use] +/// +/// # Errors +/// Returns an unsupported-functionality error for text file parts. pub fn build_responses_request_body( model_id: &str, options: &CallOptions, stream: bool, -) -> ResponsesRequestBodyResult { +) -> Result { let mut warnings: Vec = Vec::new(); let caps = get_model_capabilities(model_id); let provider_opts = &options.provider_options; @@ -839,7 +853,7 @@ pub fn build_responses_request_body( system_message_mode, store_bool, has_previous_response_id, - ); + )?; warnings.extend(input_result.warnings); // -- Base body -- @@ -908,7 +922,7 @@ pub fn build_responses_request_body( warnings.push(tw); } - ResponsesRequestBodyResult { body, warnings } + Ok(ResponsesRequestBodyResult { body, warnings }) } // -- Usage conversion -------------------------------------------------------- diff --git a/aimux-providers/src/openai/responses/mod.rs b/aimux-providers/src/openai/responses/mod.rs index 91afa8978..98d56480e 100644 --- a/aimux-providers/src/openai/responses/mod.rs +++ b/aimux-providers/src/openai/responses/mod.rs @@ -121,7 +121,7 @@ impl LanguageModel for OpenAIResponsesModel { async fn do_generate(&self, options: &CallOptions) -> Result { let headers = self.build_headers(options.headers.as_ref()); - let request_result = build_responses_request_body(&self.model_id, options, false); + let request_result = build_responses_request_body(&self.model_id, options, false)?; let body = request_result.body; let provider_key = self.provider_options_name().to_string(); @@ -155,7 +155,7 @@ impl LanguageModel for OpenAIResponsesModel { async fn do_stream(&self, options: &CallOptions) -> Result { let headers = self.build_headers(options.headers.as_ref()); - let request_result = build_responses_request_body(&self.model_id, options, true); + let request_result = build_responses_request_body(&self.model_id, options, true)?; let body = request_result.body; let warnings = request_result.warnings; let provider_key = self.provider_options_name().to_string(); diff --git a/aimux-providers/tests/convert_golden_test.rs b/aimux-providers/tests/convert_golden_test.rs index dc6adeabe..38e10a55d 100644 --- a/aimux-providers/tests/convert_golden_test.rs +++ b/aimux-providers/tests/convert_golden_test.rs @@ -145,7 +145,7 @@ fn responses_golden() { ..CallOptions::default() }; - let result = build_responses_request_body("o3-mini", &options, false); + let result = build_responses_request_body("o3-mini", &options, false).unwrap(); assert_eq!( result.body, From 1e326e65fd3f8316f1dda64f6501e9429992fe3e Mon Sep 17 00:00:00 2001 From: chenhaonan Date: Mon, 5 Oct 2026 21:58:01 +0800 Subject: [PATCH 06/10] test: delete two hand-written test files that pin Rust-only behaviour What: removes stream_error_semantics_test.rs and tool_result_and_reasoning_fix_test.rs. Why: the project keeps cassette replays, upstream samples and contract fixtures as its tests. These two files asserted behaviour written by hand against a mock and had been rewritten rather than deleted. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01WGwanvo9WLU9HWD7sRR8TS --- .../tests/stream_error_semantics_test.rs | 139 ------------------ .../tool_result_and_reasoning_fix_test.rs | 137 ----------------- 2 files changed, 276 deletions(-) delete mode 100644 aimux-providers/tests/stream_error_semantics_test.rs delete mode 100644 aimux-providers/tests/tool_result_and_reasoning_fix_test.rs diff --git a/aimux-providers/tests/stream_error_semantics_test.rs b/aimux-providers/tests/stream_error_semantics_test.rs deleted file mode 100644 index 734ef9df2..000000000 --- a/aimux-providers/tests/stream_error_semantics_test.rs +++ /dev/null @@ -1,139 +0,0 @@ -//! Stream-error classification and termination semantics (review follow-ups). -//! -//! Locks three behaviors: -//! - no fabricated HTTP 500 for non-numeric provider error codes (the "M3 -//! bug"): such errors carry no status and are not retryable; -//! - payload `retry_after_ms` hints surface through `retry_after_hint()`; -//! - the shared Responses reducer ends its stream right after a terminal -//! error event instead of waiting on a source that stays open. - -use std::collections::HashMap; - -use futures::StreamExt; -use serde_json::json; - -use aimux_core::AiMuxError; -use aimux_core::stream_part::StreamPart; -use aimux_core::types::FinishReasonUnified; -use aimux_provider_utils::stream_error_api_call; -use aimux_providers::openai::responses::responses_convert::build_responses_event_stream; - -fn as_api_call(error: &AiMuxError) -> &aimux_core::ApiCallError { - match error { - AiMuxError::ApiCall(detail) => detail, - other => panic!("expected ApiCall, got {other:?}"), - } -} - -#[test] -fn string_error_code_is_not_a_retryable_500() { - let payload = json!({"message": "Incorrect API key", "code": "invalid_api_key"}); - let error = stream_error_api_call( - "Incorrect API key", - Some("invalid_api_key".into()), - None, - &payload, - "https://example.test", - json!({}), - HashMap::new(), - ); - let detail = as_api_call(&error); - assert_eq!(detail.status_code, None); - assert!(!error.is_retryable()); -} - -#[test] -fn numeric_status_keeps_status_based_retryability() { - let payload = json!({"message": "rate limited", "code": 429}); - let error = stream_error_api_call( - "rate limited", - Some("429".into()), - Some(429), - &payload, - "https://example.test", - json!({}), - HashMap::new(), - ); - assert_eq!(as_api_call(&error).status_code, Some(429)); - assert!(error.is_retryable()); -} - -#[test] -fn payload_retry_after_ms_surfaces_as_hint() { - let payload = json!({"message": "rate limited", "code": 429, "retry_after_ms": 15000}); - let error = stream_error_api_call( - "rate limited", - None, - Some(429), - &payload, - "https://example.test", - json!({}), - HashMap::new(), - ); - assert_eq!(error.retry_after_hint(), Some(15000)); -} - -#[test] -fn real_retry_after_header_wins_over_payload() { - let payload = json!({"message": "rate limited", "retry_after_ms": 15000}); - let headers = HashMap::from([("retry-after-ms".to_string(), "7".to_string())]); - let error = stream_error_api_call( - "rate limited", - None, - Some(429), - &payload, - "https://example.test", - json!({}), - HashMap::new(), - ); - assert_eq!(error.retry_after_hint(), Some(15000)); - let error = stream_error_api_call( - "rate limited", - None, - Some(429), - &payload, - "https://example.test", - json!({}), - headers, - ); - assert_eq!(error.retry_after_hint(), Some(7)); -} - -#[tokio::test] -async fn responses_stream_ends_after_terminal_error_event() { - // First event is benign so the peek passes; the mid-stream error event is - // followed by a source that never closes (heartbeat-style server). - let first = json!({"type": "response.created", "response": {"id": "resp_1"}}); - let error_event = - json!({"type": "error", "error": {"message": "boom", "type": "server_error"}}); - let source = futures::stream::iter(vec![Ok(error_event)]).chain(futures::stream::pending()); - - let stream = build_responses_event_stream( - Some(Ok(first)), - source, - "openai".to_string(), - Vec::new(), - false, - "https://example.test".to_string(), - json!({}), - HashMap::new(), - ) - .expect("stream setup"); - - let parts: Vec<_> = stream.collect().await; - assert!( - parts - .iter() - .any(|part| matches!(part, Ok(StreamPart::Error { .. }))), - "error part yielded" - ); - let last = parts.last().expect("stream not empty"); - assert!( - matches!( - last, - Ok(StreamPart::Finish { finish_reason, .. }) - if finish_reason.unified == FinishReasonUnified::Error - ), - "stream ends with an error Finish, got {last:?}" - ); -} diff --git a/aimux-providers/tests/tool_result_and_reasoning_fix_test.rs b/aimux-providers/tests/tool_result_and_reasoning_fix_test.rs deleted file mode 100644 index 2cb7391b1..000000000 --- a/aimux-providers/tests/tool_result_and_reasoning_fix_test.rs +++ /dev/null @@ -1,137 +0,0 @@ -// Panic convert wrappers are #[deprecated]; these tests still use them. -#![allow(deprecated)] -//! Regression tests for two issues reported against aimux 0.1.1 when driving -//! OpenAI-compatible thinking models (e.g. DeepSeek `deepseek-v4-flash`) in -//! multi-turn tool-call conversations: -//! -//! 1. **`tool` role with `ContentPart[]` was rejected.** `ModelPrompt` -//! deserialization of a `tool_result` part built with the legacy `output` -//! field (the shape emitted by the Vercel AI SDK and the 0.1.0 TypeScript -//! bindings) failed with "data did not match any variant of untagged enum -//! ModelPrompt". `ContentPart::ToolResult.result` now accepts `output` as a -//! deserialization alias (`#[serde(alias = "output")]`). -//! -//! 2. **`reasoning` ContentPart was dropped on the request side.** Thinking -//! models require prior assistant `reasoning_content` to be replayed on -//! later turns, including tool-call turns. The OpenAI message converter -//! now lifts `ContentPart::Reasoning` parts to a top-level -//! `reasoning_content` string on assistant messages (mirroring the Vercel -//! AI SDK `openai-compatible` assistant conversion). -//! -//! These are pure-function tests against -//! `convert_prompt_to_openai_messages` (no network). - -use aimux_core::content::ContentPart; -use aimux_core::language_model_message::convert_to_language_model_prompt; -use aimux_core::message::{ModelMessage, ModelPrompt, Role}; -use aimux_providers::openai::convert::convert_prompt_to_openai_messages; -use serde_json::json; - -// ── Issue 1: tool role ContentPart[] / `output` alias ─────────────────────── - -/// A `tool` message whose `tool_result` part uses the legacy `output` field -/// (Vercel AI SDK / 0.1.0 TS bindings shape) round-trips through `ModelPrompt`. -#[test] -fn tool_message_with_tool_result_output_field_deserializes() { - let json_str = r#"[ - {"role":"tool","content":[{"type":"tool_result","tool_call_id":"tc1","output":"ok"}]} - ]"#; - let prompt: ModelPrompt = - serde_json::from_str(json_str).expect("output-field tool_result must deserialize"); - let msgs = match prompt { - ModelPrompt::Messages(m) => m, - other => panic!("expected Messages, got {other:?}"), - }; - assert_eq!(msgs.len(), 1); - assert_eq!(msgs[0].role, Role::Tool); - // The alias only affects deserialization; the in-memory field is `result`. - match &msgs[0].content { - aimux_core::message::MessageContent::Parts(parts) => { - assert_eq!(parts.len(), 1); - match &parts[0] { - ContentPart::ToolResult { - tool_call_id, - result, - .. - } => { - assert_eq!(tool_call_id, "tc1"); - assert_eq!(result, &json!("ok")); - } - other => panic!("expected ToolResult, got {other:?}"), - } - } - other => panic!("expected Parts, got {other:?}"), - } -} - -/// The current `result` field name still deserializes (no regression). -#[test] -fn tool_message_with_tool_result_result_field_deserializes() { - let json_str = r#"[ - {"role":"tool","content":[{"type":"tool_result","tool_call_id":"tc1","result":"ok"}]} - ]"#; - let prompt: ModelPrompt = - serde_json::from_str(json_str).expect("result-field tool_result must deserialize"); - assert!(matches!(prompt, ModelPrompt::Messages(_))); -} - -/// A full tool round-trip (assistant tool_call → tool result → …) built from -/// raw JSON reproducing the user's failing case now converts to OpenAI -/// messages with `tool_call_id` on the tool message. -#[test] -fn full_tool_round_trip_from_json_converts_with_tool_call_id() { - let json_str = r#"[ - {"role":"user","content":"write a file"}, - {"role":"assistant","content":[ - {"type":"tool_call","tool_call_id":"tc1","tool_name":"write_file","input":{"path":"/tmp/test.txt"}} - ]}, - {"role":"tool","content":[ - {"type":"tool_result","tool_call_id":"tc1","output":"Successfully wrote to /tmp/test.txt"} - ]} - ]"#; - let prompt: ModelPrompt = serde_json::from_str(json_str).expect("must deserialize"); - let msgs = match prompt { - ModelPrompt::Messages(m) => m, - _ => unreachable!(), - }; - // Convert to provider-facing prompt then to OpenAI messages. - let provider_prompt = convert_to_language_model_prompt(&msgs, None).unwrap(); - let out = convert_prompt_to_openai_messages(&provider_prompt); - assert_eq!(out.len(), 3); - // The tool message must carry tool_call_id (the core of issue 1). - assert_eq!(out[2]["role"], json!("tool")); - assert_eq!(out[2]["tool_call_id"], json!("tc1")); - assert_eq!( - out[2]["content"], - json!("Successfully wrote to /tmp/test.txt") - ); -} - -// ── Issue 2: reasoning_content replay on the request side ─────────────────── - -/// The user-facing `ModelMessage` + `ModelPrompt` path (not just the -/// provider-facing prompt) also surfaces reasoning_content, exercising the -/// `convert_to_language_model_prompt` → `convert_prompt_to_openai_messages` -/// pipeline end to end. -#[test] -fn model_prompt_path_emits_reasoning_content() { - let prompt = ModelPrompt::Messages(vec![ - ModelMessage::user("hi"), - ModelMessage { - role: Role::Assistant, - content: aimux_core::message::MessageContent::Parts(vec![ - ContentPart::reasoning("thinking about hi"), - ContentPart::text("hello!"), - ]), - }, - ]); - let msgs = match prompt { - ModelPrompt::Messages(m) => m, - _ => unreachable!(), - }; - let provider_prompt = convert_to_language_model_prompt(&msgs, None).unwrap(); - let out = convert_prompt_to_openai_messages(&provider_prompt); - let assistant = &out[1]; - assert_eq!(assistant["reasoning_content"], json!("thinking about hi")); - assert_eq!(assistant["content"], json!("hello!")); -} From 8359bb053bb99939bbe4cafb26dcba73f1f69694 Mon Sep 17 00:00:00 2001 From: chenhaonan Date: Mon, 5 Oct 2026 23:27:03 +0800 Subject: [PATCH 07/10] fix(core): provider prompt part tags use upstream's hyphenated names What: the type tags of provider prompt parts and tool result outputs are tool-call, tool-result, reasoning-file, tool-approval-response, execution-denied, error-text, error-json. Why: these types are new in this stack and no fixture pins them, so they take upstream's spelling instead of snake_case. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01WGwanvo9WLU9HWD7sRR8TS --- aimux-core/src/language_model_message.rs | 10 +++++----- bindings/node/src/types/AssistantPart.ts | 2 +- bindings/node/src/types/ToolPart.ts | 2 +- bindings/node/src/types/ToolResultOutput.ts | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/aimux-core/src/language_model_message.rs b/aimux-core/src/language_model_message.rs index 84f2c4617..e6ffebdeb 100644 --- a/aimux-core/src/language_model_message.rs +++ b/aimux-core/src/language_model_message.rs @@ -103,7 +103,7 @@ pub struct ToolResultPart { /// The provider-facing result of a tool call. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] -#[serde(tag = "type", rename_all = "snake_case")] +#[serde(tag = "type", rename_all = "kebab-case")] #[ts(export)] pub enum ToolResultOutput { Text { @@ -139,7 +139,7 @@ pub enum ToolResultOutput { /// Content within a tool result. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] -#[serde(tag = "type", rename_all = "snake_case")] +#[serde(tag = "type", rename_all = "kebab-case")] #[ts(export)] pub enum ToolResultContent { Text(TextPart), @@ -183,7 +183,7 @@ pub struct ToolApprovalResponsePart { /// Parts allowed in a user message. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] -#[serde(tag = "type", rename_all = "snake_case")] +#[serde(tag = "type", rename_all = "kebab-case")] #[ts(export)] pub enum UserPart { Text(TextPart), @@ -192,7 +192,7 @@ pub enum UserPart { /// Parts allowed in an assistant message. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] -#[serde(tag = "type", rename_all = "snake_case")] +#[serde(tag = "type", rename_all = "kebab-case")] #[ts(export)] pub enum AssistantPart { Text(TextPart), @@ -206,7 +206,7 @@ pub enum AssistantPart { /// Parts allowed in a tool message. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] -#[serde(tag = "type", rename_all = "snake_case")] +#[serde(tag = "type", rename_all = "kebab-case")] #[ts(export)] pub enum ToolPart { ToolResult(ToolResultPart), diff --git a/bindings/node/src/types/AssistantPart.ts b/bindings/node/src/types/AssistantPart.ts index 5f0aa872f..850d5780e 100644 --- a/bindings/node/src/types/AssistantPart.ts +++ b/bindings/node/src/types/AssistantPart.ts @@ -10,4 +10,4 @@ import type { ToolResultPart } from "./ToolResultPart"; /** * Parts allowed in an assistant message. */ -export type AssistantPart = { "type": "text" } & TextPart | { "type": "file" } & FilePart | { "type": "reasoning" } & LanguageModelReasoningPart | { "type": "reasoning_file" } & ReasoningFilePart | { "type": "custom" } & CustomPart | { "type": "tool_call" } & ToolCallPart | { "type": "tool_result" } & ToolResultPart; +export type AssistantPart = { "type": "text" } & TextPart | { "type": "file" } & FilePart | { "type": "reasoning" } & LanguageModelReasoningPart | { "type": "reasoning-file" } & ReasoningFilePart | { "type": "custom" } & CustomPart | { "type": "tool-call" } & ToolCallPart | { "type": "tool-result" } & ToolResultPart; diff --git a/bindings/node/src/types/ToolPart.ts b/bindings/node/src/types/ToolPart.ts index 7ab965e6d..df0fe1c1b 100644 --- a/bindings/node/src/types/ToolPart.ts +++ b/bindings/node/src/types/ToolPart.ts @@ -5,4 +5,4 @@ import type { ToolResultPart } from "./ToolResultPart"; /** * Parts allowed in a tool message. */ -export type ToolPart = { "type": "tool_result" } & ToolResultPart | { "type": "tool_approval_response" } & ToolApprovalResponsePart; +export type ToolPart = { "type": "tool-result" } & ToolResultPart | { "type": "tool-approval-response" } & ToolApprovalResponsePart; diff --git a/bindings/node/src/types/ToolResultOutput.ts b/bindings/node/src/types/ToolResultOutput.ts index 888b9f645..2855f2918 100644 --- a/bindings/node/src/types/ToolResultOutput.ts +++ b/bindings/node/src/types/ToolResultOutput.ts @@ -5,4 +5,4 @@ import type { JsonValue } from "./serde_json/JsonValue"; /** * The provider-facing result of a tool call. */ -export type ToolResultOutput = { "type": "text", value: string, provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, } | { "type": "json", value: JsonValue, provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, } | { "type": "execution_denied", reason?: string | null, provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, } | { "type": "error_text", value: string, provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, } | { "type": "error_json", value: JsonValue, provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, } | { "type": "content", value: Array, }; +export type ToolResultOutput = { "type": "text", value: string, provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, } | { "type": "json", value: JsonValue, provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, } | { "type": "execution-denied", reason?: string | null, provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, } | { "type": "error-text", value: string, provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, } | { "type": "error-json", value: JsonValue, provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, } | { "type": "content", value: Array, }; From 3cec6f648d58f9a77ea45928c598dde8fcde0251 Mon Sep 17 00:00:00 2001 From: chenhaonan Date: Tue, 6 Oct 2026 16:05:15 +0800 Subject: [PATCH 08/10] fix(providers): align prompt conversion with upstream for references, files and tool output; drop with_signature What: OpenAI Responses sends user file references as file ids and skips a denied tool result after a rejected approval; the shared OpenAI-style converter sends non-object tool input as `{}`, keeps the cache breakpoint option OpenAI-only and resolves references per vendor (DeepSeek reads its own, Groq and the compatible package reject them); Google writes tool content files as fileData, keeps the original gs: url and rejects user references on Vertex; Bedrock sends s3 images as s3Location; Cohere and Mistral handle references and document urls as upstream; xAI rejects inline non-image files. Removes the with_signature path: signatures travel only in provider options. Restores six ported tests; deletes the hand-written session inference tests. Why: review of the prompt conversion against each pinned convert-to-* module found these divergences. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01WGwanvo9WLU9HWD7sRR8TS --- aimux-core/src/language_model_message.rs | 35 +----- aimux-core/src/session.rs | 109 ------------------ aimux-providers/src/bedrock/convert.rs | 72 ++++++++++-- aimux-providers/src/cohere/convert.rs | 17 ++- aimux-providers/src/google/convert.rs | 27 ++++- aimux-providers/src/mistral/convert.rs | 26 ++++- aimux-providers/src/openai/convert.rs | 87 ++++++++++---- .../src/openai/responses/convert.rs | 27 +++-- aimux-providers/src/vertex/model.rs | 26 +++++ aimux-providers/src/xai/responses/convert.rs | 8 ++ aimux-providers/tests/google_files_test.rs | 26 +++++ aimux-providers/tests/groq_test.rs | 94 ++++++++++++++- .../tests/mistral_embedding_test.rs | 12 ++ 13 files changed, 373 insertions(+), 193 deletions(-) diff --git a/aimux-core/src/language_model_message.rs b/aimux-core/src/language_model_message.rs index 7a31c138e..2f8f920ee 100644 --- a/aimux-core/src/language_model_message.rs +++ b/aimux-core/src/language_model_message.rs @@ -317,16 +317,11 @@ fn to_assistant_part( ), ContentPart::Reasoning { text, - signature, provider_options, + .. } => AssistantPart::Reasoning(ReasoningPart { text: text.clone(), - provider_options: with_signature( - provider_options, - &["amazonBedrock", "bedrock", "anthropic"], - "signature", - signature, - ), + provider_options: provider_options.clone(), }), ContentPart::ToolCall { tool_call_id, @@ -405,32 +400,6 @@ fn to_assistant_part( }) } -fn with_signature( - options: &Option, - namespaces: &[&str], - key: &str, - signature: &Option, -) -> Option { - let Some(signature) = signature else { - return options.clone(); - }; - let mut options = options.clone().unwrap_or_default(); - let namespace = namespaces - .iter() - .find(|name| options.contains_key(**name)) - .copied() - .unwrap_or(if key == "signature" { - "anthropic" - } else { - "google" - }); - options - .entry(namespace.to_owned()) - .or_default() - .insert(key.to_owned(), Value::String(signature.clone())); - Some(options) -} - /// Convert user-facing `ModelMessage`s into a `LanguageModelPrompt`. /// /// - String content is normalized to a single text part. diff --git a/aimux-core/src/session.rs b/aimux-core/src/session.rs index c152a16a4..315c00c0e 100644 --- a/aimux-core/src/session.rs +++ b/aimux-core/src/session.rs @@ -452,16 +452,6 @@ fn civil_from_days(z: i64) -> (i64, u32, u32) { #[cfg(test)] mod tests { use super::*; - use crate::language_model_message::LanguageModelMessage; - - fn msg(text: &str) -> LanguageModelMessage { - LanguageModelMessage::user_text(text) - } - - fn prompt(messages: Vec) -> LanguageModelPrompt { - messages - } - /// `recorded_at` must look like RFC 3339 UTC: `YYYY-MM-DDTHH:MM:SS.mmmZ`. fn is_rfc3339(s: &str) -> bool { let b = s.as_bytes(); @@ -597,105 +587,6 @@ mod tests { assert!(store.list_sessions().is_empty()); } - // ── SessionInferer ────────────────────────────────────────────────────── - - #[test] - fn inferer_disabled_returns_none() { - let mut inf = SessionInferer::new(false); - assert!(inf.resolve(None, &prompt(vec![msg("hi")])).is_none()); - } - - #[test] - fn inferer_explicit_wins_and_is_remembered() { - let mut inf = SessionInferer::new(true); - let p1 = prompt(vec![msg("hi")]); - let (id, source) = inf.resolve(Some("sess-1"), &p1).unwrap(); - assert_eq!(id, "sess-1"); - assert_eq!(source, SessionSource::Explicit); - - // Continuation without an explicit id joins the remembered session. - let p2 = prompt(vec![msg("hi"), msg("hello")]); - let (id2, source2) = inf.resolve(None, &p2).unwrap(); - assert_eq!(id2, "sess-1"); - assert_eq!(source2, SessionSource::Inferred); - } - - #[test] - fn inferer_strong_prefix_continuation_groups_calls() { - let mut inf = SessionInferer::new(true); - let p1 = prompt(vec![msg("u1")]); - let (id1, source1) = inf.resolve(None, &p1).unwrap(); - assert!(id1.starts_with("auto-")); - assert_eq!(source1, SessionSource::Inferred); - - // Full-prefix continuation → same session. - let p2 = prompt(vec![msg("u1"), msg("a1"), msg("u2")]); - let (id2, _) = inf.resolve(None, &p2).unwrap(); - assert_eq!(id2, id1); - - // Equality (retry of the same step) → same session. - let (id3, _) = inf.resolve(None, &p1).unwrap(); - assert_eq!(id3, id1); - } - - #[test] - fn inferer_distinct_prompts_start_new_sessions() { - let mut inf = SessionInferer::new(true); - let (id1, _) = inf - .resolve(None, &prompt(vec![msg("what is rust")])) - .unwrap(); - let (id2, _) = inf - .resolve(None, &prompt(vec![msg("tell me a joke")])) - .unwrap(); - assert_ne!(id1, id2); - } - - #[test] - fn inferer_prefix_is_directional() { - let mut inf = SessionInferer::new(true); - let (id1, _) = inf.resolve(None, &prompt(vec![msg("abc")])).unwrap(); - // Message-level prefix continuation: leading messages identical and - // the new prompt is longer → same session. - let (id2, _) = inf - .resolve(None, &prompt(vec![msg("abc"), msg("a1"), msg("u2")])) - .unwrap(); - assert_eq!(id2, id1); - // A prompt that diverges on the first message → new session. - let (id3, _) = inf.resolve(None, &prompt(vec![msg("abd")])).unwrap(); - assert_ne!(id3, id1, "abd is not a prefix continuation"); - } - - #[test] - fn inferer_chain_continuation_stays_in_session() { - let mut inf = SessionInferer::new(true); - let p1 = prompt(vec![msg("common")]); - let p2 = prompt(vec![msg("common"), msg("b")]); - let p3 = prompt(vec![msg("common"), msg("b"), msg("c")]); - let (id1, _) = inf.resolve(None, &p1).unwrap(); - let (id2, _) = inf.resolve(None, &p2).unwrap(); - let (id3, _) = inf.resolve(None, &p3).unwrap(); - assert_eq!(id2, id1); - assert_eq!(id3, id1); - } - - #[test] - fn inferer_capacity_evicts_oldest_prompt() { - let mut inf = SessionInferer::with_capacity(true, 2); - let p1 = prompt(vec![msg("one")]); - let (id1, _) = inf.resolve(None, &p1).unwrap(); - let p2 = prompt(vec![msg("two")]); - let (id2, _) = inf.resolve(None, &p2).unwrap(); - let p3 = prompt(vec![msg("three")]); - let (id3, _) = inf.resolve(None, &p3).unwrap(); - assert_ne!(id1, id2); - assert_ne!(id2, id3); - - // p1 was evicted; a new call extending it starts a NEW session. - let p1_ext = prompt(vec![msg("one"), msg("more")]); - let (id4, _) = inf.resolve(None, &p1_ext).unwrap(); - assert_ne!(id4, id1, "evicted prompt no longer matches"); - } - // ── helpers ───────────────────────────────────────────────────────────── #[test] diff --git a/aimux-providers/src/bedrock/convert.rs b/aimux-providers/src/bedrock/convert.rs index 0d9db282c..05a649883 100644 --- a/aimux-providers/src/bedrock/convert.rs +++ b/aimux-providers/src/bedrock/convert.rs @@ -297,18 +297,59 @@ pub(crate) fn validate_tool_result_content( for part in value { let unsupported = match part { ToolResultContent::Text(_) => false, - ToolResultContent::File(file) => match &file.data { - FileData::Data { .. } => { - crate::google::convert::tool_file_media_type(file)?; - false + ToolResultContent::File(file) => { + let allowed_data = matches!(file.data, FileData::Data { .. }) + || matches!(&file.data, FileData::Url { url, .. } if url.starts_with("s3:")); + if !allowed_data { + return Err( + aimux_core::error::AiMuxError::UnsupportedFunctionality( + "tool result content part".to_string(), + ), + ); } - FileData::Url { url, .. } => { - !url.starts_with("s3:") - || !(file.media_type.starts_with("image/") - || file.media_type.starts_with("video/")) + let media_type = crate::google::convert::tool_file_media_type(file)?; + let supported = matches!( + media_type.as_str(), + "image/jpeg" + | "image/png" + | "image/gif" + | "image/webp" + | "video/x-matroska" + | "video/quicktime" + | "video/mp4" + | "video/webm" + | "video/x-flv" + | "video/mpeg" + | "video/mpg" + | "video/wmv" + | "video/x-ms-wmv" + | "video/3gpp" + | "application/pdf" + | "text/csv" + | "application/msword" + | "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + | "application/vnd.ms-excel" + | "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + | "text/html" + | "text/plain" + | "text/markdown" + ); + if !supported { + let kind = match media_type.split('/').next() { + Some("image") => "image", + Some("video") => "video", + _ => "file", + }; + return Err( + aimux_core::error::AiMuxError::UnsupportedFunctionality( + format!("{kind} mime type: {media_type}"), + ), + ); } - _ => true, - }, + matches!(file.data, FileData::Url { .. }) + && !(media_type.starts_with("image/") + || media_type.starts_with("video/")) + } ToolResultContent::Custom { .. } => true, }; if unsupported { @@ -333,6 +374,17 @@ fn push_file_part(file: &FilePart, content: &mut Vec, doc_counter: &mut u data: FileBytes::Base64(data), } => data.clone(), FileData::Text { text } => base64::engine::general_purpose::STANDARD.encode(text), + FileData::Url { url, .. } + if url.starts_with("s3:") && file.media_type.starts_with("image/") => + { + content.push(json!({ + "image": { + "format": mime_to_image_format(&file.media_type), + "source": { "s3Location": { "uri": url } } + } + })); + return true; + } FileData::Url { .. } | FileData::Reference { .. } => return false, }; let is_text = matches!(file.data, FileData::Text { .. }); diff --git a/aimux-providers/src/cohere/convert.rs b/aimux-providers/src/cohere/convert.rs index c1130de5e..38b62e834 100644 --- a/aimux-providers/src/cohere/convert.rs +++ b/aimux-providers/src/cohere/convert.rs @@ -161,7 +161,11 @@ pub fn convert_prompt_to_cohere( format!("data:{};base64,{}", file.media_type, b64) } FileData::Url { url, .. } => url.clone(), - FileData::Reference { .. } => continue, + FileData::Reference { .. } => { + return Err(AiMuxError::UnsupportedFunctionality( + "image file parts with provider references".to_string(), + )); + } FileData::Text { .. } => { return Err(AiMuxError::UnsupportedFunctionality( "image file parts with text data".to_string(), @@ -181,7 +185,16 @@ pub fn convert_prompt_to_cohere( data: FileBytes::Base64(data), } => data.clone(), FileData::Text { text } => text.clone(), - FileData::Url { .. } | FileData::Reference { .. } => continue, + FileData::Url { .. } => { + return Err(AiMuxError::UnsupportedFunctionality( + "File URL data: URLs should be downloaded by the AI SDK and not reach this point. This indicates a configuration issue.".to_string(), + )); + } + FileData::Reference { .. } => { + return Err(AiMuxError::UnsupportedFunctionality( + "file parts with provider references".to_string(), + )); + } }; let mut doc_data = json!({ "text": text }); if let Some(filename) = &file.filename { diff --git a/aimux-providers/src/google/convert.rs b/aimux-providers/src/google/convert.rs index 3ecaaeb31..d62be09d6 100644 --- a/aimux-providers/src/google/convert.rs +++ b/aimux-providers/src/google/convert.rs @@ -229,9 +229,14 @@ fn convert_user_parts(content: &[UserPart], namespace: ProviderMetadataNamespace FileData::Data { data: FileBytes::Base64(data), } => data.clone(), - FileData::Url { url, .. } => { + FileData::Url { url, original_url } => { + let uri = if url.starts_with("gs:") { + original_url.as_deref().unwrap_or(url) + } else { + url + }; parts.push(json!({ - "fileData": { "mimeType": media_type, "fileUri": url } + "fileData": { "mimeType": media_type, "fileUri": uri } })); continue; } @@ -567,6 +572,24 @@ fn convert_tool_parts( ); } } + FileData::Url { url, original_url } + if namespace == ProviderMetadataNamespace::Vertex + && url.starts_with("gs:") + && original_url + .as_deref() + .unwrap_or(url) + .starts_with("gs://") + && matches!( + file.media_type.as_str(), + "image/png" + | "image/jpeg" + | "image/webp" + | "application/pdf" + | "text/plain" + ) => + { + files.push(json!({ "fileData": { "mimeType": file.media_type, "fileUri": original_url.as_deref().unwrap_or(url) } })); + } _ => texts.push( crate::openai::convert::tool_result_content_value( std::slice::from_ref(part), diff --git a/aimux-providers/src/mistral/convert.rs b/aimux-providers/src/mistral/convert.rs index 719e506d3..2be254714 100644 --- a/aimux-providers/src/mistral/convert.rs +++ b/aimux-providers/src/mistral/convert.rs @@ -229,6 +229,7 @@ fn convert_part_to_mistral(part: &UserPart) -> Result { UserPart::Text(TextPart { text, .. }) => json!({ "type": "text", "text": text }), UserPart::File(file) => { use base64::Engine; + let is_image = file.media_type.split('/').next() == Some("image"); let url = match &file.data { FileData::Data { data } => { let b64 = match data { @@ -237,17 +238,34 @@ fn convert_part_to_mistral(part: &UserPart) -> Result { } FileBytes::Base64(data) => data.clone(), }; - format!("data:{};base64,{}", file.media_type, b64) + let media_type = &file.media_type; + if !is_image && media_type != "application/pdf" { + return Err(AiMuxError::UnsupportedFunctionality( + "Only images and PDF file parts are supported".to_string(), + )); + } + format!("data:{media_type};base64,{b64}") + } + FileData::Url { url, .. } => { + if !is_image && file.media_type != "application/pdf" { + return Err(AiMuxError::UnsupportedFunctionality( + "Only images and PDF file parts are supported".to_string(), + )); + } + url.clone() + } + FileData::Reference { .. } => { + return Err(AiMuxError::UnsupportedFunctionality( + "file parts with provider references".to_string(), + )); } - FileData::Url { url, .. } => url.clone(), - FileData::Reference { .. } => return Ok(Value::Null), FileData::Text { .. } => { return Err(AiMuxError::UnsupportedFunctionality( "text file parts".to_string(), )); } }; - if file.media_type.split('/').next() == Some("image") { + if is_image { json!({ "type": "image_url", "image_url": url }) } else { json!({ "type": "document_url", "document_url": url }) diff --git a/aimux-providers/src/openai/convert.rs b/aimux-providers/src/openai/convert.rs index 9b14e9cf9..20869916d 100644 --- a/aimux-providers/src/openai/convert.rs +++ b/aimux-providers/src/openai/convert.rs @@ -359,7 +359,11 @@ fn resolve_provider_reference( } /// Convert a file part to the OpenAI format, handling images, audio, and PDF. -fn convert_file_part_to_openai(file: &FilePart, part_index: usize) -> Result { +fn convert_file_part_to_openai( + file: &FilePart, + part_index: usize, + provider: &str, +) -> Result { use base64::Engine; let FilePart { @@ -368,11 +372,17 @@ fn convert_file_part_to_openai(file: &FilePart, part_index: usize) -> Result { - let file_id = resolve_provider_reference(reference, "openai")?; - let mut part = json!({ "type": "file", "file": { "file_id": file_id } }); + let file_id = resolve_provider_reference(reference, provider)?; + let mut part = if provider == "deepseek" { + json!({ "type": "file", "file_id": file_id }) + } else { + json!({ "type": "file", "file": { "file_id": file_id } }) + }; if let Some(bpt) = prompt_cache_breakpoint { part["prompt_cache_breakpoint"] = bpt; } @@ -480,6 +490,34 @@ fn convert_message_to_openai( provider: &str, warnings: &mut Vec, ) -> Result, AiMuxError> { + if provider != "openai" && provider != "deepseek" { + let has_reference = match msg { + LanguageModelMessage::User { content, .. } => content.iter().any(|part| { + matches!( + part, + UserPart::File(FilePart { + data: FileData::Reference { .. }, + .. + }) + ) + }), + LanguageModelMessage::Assistant { content, .. } => content.iter().any(|part| { + matches!( + part, + AssistantPart::File(FilePart { + data: FileData::Reference { .. }, + .. + }) + ) + }), + _ => false, + }; + if has_reference { + return Err(AiMuxError::UnsupportedFunctionality( + "file parts with provider references".into(), + )); + } + } let message = match msg { LanguageModelMessage::System { content, @@ -490,7 +528,10 @@ fn convert_message_to_openai( SystemMessageMode::Developer => "developer", SystemMessageMode::System => "system", }; - let content = match get_prompt_cache_breakpoint(provider_options) { + let content = match (provider == "openai") + .then(|| get_prompt_cache_breakpoint(provider_options)) + .flatten() + { None => json!(content), Some(bpt) => json!([{ "type": "text", "text": content, "prompt_cache_breakpoint": bpt, @@ -509,7 +550,7 @@ fn convert_message_to_openai( } else { tool_result_to_content(&result.output) }; - if provider != "deepseek" + if provider == "openai" && let Some(breakpoint) = tool_result_cache_breakpoint(&result.output) .or_else(|| get_prompt_cache_breakpoint(&result.provider_options)) { @@ -522,14 +563,9 @@ fn convert_message_to_openai( return Ok(messages); } LanguageModelMessage::User { content, .. } => { - let all_plain_text = content.iter().all(|part| { - matches!( - part, - UserPart::Text(TextPart { - provider_options: None, - .. - }) - ) + let all_plain_text = content.len() == 1 && content.iter().all(|part| { + matches!(part, UserPart::Text(text) + if provider != "openai" || get_prompt_cache_breakpoint(&text.provider_options).is_none()) }); let content = if all_plain_text { json!( @@ -547,9 +583,10 @@ fn convert_message_to_openai( .iter() .enumerate() .map(|(index, part)| match part { - UserPart::Text(text) => Ok(convert_text_part_to_openai(text)), - UserPart::File(file) => convert_file_part_to_openai(file, index) - .map_err(AiMuxError::InvalidArgument), + UserPart::Text(text) => Ok(convert_text_part_to_openai(text, provider)), + UserPart::File(file) => + convert_file_part_to_openai(file, index, provider) + .map_err(AiMuxError::InvalidArgument), }) .collect::, _>>()? ) @@ -565,7 +602,11 @@ fn convert_message_to_openai( AssistantPart::Text(part) => text.push_str(&part.text), AssistantPart::Reasoning(part) => reasoning.push_str(&part.text), AssistantPart::ToolCall(part) => { - let arguments = part.input.to_string(); + let arguments = if provider == "openai" && !part.input.is_object() { + "{}".to_string() + } else { + part.input.to_string() + }; tool_calls.push(json!({ "type": "function", "id": part.tool_call_id, "function": { "name": part.tool_name, "arguments": arguments }, @@ -608,10 +649,10 @@ fn convert_message_to_openai( for part in content { match part { AssistantPart::Text(text) => { - parts.push(convert_text_part_to_openai(text)) + parts.push(convert_text_part_to_openai(text, provider)) } AssistantPart::File(file) => parts.push( - convert_file_part_to_openai(file, parts.len()) + convert_file_part_to_openai(file, parts.len(), provider) .map_err(AiMuxError::InvalidArgument)?, ), AssistantPart::Reasoning(_) @@ -842,9 +883,11 @@ pub(crate) fn tool_result_cache_breakpoint(output: &ToolResultOutput) -> Option< } } -fn convert_text_part_to_openai(part: &TextPart) -> Value { +fn convert_text_part_to_openai(part: &TextPart, provider: &str) -> Value { let mut value = json!({ "type": "text", "text": part.text }); - if let Some(bpt) = get_prompt_cache_breakpoint(&part.provider_options) { + if provider == "openai" + && let Some(bpt) = get_prompt_cache_breakpoint(&part.provider_options) + { value["prompt_cache_breakpoint"] = bpt; } value diff --git a/aimux-providers/src/openai/responses/convert.rs b/aimux-providers/src/openai/responses/convert.rs index ff67cab37..7075fa930 100644 --- a/aimux-providers/src/openai/responses/convert.rs +++ b/aimux-providers/src/openai/responses/convert.rs @@ -276,6 +276,15 @@ pub fn convert_to_responses_input( else { continue; }; + if let ToolResultOutput::ExecutionDenied { + provider_options, .. + } = output + && openai_sub_option(provider_options, "approvalId") + .and_then(|value| value.as_str().map(|id| !id.is_empty())) + == Some(true) + { + continue; + } let mut content_value = convert_tool_result_output(output, &mut warnings)?; if !matches!(output, ToolResultOutput::Content { .. }) && let Some(breakpoint) = @@ -326,7 +335,7 @@ fn convert_user_part(part: &UserPart) -> Result { filename, provider_options, }) => { - let is_image = media_type.starts_with("image"); + let is_image = media_type.split('/').next() == Some("image"); let mut item = match data { FileData::Data { data } => { let b64 = match data { @@ -354,8 +363,13 @@ fn convert_user_part(part: &UserPart) -> Result { json!({ "type": "input_file", "file_url": url }) } } - FileData::Reference { .. } => { - json!({ "type": "input_text", "text": "" }) + FileData::Reference { reference } => { + let file_id = reference.get("openai").ok_or_else(|| { + AiMuxError::InvalidArgument( + "missing file reference for provider openai".into(), + ) + })?; + json!({ "type": if is_image { "input_image" } else { "input_file" }, "file_id": file_id }) } FileData::Text { .. } => { return Err(AiMuxError::UnsupportedFunctionality( @@ -364,7 +378,6 @@ fn convert_user_part(part: &UserPart) -> Result { } }; if item["type"] == "input_image" - && matches!(data, FileData::Data { .. }) && let Some(detail) = openai_sub_option(provider_options, "imageDetail") { item["detail"] = detail; @@ -397,12 +410,6 @@ fn convert_tool_result_output( continue; } let mut converted = convert_user_part(&UserPart::File(part.clone()))?; - if let FileData::Reference { reference } = &part.data { - let file_id = reference.get("openai").ok_or_else(|| { - AiMuxError::InvalidArgument("missing file reference for provider".into()) - })?; - converted = json!({"type": if part.media_type.starts_with("image/") { "input_image" } else { "input_file" }, "file_id":file_id}); - } if converted["type"] == "input_file" && converted.get("file_data").is_some() { converted["filename"] = json!(part.filename.as_deref().unwrap_or("data")); } diff --git a/aimux-providers/src/vertex/model.rs b/aimux-providers/src/vertex/model.rs index 1c2123c45..ce3c2a7a4 100644 --- a/aimux-providers/src/vertex/model.rs +++ b/aimux-providers/src/vertex/model.rs @@ -204,6 +204,19 @@ impl LanguageModel for VertexModel { ) ) }), + aimux_core::language_model_message::LanguageModelMessage::User { content, .. } => { + content.iter().any(|part| { + matches!( + part, + aimux_core::language_model_message::UserPart::File( + aimux_core::language_model_message::FilePart { + data: aimux_core::shared::FileData::Reference { .. }, + .. + } + ) + ) + }) + } _ => false, }) { return Err(AiMuxError::UnsupportedFunctionality( @@ -334,6 +347,19 @@ impl LanguageModel for VertexModel { ) ) }), + aimux_core::language_model_message::LanguageModelMessage::User { content, .. } => { + content.iter().any(|part| { + matches!( + part, + aimux_core::language_model_message::UserPart::File( + aimux_core::language_model_message::FilePart { + data: aimux_core::shared::FileData::Reference { .. }, + .. + } + ) + ) + }) + } _ => false, }) { return Err(AiMuxError::UnsupportedFunctionality( diff --git a/aimux-providers/src/xai/responses/convert.rs b/aimux-providers/src/xai/responses/convert.rs index a6c559d27..766cbd50d 100644 --- a/aimux-providers/src/xai/responses/convert.rs +++ b/aimux-providers/src/xai/responses/convert.rs @@ -308,6 +308,14 @@ pub fn convert_to_xai_responses_input( content_parts.push(json!({ "type": "input_text", "text": text })); } UserPart::File(file) => { + if matches!(file.data, FileData::Data { .. }) + && file.media_type.split('/').next() != Some("image") + { + return Err(AiMuxError::UnsupportedFunctionality(format!( + "file part media type {} as inline data (xAI Responses requires a URL or a Files API reference for non-image files)", + file.media_type + ))); + } content_parts.push(match &file.data { FileData::Data { data: FileBytes::Binary(bytes), diff --git a/aimux-providers/tests/google_files_test.rs b/aimux-providers/tests/google_files_test.rs index 938fe1df6..f0e701e9f 100644 --- a/aimux-providers/tests/google_files_test.rs +++ b/aimux-providers/tests/google_files_test.rs @@ -562,3 +562,29 @@ async fn transient_upload_failure_is_retried_without_re_initiating() { ); assert_eq!(upload_attempts.load(std::sync::atomic::Ordering::SeqCst), 2); } + +// ════════════════════════════════════════════════════════════════════════════ +// get_model_path (TS: get-model-path.test.ts) +// ════════════════════════════════════════════════════════════════════════════ + +mod get_model_path_tests { + use aimux_providers::google::utils::get_model_path; + + #[test] + fn pass_through_for_models_slash() { + assert_eq!(get_model_path("models/some-model"), "models/some-model"); + } + + #[test] + fn pass_through_for_tuned_models_slash() { + assert_eq!( + get_model_path("tunedModels/some-model"), + "tunedModels/some-model" + ); + } + + #[test] + fn add_prefix_to_models_without_slash() { + assert_eq!(get_model_path("some-model"), "models/some-model"); + } +} diff --git a/aimux-providers/tests/groq_test.rs b/aimux-providers/tests/groq_test.rs index aa99fc7a2..be9a900c6 100644 --- a/aimux-providers/tests/groq_test.rs +++ b/aimux-providers/tests/groq_test.rs @@ -26,7 +26,8 @@ use wiremock::{Mock, MockServer, ResponseTemplate}; use aimux_core::language_model::LanguageModel; use aimux_core::language_model_message::{ - AssistantPart, FilePart, LanguageModelMessage, LanguageModelPrompt, TextPart, UserPart, + AssistantPart, FilePart, LanguageModelMessage, LanguageModelPrompt, ReasoningPart, TextPart, + ToolCallPart, ToolPart, ToolResultOutput, ToolResultPart, UserPart, }; use aimux_core::options::{CallOptions, ResponseFormat, Tool}; use aimux_core::result::{GenerateContent, ReasoningOutput}; @@ -284,6 +285,97 @@ mod convert_messages { // ── tool calls ── + /// TS: "should stringify arguments to tool calls" + #[tokio::test] + async fn tool_call_arguments_stringified() { + let server = MockServer::start().await; + mock_json(&server, text_completion_body()).await; + + let model = make_provider(&server); + + let prompt: LanguageModelPrompt = vec![ + LanguageModelMessage::Assistant { + content: vec![AssistantPart::ToolCall(ToolCallPart { + tool_call_id: "quux".into(), + tool_name: "thwomp".into(), + input: json!({"foo":"bar123"}), + provider_executed: None, + provider_options: None, + })], + provider_options: None, + }, + LanguageModelMessage::Tool { + content: vec![ToolPart::ToolResult(ToolResultPart { + tool_call_id: "quux".into(), + tool_name: "thwomp".into(), + output: ToolResultOutput::Json { + value: json!({"oof":"321rab"}), + provider_options: None, + }, + provider_options: None, + })], + provider_options: None, + }, + ]; + model.do_generate(&default_options(prompt)).await.unwrap(); + + let body = first_request_body(&server).await; + // Assistant message + assert_eq!(body["messages"][0]["role"], "assistant"); + assert_eq!(body["messages"][0]["content"], ""); + assert_eq!(body["messages"][0]["tool_calls"][0]["id"], "quux"); + assert_eq!(body["messages"][0]["tool_calls"][0]["type"], "function"); + assert_eq!( + body["messages"][0]["tool_calls"][0]["function"]["name"], + "thwomp" + ); + assert_eq!( + body["messages"][0]["tool_calls"][0]["function"]["arguments"], + r#"{"foo":"bar123"}"# + ); + // Tool message + assert_eq!(body["messages"][1]["role"], "tool"); + assert_eq!(body["messages"][1]["tool_call_id"], "quux"); + assert_eq!(body["messages"][1]["content"], r#"{"oof":"321rab"}"#); + } + + /// TS: "should send reasoning if present" + #[tokio::test] + async fn reasoning_in_assistant_message() { + let server = MockServer::start().await; + mock_json(&server, text_completion_body()).await; + + let model = make_provider(&server); + + let prompt: LanguageModelPrompt = vec![LanguageModelMessage::Assistant { + content: vec![ + AssistantPart::Reasoning(ReasoningPart { + text: "I think the tool will return the correct value.".into(), + provider_options: None, + }), + AssistantPart::ToolCall(ToolCallPart { + tool_call_id: "quux".into(), + tool_name: "thwomp".into(), + input: json!({"foo":"bar123"}), + provider_executed: None, + provider_options: None, + }), + ], + provider_options: None, + }]; + model.do_generate(&default_options(prompt)).await.unwrap(); + + let body = first_request_body(&server).await; + let msg = &body["messages"][0]; + assert_eq!(msg["role"], "assistant"); + assert_eq!(msg["content"], ""); + assert_eq!( + msg["reasoning"], + "I think the tool will return the correct value." + ); + assert_eq!(msg["tool_calls"][0]["function"]["name"], "thwomp"); + } + /// TS: "should not include reasoning field when no reasoning content is present" #[tokio::test] async fn no_reasoning_field_when_absent() { diff --git a/aimux-providers/tests/mistral_embedding_test.rs b/aimux-providers/tests/mistral_embedding_test.rs index 7d5940758..66eed80be 100644 --- a/aimux-providers/tests/mistral_embedding_test.rs +++ b/aimux-providers/tests/mistral_embedding_test.rs @@ -10,6 +10,8 @@ use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; use aimux_core::embedding_model::{EmbeddingCallOptions, EmbeddingModel}; +use aimux_core::tool::FunctionTool; +use aimux_providers::mistral::convert::prepare_tools; use aimux_providers::{MistralConfig, MistralProvider}; const TEST_VALUES: &[&str] = &["sunny day at the beach", "rainy day in the city"]; @@ -211,3 +213,13 @@ async fn should_pass_headers() { Some("request-header-value") ); } + +/// TS: "should include strict field when set" +#[test] +fn prepare_tools_function_tool_with_strict() { + let tool = FunctionTool::new("strictTool", json!({ "type": "object", "properties": {} })) + .with_strict(true); + let result = prepare_tools(&Some(vec![tool]), None); + let tools = result.tools.expect("tools should be Some"); + assert_eq!(tools[0]["function"]["strict"], json!(true)); +} From 1acaa3372d49ec7180936bd2d0c4ea032147f5b0 Mon Sep 17 00:00:00 2001 From: chenhaonan Date: Tue, 6 Oct 2026 17:40:37 +0800 Subject: [PATCH 09/10] test(providers): Groq reference test expects upstream's error What: the Groq file-reference test asserts the upstream error text, 'file parts with provider references', instead of the earlier 'No provider reference found'. Why: the converter now rejects references as upstream does; the test still pinned the old message. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01WGwanvo9WLU9HWD7sRR8TS --- aimux-providers/tests/groq_test.rs | 7 +++---- bindings/node/src/types/ContentPart.ts | 22 +++++++++++----------- bindings/node/src/types/StreamPart.ts | 12 ++++++------ 3 files changed, 20 insertions(+), 21 deletions(-) diff --git a/aimux-providers/tests/groq_test.rs b/aimux-providers/tests/groq_test.rs index 143d6adab..1d7e09ac4 100644 --- a/aimux-providers/tests/groq_test.rs +++ b/aimux-providers/tests/groq_test.rs @@ -420,14 +420,13 @@ mod convert_messages { })], provider_options: None, }]; - // Groq references don't contain an "openai" key, so the converter - // returns an InvalidArgument error instead of panicking. + // Upstream rejects file parts with provider references outright. let result = model.do_generate(&default_options(prompt)).await; assert!(result.is_err()); let err = result.unwrap_err(); assert!( - err.to_string().contains("No provider reference found"), - "error should mention 'No provider reference found': {err}" + err.to_string().contains("file parts with provider references"), + "error should mention 'file parts with provider references': {err}" ); } } diff --git a/bindings/node/src/types/ContentPart.ts b/bindings/node/src/types/ContentPart.ts index bd1ddab7e..fd965cc5e 100644 --- a/bindings/node/src/types/ContentPart.ts +++ b/bindings/node/src/types/ContentPart.ts @@ -7,32 +7,32 @@ import type { JsonValue } from "./serde_json/JsonValue"; * * Shared between `ModelMessage` (user-facing) and `LanguageModelPrompt` (provider-facing). */ -export type ContentPart = { "type": "text", text: string, +export type ContentPart = { "type": "text", text: string, /** * Provider-specific options for this part (e.g. `openai.promptCacheBreakpoint`). */ -provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, } | { "type": "custom", kind: string, provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, } | { "type": "reasoning_file", data: GeneratedFileData, media_type: string, provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, } | { "type": "tool_approval_request", approval_id: string, tool_call_id: string, reason?: string | null, is_automatic?: boolean | null, signature?: string | null, input_schema_input?: JsonValue | null, } | { "type": "image", image: Array, media_type: string, provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, } | { "type": "file", data: Array, media_type: string, filename?: string | null, provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, } | { "type": "file_base64", data: string, media_type: string, filename?: string | null, provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, } | { "type": "file_url", url: string, media_type: string, provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, } | { "type": "file_reference", media_type: string, reference: JsonValue, filename?: string | null, provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, } | { "type": "reasoning", text: string, signature: string | null, +provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, } | { "type": "custom", kind: string, provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, } | { "type": "reasoning_file", data: GeneratedFileData, media_type: string, provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, } | { "type": "tool_approval_request", approval_id: string, tool_call_id: string, reason?: string | null, is_automatic?: boolean | null, signature?: string | null, input_schema_input?: JsonValue | null, } | { "type": "image", image: Array, media_type: string, provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, } | { "type": "file", data: Array, media_type: string, filename?: string | null, provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, } | { "type": "file_base64", data: string, media_type: string, filename?: string | null, provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, } | { "type": "file_url", url: string, media_type: string, provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, } | { "type": "file_reference", media_type: string, reference: JsonValue, filename?: string | null, provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, } | { "type": "reasoning", text: string, signature: string | null, /** * Provider-specific options for this part (e.g. * `anthropic.signature`, `anthropic.redactedData`, * `anthropic.cacheControl`). */ -provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, } | { "type": "tool_call", tool_call_id: string, tool_name: string, +provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, } | { "type": "tool_call", tool_call_id: string, tool_name: string, /** * Arguments as a JSON value (usually an object). */ -input: JsonValue, +input: JsonValue, /** * Whether the tool call is executed by the provider rather than by * the client. This is part of the prompt contract because providers * need it to replay server tool calls on a later turn. */ -provider_executed?: boolean | null, +provider_executed?: boolean | null, /** * Provider-specific options for this part (e.g. * `anthropic.cacheControl`). */ -provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, } | { "type": "tool_result", tool_call_id: string, +provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, } | { "type": "tool_result", tool_call_id: string, /** * The tool's output (usually a JSON value or plain text). * @@ -41,25 +41,25 @@ provider_options?: { [key in string]: { [key in string]: JsonValue } } | null, } * so multi-part tool messages constructed either way round-trip * correctly. Serialization always emits `result`. */ -result: JsonValue, +result: JsonValue, /** * The name of the tool that produced this result (optional on the * user-input side; providers that need it can look it up from the * preceding tool call). */ -tool_name?: string | null, +tool_name?: string | null, /** * Whether the result is an error or error message. */ -is_error?: boolean | null, +is_error?: boolean | null, /** * Whether the result is preliminary (replaces prior, e.g. image previews). */ -preliminary?: boolean | null, +preliminary?: boolean | null, /** * Whether the tool is dynamic (defined at runtime, e.g. MCP tools). */ -dynamic?: boolean | null, +dynamic?: boolean | null, /** * Provider-specific options for this part (e.g. * `anthropic.cacheControl`). diff --git a/bindings/node/src/types/StreamPart.ts b/bindings/node/src/types/StreamPart.ts index adab7631d..a7e850989 100644 --- a/bindings/node/src/types/StreamPart.ts +++ b/bindings/node/src/types/StreamPart.ts @@ -19,27 +19,27 @@ import type { JsonValue } from "./serde_json/JsonValue"; * [`StreamPart`] (raw argument text); `stream_text` emits [`TextStreamPart`] * (parsed and validated [`ToolCall`]). */ -export type StreamPart = { "TextStart": { id: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "TextDelta": { id: string, delta: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "TextEnd": { id: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "StreamStart": { warnings: Array, } } | { "Finish": { finish_reason: FinishReason, usage: Usage, provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "Error": { error: AiMuxError, } } | { "ToolInputStart": { id: string, tool_name: string, +export type StreamPart = { "TextStart": { id: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "TextDelta": { id: string, delta: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "TextEnd": { id: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "StreamStart": { warnings: Array, } } | { "Finish": { finish_reason: FinishReason, usage: Usage, provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "Error": { error: AiMuxError, } } | { "ToolInputStart": { id: string, tool_name: string, /** * Whether the tool call will be executed by the provider. */ -provider_executed?: boolean | null, +provider_executed?: boolean | null, /** * Whether the tool is dynamic (defined at runtime, e.g. MCP tools). */ -dynamic?: boolean | null, +dynamic?: boolean | null, /** * Optional title for the tool call. */ -title?: string | null, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ToolInputDelta": { id: string, delta: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ToolInputEnd": { id: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ToolCall": ToolCall } | { "ToolResult": ToolResult } | { "File": GeneratedFile } | { "ReasoningFile": ReasoningFileOutput } | { "Custom": { kind: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ToolApprovalRequest": ToolApprovalRequestOutput } | { "ReasoningStart": { id: string, +title?: string | null, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ToolInputDelta": { id: string, delta: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ToolInputEnd": { id: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ToolCall": ToolCall } | { "ToolResult": ToolResult } | { "File": GeneratedFile } | { "ReasoningFile": ReasoningFileOutput } | { "Custom": { kind: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ToolApprovalRequest": ToolApprovalRequestOutput } | { "ReasoningStart": { id: string, /** * Provider-specific metadata (e.g. xAI `itemId`). */ -provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ReasoningDelta": { id: string, delta: string, +provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ReasoningDelta": { id: string, delta: string, /** * Provider-specific metadata (e.g. xAI `itemId`). */ -provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ReasoningEnd": { id: string, +provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ReasoningEnd": { id: string, /** * Provider-specific metadata (e.g. xAI `itemId`, `reasoningEncryptedContent`). */ From 0986da5da5fe74a44786edde88acf3e6b144ad3e Mon Sep 17 00:00:00 2001 From: chenhaonan Date: Tue, 6 Oct 2026 18:16:11 +0800 Subject: [PATCH 10/10] style: format the Groq reference test Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01WGwanvo9WLU9HWD7sRR8TS --- aimux-providers/tests/groq_test.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/aimux-providers/tests/groq_test.rs b/aimux-providers/tests/groq_test.rs index 1d7e09ac4..a206caf91 100644 --- a/aimux-providers/tests/groq_test.rs +++ b/aimux-providers/tests/groq_test.rs @@ -425,7 +425,8 @@ mod convert_messages { assert!(result.is_err()); let err = result.unwrap_err(); assert!( - err.to_string().contains("file parts with provider references"), + err.to_string() + .contains("file parts with provider references"), "error should mention 'file parts with provider references': {err}" ); }