diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 398d4339e..d17dfa22c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -390,9 +390,9 @@ jobs: working-directory: bindings/flutter/example run: flutter test - # ── Contract tests: shared JSON fixtures (catches cross-language type drift) ── + # Generated files must match their generators. contract-tests: - name: Contract tests + name: Generator drift checks runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -400,29 +400,13 @@ jobs: run: python3 scripts/gen_providers_doc.py --check - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - - uses: actions/setup-node@v4 - with: - node-version: 22 - - name: Run Rust contract tests - run: cargo test --test contract_test -p aimux-core - name: ts-rs export drift (checked-in types must match generator output) run: python3 scripts/gen_ts_types.py --check - - name: Build Node binding - working-directory: bindings/node - run: | - npm ci - npx napi build --platform - - name: Run Node contract tests - working-directory: contract-tests - run: | - node --experimental-strip-types run-node.ts - # ── Swift contract tests: the eighth binding joins the shared fixtures ───── - # Needs macOS (XCTest) and the FFI cdylib, which the Swift package links via - # the CAimuxFFI system module. The Dart side needs no job here: flutter-binding - # already runs `flutter test`, which picks up bindings/flutter/test/contract_test.dart. + # Swift tests need macOS (XCTest) and the FFI cdylib, which the Swift package + # links via the CAimuxFFI system module. swift-contract-tests: - name: Swift contract tests + name: Swift tests runs-on: macos-latest steps: - uses: actions/checkout@v4 @@ -430,7 +414,7 @@ jobs: - uses: Swatinem/rust-cache@v2 - name: Build the FFI library the Swift package links against run: cargo build -p aimux-ffi --release - - name: Run Swift contract tests + - name: Run Swift tests working-directory: bindings/swift env: DYLD_LIBRARY_PATH: ${{ github.workspace }}/target/release diff --git a/CHANGELOG.md b/CHANGELOG.md index 89227283c..d20ed5b44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Breaking +- Tool-call and tool-result types are unified, one per protocol layer + (`aimux-core`). A provider emits `tool::RawToolCall` (`input` is the raw + argument text) in `GenerateContent::ToolCall(..)` and + `StreamPart::ToolCall(..)`, and `tool::ToolResult` in the `ToolResult` + variants; these variants are newtype variants now. `StreamPart` is generic + over the tool-call type: a provider's `do_stream` yields `StreamPart` + (= `StreamPart`), `stream_text` and everything after it yield + `TextStreamPart` (= `StreamPart`, parsed input with `invalid` / + `error`). `RawToolCall` moved from `parse_tool_call` to `tool`. Wire format: + enum variants keep their JSON shape; a provider-layer `StreamPart::ToolCall` + has a string `input` and no `invalid` / `error`; a + `GenerateContent::ToolCall` with a non-string `input` no longer + deserializes; the standalone `tool::ToolResult` gains a required + `tool_name` and optional `dynamic` / `provider_metadata`. + - Removed the generated `ProviderName` type in every binding (Rust enum, TS const object, Go/Java/Kotlin consts, Swift enum, Dart consts, Python `Literal`) and `scripts/gen_provider_names.py`. Provider names are plain diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fce47f3de..240a759ab 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,7 +13,6 @@ aimux/ ├── aimux-provider-utils/ # HTTP utilities: retry, backoff, error parsing, API-key loading, streamed tool-call tracking ├── aimux-ffi/ # C ABI (opaque handle + JSON + push callback) for non-native bindings ├── bindings/ # Node, Python, Swift, Kotlin, Flutter, Go, C — share one Rust core -├── contract-tests/ # Shared JSON fixtures exercised across languages ├── rfc/ # Design docs (RFCs) ├── docs/ # Public product docs (see docs/README.md) └── scripts/ # Code-generation and maintenance scripts @@ -50,9 +49,6 @@ cargo test --workspace # A single crate cargo test -p aimux-providers --tests - -# Contract tests (cross-language shared fixtures) -cargo test --test contract_test -p aimux-core ``` If you add or change a provider's request/response shapes, record a new @@ -108,8 +104,7 @@ Keep the subject line imperative and ≤ 72 characters. 2. Make your change with focused commits. 3. Ensure `cargo fmt`, `cargo clippy -D warnings`, and `cargo test --workspace` all pass locally. CI runs the same. -4. If you add a provider or binding, include cassette tests or contract - fixtures as appropriate. +4. If you add a provider or binding, include cassette tests. 5. Update the relevant doc (README, `docs/`, or `rfc/`) if your change affects the public surface. 6. Open a PR against `master` and fill in the template. diff --git a/aimux-core/src/content.rs b/aimux-core/src/content.rs index e6dfe464a..8e97e5948 100644 --- a/aimux-core/src/content.rs +++ b/aimux-core/src/content.rs @@ -104,11 +104,6 @@ pub enum ContentPart { /// need it to replay server tool calls on a later turn. #[serde(default, skip_serializing_if = "Option::is_none")] provider_executed: Option, - /// Provider-assigned thought signature (e.g. Google Gemini - /// `thoughtSignature`). Must be echoed back verbatim on the follow-up - /// turn when the tool result is sent. - #[serde(default, skip_serializing_if = "Option::is_none")] - thought_signature: Option, /// Provider-specific options for this part (e.g. /// `anthropic.cacheControl`). #[serde(default, skip_serializing_if = "Option::is_none")] @@ -167,7 +162,6 @@ impl ContentPart { tool_name: tool_name.into(), input, provider_executed: None, - thought_signature: None, provider_options: None, } } diff --git a/aimux-core/src/generate.rs b/aimux-core/src/generate.rs index 274939bb7..08b57761c 100644 --- a/aimux-core/src/generate.rs +++ b/aimux-core/src/generate.rs @@ -21,24 +21,24 @@ use crate::language_model::LanguageModel; use crate::language_model_message::convert_to_language_model_prompt; use crate::message::{ModelMessage, ModelPrompt}; use crate::options::{CallOptions, ResponseFormat, ToolChoice}; -use crate::parse_tool_call::{RawToolCall, ToolCallRepair, parse_tool_call}; +use crate::parse_tool_call::{ToolCallRepair, parse_tool_call}; use crate::result::{ FilePart, GenerateContent, GenerateResult, ReasoningPart, SourcePart, StreamResult, StreamTextResultAggregated, }; -use crate::stream_part::StreamPart; +use crate::stream_part::{StreamPart, TextStreamPart}; use crate::tool::Tool; use crate::types::{FinishReason, ReasoningEffort, Usage, Warning}; use crate::{AbortSignal, retry, timeout}; /// Matches the AI SDK's `isOutputChunk`: only chunks containing model output /// start or reset the first/chunk output timers. -fn is_output_chunk(part: &StreamPart) -> bool { +fn is_output_chunk(part: &StreamPart) -> bool { match part { StreamPart::TextDelta { delta, .. } | StreamPart::ReasoningDelta { delta, .. } | StreamPart::ToolInputDelta { delta, .. } => !delta.is_empty(), - StreamPart::ToolCall { .. } | StreamPart::File { .. } => true, + StreamPart::ToolCall(_) | StreamPart::File { .. } => true, _ => false, } } @@ -107,6 +107,12 @@ impl GenerateTextOptions { self, prompt: crate::language_model_message::LanguageModelPrompt, ) -> CallOptions { + let tool_choice = self.tool_choice.or_else(|| { + self.tools + .as_ref() + .filter(|tools| !tools.is_empty()) + .map(|_| ToolChoice::Auto) + }); CallOptions { prompt, max_output_tokens: self.max_output_tokens, @@ -119,7 +125,7 @@ impl GenerateTextOptions { response_format: self.response_format, seed: self.seed, tools: self.tools, - tool_choice: self.tool_choice.unwrap_or_default(), + tool_choice, headers: self.headers, provider_options: self.provider_options, reasoning: self.reasoning, @@ -221,8 +227,8 @@ pub struct GenerateObjectResult { /// Result of `stream_text` (user-facing). pub struct StreamTextResult { - /// The stream of `StreamPart` items. - pub stream: Pin> + Send>>, + /// The stream of `TextStreamPart` items. + pub stream: Pin> + Send>>, /// The request body that was sent (for debugging / cache probing, RFC-0015). pub request_body: Option, /// Response headers. @@ -373,49 +379,11 @@ impl StreamTextResult { StreamPart::ReasoningEnd { provider_metadata, .. } => rm.reasoning_end(provider_metadata), - StreamPart::ToolCall { - tool_call_id, - tool_name, - input, - provider_executed, - dynamic, - thought_signature, - provider_metadata, - invalid, - error, - .. - } => { - let call = crate::tool::ToolCall { - tool_call_id, - tool_name, - input, - provider_executed, - dynamic, - thought_signature, - provider_metadata, - invalid, - error, - }; + StreamPart::ToolCall(call) => { rm.tool_call(&call); tool_calls.push(call); } - StreamPart::ToolResult { - tool_call_id, - tool_name, - result, - is_error, - preliminary, - dynamic, - provider_metadata, - } => rm.tool_result( - tool_call_id, - tool_name, - result, - is_error, - preliminary, - dynamic, - provider_metadata, - ), + StreamPart::ToolResult(result) => rm.tool_result(result), StreamPart::Source { id, source_type, @@ -656,26 +624,9 @@ pub async fn generate_text( text.push_str(t); rm.text(t, provider_metadata.as_ref()); } - GenerateContent::ToolCall { - tool_call_id, - tool_name, - input, - provider_executed, - dynamic, - thought_signature, - provider_metadata, - .. - } => { + GenerateContent::ToolCall(call) => { let parsed = parse_tool_call( - RawToolCall { - tool_call_id: tool_call_id.clone(), - tool_name: tool_name.clone(), - input: input.clone(), - provider_executed: *provider_executed, - dynamic: *dynamic, - thought_signature: thought_signature.clone(), - provider_metadata: provider_metadata.clone(), - }, + call.clone(), tools.as_deref(), repair_tool_call.as_ref(), &messages, @@ -715,25 +666,7 @@ pub async fn generate_text( } // Provider-executed results stay in the assistant message so the // provider can replay its own server-tool transcript next turn. - GenerateContent::ToolResult { - tool_call_id, - tool_name, - result, - is_error, - preliminary, - dynamic, - provider_metadata, - } => { - rm.tool_result( - tool_call_id.clone(), - tool_name.clone(), - result.clone(), - *is_error, - *preliminary, - *dynamic, - provider_metadata.clone(), - ); - } + GenerateContent::ToolResult(result) => rm.tool_result(result.clone()), } } @@ -1097,48 +1030,22 @@ pub async fn stream_text( }) }; let mut stream = stream; - let stream: Pin> + Send>> = + let stream: Pin> + Send>> = Box::pin(async_stream::stream! { while let Some(item) = stream.next().await { match item { - Ok(StreamPart::ToolCall { - tool_call_id, - tool_name, - input, - provider_executed, - dynamic, - thought_signature, - provider_metadata, - .. - }) => { + Ok(StreamPart::ToolCall(raw)) => { let parsed = parse_tool_call( - RawToolCall { - tool_call_id, - tool_name, - input: crate::parse_tool_call::raw_tool_input(&input), - provider_executed, - dynamic, - thought_signature, - provider_metadata, - }, + raw, tools.as_deref(), repair_tool_call.as_ref(), &messages, operation_instructions.as_deref(), ).await; - yield Ok(StreamPart::ToolCall { - tool_call_id: parsed.tool_call_id, - tool_name: parsed.tool_name, - input: parsed.input, - provider_executed: parsed.provider_executed, - dynamic: parsed.dynamic, - thought_signature: parsed.thought_signature, - invalid: parsed.invalid, - error: parsed.error, - provider_metadata: parsed.provider_metadata, - }); + yield Ok(StreamPart::ToolCall(parsed)); } - item => yield item, + Ok(part) => yield Ok(part.map_tool_call(|_| unreachable!("matched above"))), + Err(error) => yield Err(error), } } }); @@ -1749,7 +1656,7 @@ mod stream_aggregation_tests { use super::*; use crate::types::FinishReasonUnified; - fn result_from(parts: Vec>) -> StreamTextResult { + fn result_from(parts: Vec>) -> StreamTextResult { StreamTextResult { stream: Box::pin(futures::stream::iter(parts)), request_body: None, @@ -1757,7 +1664,7 @@ mod stream_aggregation_tests { } } - fn delta(text: &str) -> StreamPart { + fn delta(text: &str) -> TextStreamPart { StreamPart::TextDelta { id: "text-1".into(), delta: text.into(), @@ -1765,7 +1672,7 @@ mod stream_aggregation_tests { } } - fn finish() -> StreamPart { + fn finish() -> TextStreamPart { StreamPart::Finish { finish_reason: FinishReason { unified: FinishReasonUnified::Stop, diff --git a/aimux-core/src/lib.rs b/aimux-core/src/lib.rs index 4c15ad955..00d85ff9e 100644 --- a/aimux-core/src/lib.rs +++ b/aimux-core/src/lib.rs @@ -96,9 +96,8 @@ pub mod prelude { }; pub use crate::options::{CallOptions, ResponseFormat, ToolChoice}; pub use crate::parse_tool_call::{ - RawToolCall, ToolCallRepair, ToolCallRepairContext, ToolCallRepairReply, - apply_tool_call_repair, apply_tool_call_repair_to_result, tool_call_repair_context, - tool_call_repair_inputs, + ToolCallRepair, ToolCallRepairContext, ToolCallRepairReply, apply_tool_call_repair, + apply_tool_call_repair_to_result, tool_call_repair_context, tool_call_repair_inputs, }; pub use crate::provider::Provider; pub use crate::reranking_model::{ @@ -120,8 +119,11 @@ pub mod prelude { SharedProviderOptions, SharedProviderReference, Size, }; pub use crate::speech_model::{SpeechCallOptions, SpeechModel, SpeechResult, generate_speech}; - pub use crate::stream_part::StreamPart; - pub use crate::tool::{FunctionTool, ProviderTool, Tool, ToolCall, ToolResult}; + pub use crate::stream_part::{StreamPart, TextStreamPart}; + pub use crate::tool::{ + FunctionTool, FunctionToolInputExample, ProviderTool, RawToolCall, Tool, ToolCall, + ToolResult, + }; pub use crate::transcription_model::{ AudioChunk, InputAudioFormat, TranscriptionCallOptions, TranscriptionModel, TranscriptionResult, TranscriptionStreamOptions, TranscriptionStreamPart, diff --git a/aimux-core/src/moa.rs b/aimux-core/src/moa.rs index 71979d609..5111ced50 100644 --- a/aimux-core/src/moa.rs +++ b/aimux-core/src/moa.rs @@ -16,7 +16,7 @@ use serde::Deserialize; use crate::composite::{ChildModel, add_usage, build_aggregator_prompt, extract_text}; use crate::error::AiMuxError; use crate::language_model::LanguageModel; -use crate::options::{CallOptions, ToolChoice}; +use crate::options::CallOptions; use crate::result::{GenerateResult, StreamResult}; use crate::retry; use crate::stream_part::StreamPart; @@ -98,12 +98,12 @@ impl MoaModel { /// Build reference call options from the user's options. When /// `strip_reference_tools` is set, `tools` is cleared and `tool_choice` is - /// reset to `Auto` so references don't carry tool schemas. + /// cleared so references don't carry tool schemas. fn reference_options(&self, options: &CallOptions) -> CallOptions { let mut o = options.clone(); if self.config.strip_reference_tools { o.tools = None; - o.tool_choice = ToolChoice::Auto; + o.tool_choice = None; } o } @@ -306,699 +306,3 @@ 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::result::GenerateContent; - use crate::stream_part::StreamPart; - use crate::types::{FinishReason, FinishReasonUnified, TokenUsage}; - use async_trait::async_trait; - use futures::stream; - use std::sync::Arc; - use std::sync::atomic::{AtomicUsize, Ordering}; - - /// A mock child that returns fixed text with fixed usage. `fail` forces an - /// error. Used as both reference and aggregator. - struct MockChild { - name: &'static str, - text: String, - fail: bool, - usage: Usage, - retry_failures: usize, - calls: Arc, - } - - #[async_trait] - impl LanguageModel for MockChild { - fn provider(&self) -> &str { - "mock" - } - fn model_id(&self) -> &str { - self.name - } - async fn do_generate(&self, _options: &CallOptions) -> Result { - let attempt = self.calls.fetch_add(1, Ordering::SeqCst) + 1; - if self.fail { - return Err(AiMuxError::Other(format!("{} failed", self.name))); - } - if attempt <= self.retry_failures { - return Err(retryable_error(self.name)); - } - Ok(GenerateResult { - content: vec![GenerateContent::Text { - text: self.text.clone(), - provider_metadata: None, - }], - finish_reason: FinishReason { - unified: FinishReasonUnified::Stop, - raw: None, - }, - usage: self.usage.clone(), - warnings: vec![], - provider_metadata: None, - response: crate::types::ResponseMetadata { - model_id: Some(self.name.into()), - ..Default::default() - }, - request_body: None, - response_headers: None, - }) - } - async fn do_stream(&self, _options: &CallOptions) -> Result { - let attempt = self.calls.fetch_add(1, Ordering::SeqCst) + 1; - if self.fail { - return Err(AiMuxError::Other(format!("{} failed", self.name))); - } - if attempt <= self.retry_failures { - return Err(retryable_error(self.name)); - } - let parts: Vec> = vec![ - Ok(StreamPart::StreamStart { warnings: vec![] }), - Ok(StreamPart::TextDelta { - id: "t1".into(), - delta: self.text.clone(), - provider_metadata: None, - }), - Ok(StreamPart::Finish { - finish_reason: FinishReason { - unified: FinishReasonUnified::Stop, - raw: None, - }, - usage: self.usage.clone(), - provider_metadata: None, - }), - ]; - Ok(StreamResult { - stream: Box::pin(stream::iter(parts)), - request_body: None, - response_headers: None, - }) - } - } - - fn retryable_error(name: &str) -> AiMuxError { - AiMuxError::ApiCall(Box::new(crate::ApiCallError { - status_code: Some(503), - response_headers: Some(std::collections::HashMap::from([( - "retry-after-ms".into(), - "0".into(), - )])), - is_retryable: true, - ..crate::ApiCallError::new( - format!("{name} retryable failure"), - "https://example.test", - serde_json::json!({}), - ) - })) - } - - fn retry_child( - name: &'static str, - text: &str, - retry_failures: usize, - ) -> (ChildModel, Arc) { - let calls = Arc::new(AtomicUsize::new(0)); - ( - Arc::new(MockChild { - name, - text: text.into(), - fail: false, - usage: Usage::default(), - retry_failures, - calls: calls.clone(), - }), - calls, - ) - } - - fn mk(name: &'static str, text: &str, fail: bool, total: u32) -> ChildModel { - Arc::new(MockChild { - name, - text: text.into(), - fail, - usage: Usage { - input_tokens: TokenUsage { - total: Some(total), - ..Default::default() - }, - output_tokens: TokenUsage { - total: Some(total), - ..Default::default() - }, - raw: None, - }, - retry_failures: 0, - calls: Arc::new(AtomicUsize::new(0)), - }) - } - - fn opts_with_prompt() -> CallOptions { - CallOptions { - prompt: vec![LanguageModelPromptMessage { - role: Role::User, - content: vec![ContentPart::text("What is Rust?")], - provider_options: None, - }], - ..Default::default() - } - } - - #[tokio::test] - async fn generate_fans_out_and_aggregates() { - let refs = vec![ - mk("ref-a", "A says hi", false, 10), - mk("ref-b", "B says hi", false, 20), - ]; - let agg = mk("aggregator", "aggregated answer", false, 5); - let moa = MoaModel::new(refs, agg, MoaConfig::default()); - - let r = moa.do_generate(&opts_with_prompt()).await.unwrap(); - // Aggregator text wins as the content. - assert_eq!(extract_text(&r.content), "aggregated answer"); - // usage = references(10 + 20) + aggregator(5) = 35 on each side. - assert_eq!(r.usage.input_tokens.total, Some(35)); - assert_eq!(r.usage.output_tokens.total, Some(35)); - } - - #[tokio::test] - async fn best_effort_drops_failed_reference_and_warns() { - let refs = vec![mk("ref-a", "A ok", false, 10), mk("ref-b", "B", true, 0)]; - let agg = mk("aggregator", "final", false, 1); - let moa = MoaModel::new(refs, agg, MoaConfig::default()); - - let r = moa.do_generate(&opts_with_prompt()).await.unwrap(); - assert_eq!(extract_text(&r.content), "final"); - // Failed reference still contributed its (zero) usage; only ref-a (10) + agg (1). - assert_eq!(r.usage.input_tokens.total, Some(11)); - assert!(r.warnings.iter().any(|w| matches!( - w, - Warning::Other { message } if message.contains("reference 1 failed") - ))); - } - - #[tokio::test] - async fn fail_fast_propagates_first_reference_error() { - let refs = vec![mk("ref-a", "A", true, 0), mk("ref-b", "B ok", false, 10)]; - let agg = mk("aggregator", "final", false, 1); - let moa = MoaModel::new( - refs, - agg, - MoaConfig { - fail_mode: MoaFailMode::FailFast, - ..Default::default() - }, - ); - let err = moa.do_generate(&opts_with_prompt()).await.unwrap_err(); - assert!(err.to_string().contains("ref-a failed")); - } - - #[tokio::test] - async fn all_references_fail_is_error() { - let refs = vec![mk("ref-a", "A", true, 0), mk("ref-b", "B", true, 0)]; - let agg = mk("aggregator", "final", false, 1); - let moa = MoaModel::new(refs, agg, MoaConfig::default()); - let err = moa.do_generate(&opts_with_prompt()).await.unwrap_err(); - assert!(err.to_string().contains("all reference models failed")); - } - - #[tokio::test] - async fn no_references_degrades_to_aggregator() { - // With 0 references, aggregator runs the original prompt directly. - let agg = mk("aggregator", "solo answer", false, 3); - let moa = MoaModel::new(vec![], agg, MoaConfig::default()); - let r = moa.do_generate(&opts_with_prompt()).await.unwrap(); - assert_eq!(extract_text(&r.content), "solo answer"); - assert_eq!(r.usage.input_tokens.total, Some(3)); - } - - #[tokio::test] - async fn stream_runs_references_then_streams_aggregator() { - let refs = vec![mk("ref-a", "A", false, 7), mk("ref-b", "B", false, 8)]; - let agg = mk("aggregator", "streamed", false, 2); - let moa = MoaModel::new(refs, agg, MoaConfig::default()); - - let result = moa.do_stream(&opts_with_prompt()).await.unwrap(); - let mut s = result.stream; - let mut saw_start = false; - let mut text = String::new(); - let mut finish_usage: Option = None; - while let Some(part) = s.next().await { - match part.unwrap() { - StreamPart::StreamStart { .. } => saw_start = true, - StreamPart::TextDelta { delta, .. } => text.push_str(&delta), - StreamPart::Finish { usage, .. } => finish_usage = Some(usage), - _ => {} - } - } - assert!(saw_start); - assert_eq!(text, "streamed"); - // references(7 + 8) + aggregator(2) = 17 on each side. - assert_eq!(finish_usage.unwrap().input_tokens.total, Some(17)); - } - - #[tokio::test] - async fn retries_references_and_aggregator_independently_without_replaying_fanout() { - let (ref_a, ref_a_calls) = retry_child("ref-a", "A", 1); - let (ref_b, ref_b_calls) = retry_child("ref-b", "B", 0); - let (aggregator, aggregator_calls) = retry_child("aggregator", "final", 1); - let moa = MoaModel::new(vec![ref_a, ref_b], aggregator, MoaConfig::default()); - assert_eq!(moa.retry_config().max_retries, 0); - - let result = crate::generate::generate_text( - &moa, - "hello", - crate::generate::GenerateTextOptions { - max_retries: Some(2), - ..Default::default() - }, - ) - .await - .unwrap(); - - assert_eq!(result.text, "final"); - assert_eq!(ref_a_calls.load(Ordering::SeqCst), 2); - assert_eq!(ref_b_calls.load(Ordering::SeqCst), 1); - assert_eq!(aggregator_calls.load(Ordering::SeqCst), 2); - } - - #[tokio::test] - async fn retries_aggregator_stream_setup_without_replaying_references() { - let (reference, reference_calls) = retry_child("reference", "A", 0); - let (aggregator, aggregator_stream_calls) = retry_child("aggregator", "final", 1); - let moa = MoaModel::new(vec![reference], aggregator, MoaConfig::default()); - - let _result = crate::generate::stream_text( - &moa, - "hello", - crate::generate::GenerateTextOptions { - max_retries: Some(2), - ..Default::default() - }, - ) - .await - .unwrap(); - - assert_eq!(reference_calls.load(Ordering::SeqCst), 1); - assert_eq!(aggregator_stream_calls.load(Ordering::SeqCst), 2); - } - - #[tokio::test] - async fn stream_best_effort_emits_drop_warning_in_start() { - let refs = vec![mk("ref-a", "A", false, 5), mk("ref-b", "B", true, 0)]; - let agg = mk("aggregator", "out", false, 1); - let moa = MoaModel::new(refs, agg, MoaConfig::default()); - - let result = moa.do_stream(&opts_with_prompt()).await.unwrap(); - let mut s = result.stream; - // First item is our StreamStart, which should carry the drop warning. - match s.next().await.unwrap().unwrap() { - StreamPart::StreamStart { warnings } => { - assert!(warnings - .iter() - .any(|w| matches!(w, Warning::Other { message } if message.contains("reference 1 failed")))); - } - other => panic!("expected StreamStart, got {other:?}"), - } - } - - #[tokio::test] - async fn strip_reference_tools_default_clears_tools() { - // Verify the helper clears tools/tool_choice when configured (default). - let agg = mk("agg", "x", false, 0); - let moa = MoaModel::new(vec![], agg, MoaConfig::default()); - let opts = CallOptions { - prompt: vec![], - tools: Some(vec![]), - tool_choice: ToolChoice::Required, - ..Default::default() - }; - let ref_opts = moa.reference_options(&opts); - assert!(ref_opts.tools.is_none()); - assert_eq!(ref_opts.tool_choice, ToolChoice::Auto); - // The original options are untouched (clone, not mutate). - assert_eq!(opts.tool_choice, ToolChoice::Required); - } - - #[tokio::test] - 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 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") - ))); - } - - /// A mock aggregator whose stream yields TextDelta then a transport Err, - /// then (misbehaving) more parts. Guards that MoA relays the Err and stops. - struct MidStreamErrorAggregator; - - #[async_trait] - impl LanguageModel for MidStreamErrorAggregator { - fn provider(&self) -> &str { - "mock" - } - fn model_id(&self) -> &str { - "agg-err" - } - async fn do_generate(&self, _options: &CallOptions) -> Result { - unimplemented!() - } - async fn do_stream(&self, _options: &CallOptions) -> Result { - let parts: Vec> = vec![ - Ok(StreamPart::StreamStart { warnings: vec![] }), - Ok(StreamPart::TextDelta { - id: "t1".into(), - delta: "partial".into(), - provider_metadata: None, - }), - // A transport error mid-stream. - Err(AiMuxError::Other("aggregator transport blew up".into())), - // A misbehaving aggregator that keeps going after the Err. - Ok(StreamPart::TextDelta { - id: "t2".into(), - delta: "should-not-see".into(), - provider_metadata: None, - }), - ]; - Ok(StreamResult { - stream: Box::pin(stream::iter(parts)), - request_body: None, - response_headers: None, - }) - } - } - - #[tokio::test] - async fn stream_relays_aggregator_transport_error_and_stops() { - // N3 regression guard: a mid-stream transport Err from the aggregator - // is relayed and the stream terminates — no subsequent parts forwarded. - let refs = vec![mk("ref-a", "A", false, 5)]; - let agg = Arc::new(MidStreamErrorAggregator); - let moa = MoaModel::new(refs, agg, MoaConfig::default()); - let result = moa.do_stream(&opts_with_prompt()).await.unwrap(); - let parts: Vec<_> = result.stream.collect().await; - let mut saw_error = false; - let mut text = String::new(); - for part in parts { - match part { - Ok(StreamPart::StreamStart { .. }) => {} - Ok(StreamPart::TextDelta { delta, .. }) => text.push_str(&delta), - Err(e) => { - saw_error = true; - assert!(e.to_string().contains("blew up"), "got: {e}"); - } - _ => {} - } - } - assert!( - saw_error, - "expected the aggregator transport error to be relayed" - ); - assert!( - !text.contains("should-not-see"), - "parts after the terminal Err must not be forwarded; got: {text}" - ); - } - - struct RecoverableFrameErrorAggregator; - - #[async_trait] - impl LanguageModel for RecoverableFrameErrorAggregator { - fn provider(&self) -> &str { - "mock" - } - - fn model_id(&self) -> &str { - "agg-recoverable-error" - } - - async fn do_generate(&self, _options: &CallOptions) -> Result { - unimplemented!() - } - - async fn do_stream(&self, _options: &CallOptions) -> Result { - Ok(StreamResult { - stream: Box::pin(stream::iter([ - Ok(StreamPart::StreamStart { warnings: vec![] }), - Err(AiMuxError::JsonParse("malformed SSE data".into())), - Ok(StreamPart::TextDelta { - id: "t1".into(), - delta: "after-error".into(), - provider_metadata: None, - }), - Ok(StreamPart::Finish { - finish_reason: FinishReason { - unified: FinishReasonUnified::Stop, - raw: Some("stop".into()), - }, - usage: Usage::default(), - provider_metadata: None, - }), - ])), - request_body: None, - response_headers: None, - }) - } - } - - #[tokio::test] - async fn stream_continues_after_recoverable_aggregator_frame_error() { - let refs = vec![mk("ref-a", "A", false, 5)]; - let moa = MoaModel::new( - refs, - Arc::new(RecoverableFrameErrorAggregator), - MoaConfig::default(), - ); - let result = moa.do_stream(&opts_with_prompt()).await.unwrap(); - let outcomes: Vec<_> = result.stream.collect().await; - let error_index = outcomes - .iter() - .position(|outcome| matches!(outcome, Err(AiMuxError::JsonParse(_)))) - .expect("parse error should be relayed"); - - 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 - ))); - } - - /// Like `mk` but with full per-field usage (for G4 accumulation testing). - fn mk_full( - name: &'static str, - text: &str, - total: u32, - cache_read: u32, - cache_write: u32, - text_tokens: u32, - reasoning_tokens: u32, - ) -> ChildModel { - Arc::new(MockChild { - name, - text: text.into(), - fail: false, - usage: Usage { - input_tokens: TokenUsage { - total: Some(total), - cache_read: Some(cache_read), - cache_write: Some(cache_write), - ..Default::default() - }, - output_tokens: TokenUsage { - total: Some(total), - text: Some(text_tokens), - reasoning: Some(reasoning_tokens), - ..Default::default() - }, - raw: None, - }, - retry_failures: 0, - calls: Arc::new(AtomicUsize::new(0)), - }) - } - - #[tokio::test] - async fn generate_accumulates_per_field_usage() { - // G4: add_usage must sum cache_read/cache_write/text/reasoning, not - // just total. ref-a: cache_read=5,cache_write=2,text=3,reasoning=1; - // ref-b: cache_read=7,cache_write=4,text=6,reasoning=2; agg totals=1. - let refs = vec![ - mk_full("ref-a", "A", 10, 5, 2, 3, 1), - mk_full("ref-b", "B", 20, 7, 4, 6, 2), - ]; - let agg = mk_full("aggregator", "out", 1, 0, 0, 1, 0); - let moa = MoaModel::new(refs, agg, MoaConfig::default()); - let r = moa.do_generate(&opts_with_prompt()).await.unwrap(); - // input: cache_read 5+7+0=12, cache_write 2+4+0=6 - assert_eq!(r.usage.input_tokens.cache_read, Some(12)); - assert_eq!(r.usage.input_tokens.cache_write, Some(6)); - // output: text 3+6+1=10, reasoning 1+2+0=3 - assert_eq!(r.usage.output_tokens.text, Some(10)); - assert_eq!(r.usage.output_tokens.reasoning, Some(3)); - } - - #[tokio::test] - async fn strip_reference_tools_false_preserves_tools() { - // G5: strip_reference_tools=false keeps tools/tool_choice on references. - let agg = mk("agg", "x", false, 0); - let moa = MoaModel::new( - vec![], - agg, - MoaConfig { - strip_reference_tools: false, - ..Default::default() - }, - ); - let opts = CallOptions { - prompt: vec![], - tools: Some(vec![]), - tool_choice: ToolChoice::Required, - ..Default::default() - }; - let ref_opts = moa.reference_options(&opts); - assert_eq!(ref_opts.tools.as_ref().map(std::vec::Vec::len), Some(0)); - assert_eq!(ref_opts.tool_choice, ToolChoice::Required); - } - - #[tokio::test] - async fn aggregator_failure_on_generate_propagates() { - // G11: if the aggregator's do_generate fails, the error surfaces - // (references already ran successfully). - let refs = vec![mk("ref-a", "A", false, 5)]; - let agg = mk("aggregator", "out", true, 1); - let moa = MoaModel::new(refs, agg, MoaConfig::default()); - let err = moa.do_generate(&opts_with_prompt()).await.unwrap_err(); - assert!(err.to_string().contains("aggregator failed")); - } - - /// A mock aggregator whose stream emits non-text parts (ToolCall, Source, - /// Reasoning) so we can verify they're forwarded by MoA's do_stream. - struct RichStreamAggregator; - - #[async_trait] - impl LanguageModel for RichStreamAggregator { - fn provider(&self) -> &str { - "mock" - } - fn model_id(&self) -> &str { - "rich-agg" - } - async fn do_generate(&self, _: &CallOptions) -> Result { - unimplemented!() - } - async fn do_stream(&self, _: &CallOptions) -> Result { - let parts: Vec> = vec![ - Ok(StreamPart::StreamStart { warnings: vec![] }), - Ok(StreamPart::TextDelta { - id: "t1".into(), - delta: "answer".into(), - provider_metadata: None, - }), - Ok(StreamPart::ToolCall { - tool_call_id: "tc1".into(), - tool_name: "search".into(), - input: serde_json::json!({"q": "rust"}), - provider_executed: None, - dynamic: None, - thought_signature: None, - invalid: None, - error: None, - provider_metadata: None, - }), - Ok(StreamPart::Source { - id: "s1".into(), - source_type: "url".into(), - url: Some("https://example.com".into()), - title: Some("Ex".into()), - provider_metadata: None, - }), - Ok(StreamPart::Finish { - finish_reason: FinishReason { - unified: FinishReasonUnified::Stop, - raw: None, - }, - usage: Usage::default(), - provider_metadata: None, - }), - ]; - Ok(StreamResult { - stream: Box::pin(stream::iter(parts)), - request_body: None, - response_headers: None, - }) - } - } - - #[tokio::test] - async fn stream_forwards_non_text_parts() { - // G10: ToolCall/Source deltas from the aggregator must pass through - // (not just TextDelta). - let refs = vec![mk("ref-a", "A", false, 5)]; - let agg = Arc::new(RichStreamAggregator); - let moa = MoaModel::new(refs, agg, MoaConfig::default()); - let result = moa.do_stream(&opts_with_prompt()).await.unwrap(); - let parts: Vec<_> = result.stream.collect().await; - let mut saw_tool_call = false; - let mut saw_source = false; - for part in parts { - match part.unwrap() { - StreamPart::ToolCall { tool_name, .. } => { - saw_tool_call = true; - assert_eq!(tool_name, "search"); - } - StreamPart::Source { id, .. } => { - saw_source = true; - assert_eq!(id, "s1"); - } - _ => {} - } - } - assert!(saw_tool_call, "aggregator ToolCall must be forwarded"); - assert!(saw_source, "aggregator Source must be forwarded"); - } - - #[tokio::test] - async fn nested_router_as_moa_reference() { - // G8: composability — a RouterModel can be used as a MoaModel reference - // (both are Arc). RFC-0022 §6.2 explicitly says this - // is supported and "needs testing". The router's fallback should run - // inside the MoA fanout, and its output feeds the aggregator. - let router = crate::router::RouterModel::new( - vec![ - mk("router-primary", "router picked primary", true, 0), - mk("router-backup", "router fell back", false, 4), - ], - Box::new(crate::router::RuleRouter), - crate::router::FallbackPolicy::OnError, - crate::router::RouterConfig::default(), - ); - let router_child: ChildModel = Arc::new(router); - let plain_ref = mk("plain-ref", "plain reference", false, 3); - let agg = mk("aggregator", "aggregated", false, 1); - let moa = MoaModel::new(vec![router_child, plain_ref], agg, MoaConfig::default()); - - let r = moa.do_generate(&opts_with_prompt()).await.unwrap(); - // Aggregator output wins as content. - assert_eq!(extract_text(&r.content), "aggregated"); - // Usage: router(fell back to backup=4) + plain_ref(3) + aggregator(1) = 8. - assert_eq!(r.usage.input_tokens.total, Some(8)); - } -} diff --git a/aimux-core/src/openai_output.rs b/aimux-core/src/openai_output.rs index 1eb531e1f..12ffb37bf 100644 --- a/aimux-core/src/openai_output.rs +++ b/aimux-core/src/openai_output.rs @@ -38,7 +38,8 @@ use crate::parse_tool_call::raw_tool_call_text; use crate::result::GenerateContent; use crate::result::GenerateResult; use crate::shared::FileData; -use crate::stream_part::StreamPart; +use crate::stream_part::{StreamPart, TextStreamPart}; +use crate::tool::{ToolCall, ToolResult}; use crate::types::{FinishReason, FinishReasonUnified, Usage}; // ───────────────────────────────────────────────────────────────────────────── @@ -240,25 +241,20 @@ pub fn to_chat_completion(result: &GenerateResult, model: &str) -> ChatCompletio GenerateContent::Reasoning { text, .. } => { reasoning_text.push_str(text); } - GenerateContent::ToolCall { - tool_call_id, - tool_name, - input, - .. - } => { + GenerateContent::ToolCall(call) => { // The provider's raw argument text passes through verbatim; // OpenAI's wire format requires a JSON object even when the // model emitted no arguments at all. - let arguments = if input.trim().is_empty() { + let arguments = if call.input.trim().is_empty() { "{}".to_string() } else { - input.clone() + call.input.clone() }; tool_calls.push(ChatCompletionToolCall { - id: tool_call_id.clone(), + id: call.tool_call_id.clone(), tool_type: "function".to_string(), function: ChatCompletionFunction { - name: tool_name.clone(), + name: call.tool_name.clone(), arguments, }, }); @@ -291,12 +287,12 @@ pub fn to_chat_completion(result: &GenerateResult, model: &str) -> ChatCompletio } content_text.push_str(&file_data_to_text(data, media_type)); } - GenerateContent::ToolResult { + GenerateContent::ToolResult(ToolResult { tool_name, result, is_error, .. - } => { + }) => { // Degraded mapping: provider-executed tool result as text. if !content_text.is_empty() { content_text.push('\n'); @@ -409,7 +405,7 @@ impl std::fmt::Debug for ChatCompletionStream { /// SDK; they are never held back. See `stream_text_as_openai`. #[must_use] pub fn to_chat_completion_stream( - stream: Pin> + Send>>, + stream: Pin> + Send>>, model: &str, options: OpenAiStreamOptions, ) -> ChatCompletionStream { @@ -542,7 +538,7 @@ impl StreamState { /// Process a single StreamPart, returning zero or more output chunks. fn process_part( &mut self, - part: &StreamPart, + part: &TextStreamPart, include_reasoning: bool, include_usage: bool, ) -> Vec { @@ -702,14 +698,14 @@ impl StreamState { } StreamPart::ToolInputEnd { .. } => {} - StreamPart::ToolCall { + StreamPart::ToolCall(ToolCall { tool_call_id, tool_name, input, invalid, error, .. - } => { + }) => { // Complete tool call (e.g. from non-streaming-style providers). // A provider may carry all input on its start frame and emit no // deltas. In that case the final call is the first point where @@ -791,9 +787,9 @@ impl StreamState { } } - StreamPart::ToolResult { + StreamPart::ToolResult(ToolResult { tool_name, result, .. - } => { + }) => { // Degraded: provider-executed tool result as content. if let Some(c) = self.ensure_started() { chunks.push(c); @@ -1061,923 +1057,3 @@ fn random_id() -> String { let ts = now_unix(); format!("{ts:012x}{count:012x}") } - -// ───────────────────────────────────────────────────────────────────────────── -// Tests -// ───────────────────────────────────────────────────────────────────────────── - -#[cfg(test)] -mod tests { - use super::*; - use crate::result::GenerateResult; - use crate::types::TokenUsage; - use crate::types::{FinishReason, FinishReasonUnified, ResponseMetadata, Usage}; - use futures::StreamExt; - use serde_json::json; - - fn make_result(content: Vec) -> GenerateResult { - GenerateResult { - content, - finish_reason: FinishReason { - unified: FinishReasonUnified::Stop, - raw: None, - }, - usage: Usage::default(), - warnings: vec![], - provider_metadata: None, - response: ResponseMetadata { - id: Some("chatcmpl-test123".to_string()), - timestamp: None, - model_id: Some("gpt-4o".to_string()), - }, - request_body: None, - response_headers: None, - } - } - - #[test] - fn test_text_only_non_streaming() { - let result = make_result(vec![GenerateContent::Text { - text: "Hello world".to_string(), - provider_metadata: None, - }]); - let completion = to_chat_completion(&result, "gpt-4o"); - - assert_eq!(completion.object, "chat.completion"); - assert_eq!(completion.id, "chatcmpl-test123"); - assert_eq!(completion.model, "gpt-4o"); - assert_eq!(completion.choices.len(), 1); - assert_eq!(completion.choices[0].index, 0); - assert_eq!( - completion.choices[0].message.content.as_deref(), - Some("Hello world") - ); - assert_eq!(completion.choices[0].message.role, "assistant"); - assert_eq!(completion.choices[0].finish_reason.as_deref(), Some("stop")); - assert!(completion.choices[0].message.tool_calls.is_none()); - assert!(completion.choices[0].message.reasoning_content.is_none()); - } - - #[test] - fn test_tool_call_non_streaming() { - let result = make_result(vec![ - GenerateContent::Text { - text: "Let me check.".to_string(), - provider_metadata: None, - }, - GenerateContent::ToolCall { - tool_call_id: "call_abc".to_string(), - tool_name: "get_weather".to_string(), - input: r#"{"city":"Tokyo"}"#.to_string(), - provider_executed: None, - dynamic: None, - thought_signature: None, - provider_metadata: None, - }, - ]); - // Set finish_reason to ToolCalls. - let mut result = result; - result.finish_reason = FinishReason { - unified: FinishReasonUnified::ToolCalls, - raw: None, - }; - - let completion = to_chat_completion(&result, "gpt-4o"); - - let msg = &completion.choices[0].message; - assert_eq!(msg.content.as_deref(), Some("Let me check.")); - let tool_calls = msg.tool_calls.as_ref().unwrap(); - assert_eq!(tool_calls.len(), 1); - assert_eq!(tool_calls[0].id, "call_abc"); - assert_eq!(tool_calls[0].tool_type, "function"); - assert_eq!(tool_calls[0].function.name, "get_weather"); - assert_eq!(tool_calls[0].function.arguments, r#"{"city":"Tokyo"}"#); - assert_eq!( - completion.choices[0].finish_reason.as_deref(), - Some("tool_calls") - ); - } - - #[test] - fn test_raw_tool_call_arguments_are_not_double_encoded() { - let result = make_result(vec![GenerateContent::ToolCall { - tool_call_id: "call_raw".to_string(), - tool_name: "get_weather".to_string(), - input: r#"{"city":"Tokyo"}"#.to_string(), - provider_executed: None, - dynamic: None, - thought_signature: None, - provider_metadata: None, - }]); - - let completion = to_chat_completion(&result, "gpt-4o"); - assert_eq!( - completion.choices[0].message.tool_calls.as_ref().unwrap()[0] - .function - .arguments, - r#"{"city":"Tokyo"}"# - ); - } - - #[test] - fn blank_invalid_tool_arguments_render_as_an_empty_object() { - // Both OpenAI renderers: blank raw text has no wire representation - // other than `{}`. - let error = AiMuxError::InvalidToolInput { - tool_name: "get_weather".to_string(), - tool_input: String::new(), - cause: "Type validation failed: missing 'city'".to_string(), - }; - let arguments = parsed_tool_call_arguments(&json!({}), Some(true), Some(&error)); - assert_eq!(arguments, "{}"); - - let result = make_result(vec![GenerateContent::ToolCall { - tool_call_id: "call_blank".to_string(), - tool_name: "get_weather".to_string(), - input: " ".to_string(), - provider_executed: None, - dynamic: None, - thought_signature: None, - provider_metadata: None, - }]); - assert_eq!( - to_chat_completion(&result, "gpt-4o").choices[0] - .message - .tool_calls - .as_ref() - .unwrap()[0] - .function - .arguments, - "{}" - ); - } - - #[test] - fn parsed_tool_call_arguments_null_is_not_rewritten_to_empty_object() { - // A valid call whose parsed input happens to be `null` (no schema - // constraint rejected it) must round-trip as `null`, not `{}`. - let arguments = parsed_tool_call_arguments(&Value::Null, None, None); - assert_eq!(arguments, "null"); - } - - /// Regression coverage for the P1 finding on PR #165: `input: Value` - /// alone cannot distinguish a validly parsed JSON string from malformed - /// text wrapped as a fallback string, so an invalid call's arguments are - /// recovered from its typed error instead. Driven end to end so the - /// error/`input` pairing `parse_tool_call` actually produces is pinned - /// too, not just the renderer. - #[tokio::test] - async fn invalid_tool_calls_stream_their_raw_arguments_verbatim() { - let failing_repair = crate::parse_tool_call::ToolCallRepair::new(|_context| async { - Err(AiMuxError::Other("repair model failed".to_string())) - }); - let cases: [(&str, &str, Option<&crate::parse_tool_call::ToolCallRepair>); 3] = [ - // Valid JSON string rejected by the schema: the quotes must - // survive, or the arguments field stops being JSON at all. - ("get_weather", r#""hello""#, None), - // Unknown tool *and* malformed text: `NoSuchTool` carries no raw - // text of its own, so it comes off the unparsable `input`. - ("unknown_tool", r#"{"a":"#, None), - // A failed repair callback wraps the pre-repair error. - ("get_weather", r#"{"a":"#, Some(&failing_repair)), - ]; - let tools = [crate::tool::Tool::Function(crate::tool::FunctionTool::new( - "get_weather", - json!({"type": "object", "properties": {}, "additionalProperties": false}), - ))]; - - for (tool_name, raw_input, repair) in cases { - let parsed = crate::parse_tool_call::parse_tool_call( - crate::parse_tool_call::RawToolCall { - tool_call_id: "call_1".to_string(), - tool_name: tool_name.to_string(), - input: raw_input.to_string(), - provider_executed: None, - dynamic: None, - thought_signature: None, - provider_metadata: None, - }, - Some(&tools), - repair, - &[], - None, - ) - .await; - assert_eq!(parsed.invalid, Some(true), "{raw_input}"); - - let parts: Vec> = vec![Ok(StreamPart::ToolCall { - tool_call_id: parsed.tool_call_id, - tool_name: parsed.tool_name, - input: parsed.input, - provider_executed: parsed.provider_executed, - dynamic: parsed.dynamic, - thought_signature: parsed.thought_signature, - invalid: parsed.invalid, - error: parsed.error, - provider_metadata: parsed.provider_metadata, - })]; - let chunks = collect_stream(to_chat_completion_stream( - Box::pin(futures::stream::iter(parts)), - "gpt-4o", - OpenAiStreamOptions::default(), - )) - .await; - - let arguments = chunks - .iter() - .find_map(|c| { - c.choices - .first()? - .delta - .tool_calls - .as_ref()? - .first()? - .function - .arguments - .clone() - }) - .expect("tool call arguments chunk not found"); - assert_eq!(arguments, raw_input, "tool {tool_name}"); - } - } - - #[test] - fn test_tool_call_null_content() { - // Tool call with no text → content should be null. - let result = make_result(vec![GenerateContent::ToolCall { - tool_call_id: "call_abc".to_string(), - tool_name: "get_weather".to_string(), - input: r#"{"city":"Tokyo"}"#.to_string(), - provider_executed: None, - dynamic: None, - thought_signature: None, - provider_metadata: None, - }]); - let completion = to_chat_completion(&result, "gpt-4o"); - - assert_eq!(completion.choices[0].message.content, None); - assert!(completion.choices[0].message.tool_calls.is_some()); - } - - #[test] - fn test_reasoning_non_streaming() { - let result = make_result(vec![ - GenerateContent::Reasoning { - text: "Thinking...".to_string(), - provider_metadata: None, - }, - GenerateContent::Text { - text: "Answer".to_string(), - provider_metadata: None, - }, - ]); - let completion = to_chat_completion(&result, "gpt-4o"); - - assert_eq!( - completion.choices[0].message.reasoning_content.as_deref(), - Some("Thinking...") - ); - assert_eq!( - completion.choices[0].message.content.as_deref(), - Some("Answer") - ); - } - - #[test] - fn test_usage_round_trip() { - let usage = Usage { - input_tokens: TokenUsage { - total: Some(100), - cache_read: Some(30), - cache_write: Some(10), - ..Default::default() - }, - output_tokens: TokenUsage { - total: Some(50), - reasoning: Some(20), - ..Default::default() - }, - raw: None, - }; - let mut result = make_result(vec![]); - result.usage = usage; - - let completion = to_chat_completion(&result, "gpt-4o"); - let u = &completion.usage; - - assert_eq!(u.prompt_tokens, 100); - assert_eq!(u.completion_tokens, 50); - assert_eq!(u.total_tokens, 150); - assert_eq!(u.prompt_tokens_details.as_ref().unwrap().cached_tokens, 30); - assert_eq!( - u.prompt_tokens_details.as_ref().unwrap().cache_write_tokens, - Some(10) - ); - assert_eq!( - u.completion_tokens_details - .as_ref() - .unwrap() - .reasoning_tokens, - Some(20) - ); - } - - #[test] - fn test_finish_reason_mapping() { - let cases = [ - (FinishReasonUnified::Stop, "stop"), - (FinishReasonUnified::Length, "length"), - (FinishReasonUnified::ContentFilter, "content_filter"), - (FinishReasonUnified::ToolCalls, "tool_calls"), - (FinishReasonUnified::Error, "stop"), - ]; - for (unified, expected) in cases { - let fr = FinishReason { unified, raw: None }; - assert_eq!(finish_reason_to_openai(&fr).as_deref(), Some(expected)); - } - } - - // ── Streaming tests ── - - async fn collect_stream(stream: ChatCompletionStream) -> Vec { - let mut chunks = Vec::new(); - let mut s = stream.stream; - while let Some(result) = s.next().await { - chunks.push(result.unwrap()); - } - chunks - } - - #[tokio::test] - async fn test_stream_text_only() { - let parts: Vec> = vec![ - Ok(StreamPart::StreamStart { warnings: vec![] }), - Ok(StreamPart::TextStart { - id: "0".to_string(), - provider_metadata: None, - }), - Ok(StreamPart::TextDelta { - id: "0".to_string(), - delta: "Hello ".to_string(), - provider_metadata: None, - }), - Ok(StreamPart::TextDelta { - id: "0".to_string(), - delta: "world".to_string(), - provider_metadata: None, - }), - Ok(StreamPart::TextEnd { - id: "0".to_string(), - provider_metadata: None, - }), - Ok(StreamPart::Finish { - finish_reason: FinishReason { - unified: FinishReasonUnified::Stop, - raw: None, - }, - usage: Usage::default(), - provider_metadata: None, - }), - ]; - - let input_stream = Box::pin(futures::stream::iter(parts)); - let result = - to_chat_completion_stream(input_stream, "gpt-4o", OpenAiStreamOptions::default()); - let chunks = collect_stream(result).await; - - // First chunk: role frame. - assert_eq!( - chunks[0].choices[0].delta.role.as_deref(), - Some("assistant") - ); - assert_eq!(chunks[0].choices[0].delta.content.as_deref(), Some("")); - - // Content deltas. - assert_eq!( - chunks[1].choices[0].delta.content.as_deref(), - Some("Hello ") - ); - assert_eq!(chunks[2].choices[0].delta.content.as_deref(), Some("world")); - - // Final chunk: finish_reason. - let last = chunks.last().unwrap(); - assert_eq!(last.choices[0].finish_reason.as_deref(), Some("stop")); - assert!(last.choices[0].delta.content.is_none()); - } - - #[tokio::test] - async fn test_stream_tool_calls() { - let parts: Vec> = vec![ - Ok(StreamPart::StreamStart { warnings: vec![] }), - Ok(StreamPart::ToolInputStart { - id: "call_1".to_string(), - tool_name: "get_weather".to_string(), - provider_executed: None, - dynamic: None, - title: None, - provider_metadata: None, - }), - Ok(StreamPart::ToolInputDelta { - id: "call_1".to_string(), - delta: "{\"city\":".to_string(), - provider_metadata: None, - }), - Ok(StreamPart::ToolInputDelta { - id: "call_1".to_string(), - delta: "\"Tokyo\"}".to_string(), - provider_metadata: None, - }), - Ok(StreamPart::ToolInputEnd { - id: "call_1".to_string(), - provider_metadata: None, - }), - Ok(StreamPart::ToolCall { - tool_call_id: "call_1".to_string(), - tool_name: "get_weather".to_string(), - input: json!({ "city": "Tokyo" }), - provider_executed: None, - dynamic: None, - thought_signature: None, - invalid: None, - error: None, - provider_metadata: None, - }), - Ok(StreamPart::Finish { - finish_reason: FinishReason { - unified: FinishReasonUnified::ToolCalls, - raw: None, - }, - usage: Usage::default(), - provider_metadata: None, - }), - ]; - - let input_stream = Box::pin(futures::stream::iter(parts)); - let result = - to_chat_completion_stream(input_stream, "gpt-4o", OpenAiStreamOptions::default()); - let chunks = collect_stream(result).await; - - // Find the tool_call opening chunk (with id + name). - let open_chunk = chunks - .iter() - .find(|c| { - c.choices - .first() - .and_then(|ch| ch.delta.tool_calls.as_ref()) - .and_then(|tcs| tcs.first()) - .and_then(|tc| tc.id.as_deref()) - == Some("call_1") - }) - .expect("tool call opening chunk not found"); - - let tc = open_chunk.choices[0] - .delta - .tool_calls - .as_ref() - .unwrap() - .first() - .unwrap(); - assert_eq!(tc.index, 0); - assert_eq!(tc.id.as_deref(), Some("call_1")); - assert_eq!(tc.tool_type.as_deref(), Some("function")); - assert_eq!(tc.function.name.as_deref(), Some("get_weather")); - - // Find argument delta chunks. - let arg_chunks: Vec<_> = chunks - .iter() - .filter(|c| { - c.choices - .first() - .and_then(|ch| ch.delta.tool_calls.as_ref()) - .and_then(|tcs| tcs.first()) - .is_some_and(|tc| tc.id.is_none() && tc.function.arguments.is_some()) - }) - .collect(); - assert_eq!(arg_chunks.len(), 2); - assert_eq!( - arg_chunks[0].choices[0].delta.tool_calls.as_ref().unwrap()[0] - .function - .arguments - .as_deref(), - Some("{\"city\":") - ); - - // Final chunk. - let last = chunks.last().unwrap(); - assert_eq!(last.choices[0].finish_reason.as_deref(), Some("tool_calls")); - } - - #[tokio::test] - async fn test_stream_complete_call_backfills_arguments_absent_from_deltas() { - let parts: Vec> = vec![ - Ok(StreamPart::ToolInputStart { - id: "call_1".to_string(), - tool_name: "fetch".to_string(), - provider_executed: Some(true), - dynamic: None, - title: None, - provider_metadata: None, - }), - Ok(StreamPart::ToolInputEnd { - id: "call_1".to_string(), - provider_metadata: None, - }), - Ok(StreamPart::ToolCall { - tool_call_id: "call_1".to_string(), - tool_name: "fetch".to_string(), - input: json!({ "url": "https://example.com" }), - provider_executed: Some(true), - dynamic: None, - thought_signature: None, - invalid: None, - error: None, - provider_metadata: None, - }), - Ok(StreamPart::Finish { - finish_reason: FinishReason { - unified: FinishReasonUnified::Stop, - raw: None, - }, - usage: Usage::default(), - provider_metadata: None, - }), - ]; - - let result = to_chat_completion_stream( - Box::pin(futures::stream::iter(parts)), - "claude", - OpenAiStreamOptions::default(), - ); - let chunks = collect_stream(result).await; - let arguments = chunks - .iter() - .filter_map(|chunk| { - chunk - .choices - .first()? - .delta - .tool_calls - .as_ref()? - .first()? - .function - .arguments - .as_deref() - }) - .collect::(); - - assert_eq!(arguments, r#"{"url":"https://example.com"}"#); - } - - #[tokio::test] - async fn test_stream_multiple_tool_calls_index() { - let parts: Vec> = vec![ - Ok(StreamPart::StreamStart { warnings: vec![] }), - Ok(StreamPart::ToolInputStart { - id: "call_a".to_string(), - tool_name: "tool_a".to_string(), - provider_executed: None, - dynamic: None, - title: None, - provider_metadata: None, - }), - Ok(StreamPart::ToolInputStart { - id: "call_b".to_string(), - tool_name: "tool_b".to_string(), - provider_executed: None, - dynamic: None, - title: None, - provider_metadata: None, - }), - Ok(StreamPart::Finish { - finish_reason: FinishReason { - unified: FinishReasonUnified::ToolCalls, - raw: None, - }, - usage: Usage::default(), - provider_metadata: None, - }), - ]; - - let input_stream = Box::pin(futures::stream::iter(parts)); - let result = - to_chat_completion_stream(input_stream, "gpt-4o", OpenAiStreamOptions::default()); - let chunks = collect_stream(result).await; - - // Find both tool call opening chunks. - let open_chunks: Vec<_> = chunks - .iter() - .filter(|c| { - c.choices - .first() - .and_then(|ch| ch.delta.tool_calls.as_ref()) - .and_then(|tcs| tcs.first()) - .and_then(|tc| tc.id.as_deref()) - .is_some() - }) - .collect(); - assert_eq!(open_chunks.len(), 2); - - let tc0 = &open_chunks[0].choices[0].delta.tool_calls.as_ref().unwrap()[0]; - let tc1 = &open_chunks[1].choices[0].delta.tool_calls.as_ref().unwrap()[0]; - assert_eq!(tc0.index, 0); - assert_eq!(tc0.id.as_deref(), Some("call_a")); - assert_eq!(tc1.index, 1); - assert_eq!(tc1.id.as_deref(), Some("call_b")); - } - - #[tokio::test] - async fn test_stream_reasoning() { - let parts: Vec> = vec![ - Ok(StreamPart::StreamStart { warnings: vec![] }), - Ok(StreamPart::ReasoningStart { - id: "reasoning-0".to_string(), - provider_metadata: None, - }), - Ok(StreamPart::ReasoningDelta { - id: "reasoning-0".to_string(), - delta: "Hmm...".to_string(), - provider_metadata: None, - }), - Ok(StreamPart::ReasoningEnd { - id: "reasoning-0".to_string(), - provider_metadata: None, - }), - Ok(StreamPart::TextDelta { - id: "0".to_string(), - delta: "Answer".to_string(), - provider_metadata: None, - }), - Ok(StreamPart::Finish { - finish_reason: FinishReason { - unified: FinishReasonUnified::Stop, - raw: None, - }, - usage: Usage::default(), - provider_metadata: None, - }), - ]; - - let input_stream = Box::pin(futures::stream::iter(parts)); - let result = - to_chat_completion_stream(input_stream, "gpt-4o", OpenAiStreamOptions::default()); - let chunks = collect_stream(result).await; - - // Find reasoning chunk. - let reasoning_chunk = chunks - .iter() - .find(|c| c.choices[0].delta.reasoning_content.is_some()) - .expect("reasoning chunk not found"); - assert_eq!( - reasoning_chunk.choices[0] - .delta - .reasoning_content - .as_deref(), - Some("Hmm...") - ); - - // Find text chunk. - let text_chunk = chunks - .iter() - .find(|c| { - c.choices[0] - .delta - .content - .as_deref() - .is_some_and(|s| s == "Answer") - }) - .expect("text chunk not found"); - assert_eq!( - text_chunk.choices[0].delta.content.as_deref(), - Some("Answer") - ); - } - - #[tokio::test] - async fn test_stream_usage_in_final_chunk() { - let usage = Usage { - input_tokens: TokenUsage { - total: Some(42), - ..Default::default() - }, - output_tokens: TokenUsage { - total: Some(10), - ..Default::default() - }, - raw: None, - }; - - let parts: Vec> = vec![ - Ok(StreamPart::StreamStart { warnings: vec![] }), - Ok(StreamPart::TextDelta { - id: "0".to_string(), - delta: "Hi".to_string(), - provider_metadata: None, - }), - Ok(StreamPart::Finish { - finish_reason: FinishReason { - unified: FinishReasonUnified::Stop, - raw: None, - }, - usage, - provider_metadata: None, - }), - ]; - - let input_stream = Box::pin(futures::stream::iter(parts)); - let result = to_chat_completion_stream( - input_stream, - "gpt-4o", - OpenAiStreamOptions { - include_usage: true, - include_reasoning: true, - }, - ); - let chunks = collect_stream(result).await; - - let last = chunks.last().unwrap(); - assert_eq!(last.usage.as_ref().unwrap().prompt_tokens, 42); - assert_eq!(last.usage.as_ref().unwrap().completion_tokens, 10); - assert_eq!(last.usage.as_ref().unwrap().total_tokens, 52); - } - - #[tokio::test] - async fn test_stream_no_usage_when_disabled() { - let parts: Vec> = vec![ - Ok(StreamPart::StreamStart { warnings: vec![] }), - Ok(StreamPart::Finish { - finish_reason: FinishReason { - unified: FinishReasonUnified::Stop, - raw: None, - }, - usage: Usage { - input_tokens: TokenUsage { - total: Some(42), - ..Default::default() - }, - output_tokens: TokenUsage::default(), - raw: None, - }, - provider_metadata: None, - }), - ]; - - let input_stream = Box::pin(futures::stream::iter(parts)); - let result = to_chat_completion_stream( - input_stream, - "gpt-4o", - OpenAiStreamOptions { - include_usage: false, - include_reasoning: true, - }, - ); - let chunks = collect_stream(result).await; - - let last = chunks.last().unwrap(); - assert!(last.usage.is_none()); - } - - #[tokio::test] - async fn test_stream_error() { - let parts: Vec> = vec![ - Ok(StreamPart::StreamStart { warnings: vec![] }), - Ok(StreamPart::TextDelta { - id: "0".to_string(), - delta: "partial".to_string(), - provider_metadata: None, - }), - Ok(StreamPart::Error { - error: AiMuxError::ApiCall(Box::new(crate::error::ApiCallError::new( - "something went wrong", - "https://example.test/v1", - serde_json::json!({}), - ))), - }), - ]; - - let input_stream = Box::pin(futures::stream::iter(parts)); - let result = - to_chat_completion_stream(input_stream, "gpt-4o", OpenAiStreamOptions::default()); - let chunks = collect_stream(result).await; - - // Error should appear as a content delta. - let error_chunk = chunks - .iter() - .find(|c| { - c.choices[0] - .delta - .content - .as_deref() - .is_some_and(|s| s.contains("[error]")) - }) - .expect("error chunk not found"); - - assert!( - error_chunk.choices[0] - .delta - .content - .as_deref() - .unwrap() - .contains("something went wrong") - ); - - // Final chunk should still have finish_reason. - let last = chunks.last().unwrap(); - assert_eq!(last.choices[0].finish_reason.as_deref(), Some("stop")); - } - - #[tokio::test] - async fn recoverable_frame_error_does_not_truncate_chat_completion_stream() { - let parts: Vec> = vec![ - Ok(StreamPart::StreamStart { warnings: vec![] }), - Err(AiMuxError::JsonParse("malformed SSE data".into())), - Ok(StreamPart::TextDelta { - id: "0".to_string(), - delta: "after-error".to_string(), - provider_metadata: None, - }), - Ok(StreamPart::Finish { - finish_reason: FinishReason { - unified: FinishReasonUnified::Stop, - raw: Some("stop".into()), - }, - usage: Usage::default(), - provider_metadata: None, - }), - ]; - - let result = to_chat_completion_stream( - Box::pin(futures::stream::iter(parts)), - "gpt-4o", - OpenAiStreamOptions::default(), - ); - let outcomes: Vec<_> = result.stream.collect().await; - let error_index = outcomes - .iter() - .position(|outcome| matches!(outcome, Err(AiMuxError::JsonParse(_)))) - .expect("parse error should be preserved as an error item"); - - assert!(outcomes[error_index + 1..].iter().any(|outcome| matches!( - outcome, - Ok(chunk) - if chunk.choices.first().and_then(|choice| choice.delta.content.as_deref()) - == Some("after-error") - ))); - assert!(outcomes.iter().any(|outcome| matches!( - outcome, - Ok(chunk) - if chunk.choices.first().and_then(|choice| choice.finish_reason.as_deref()) - == Some("stop") - ))); - } - - #[tokio::test] - async fn test_stream_completes_without_finish() { - // Stream ends without Finish — should still emit a final chunk. - let parts: Vec> = vec![ - Ok(StreamPart::StreamStart { warnings: vec![] }), - Ok(StreamPart::TextDelta { - id: "0".to_string(), - delta: "Hello".to_string(), - provider_metadata: None, - }), - ]; - - let input_stream = Box::pin(futures::stream::iter(parts)); - let result = - to_chat_completion_stream(input_stream, "gpt-4o", OpenAiStreamOptions::default()); - let chunks = collect_stream(result).await; - - let last = chunks.last().unwrap(); - assert_eq!(last.choices[0].finish_reason.as_deref(), Some("stop")); - } - - #[test] - fn test_sse_encoding() { - let chunk = ChatCompletionChunk { - id: "chatcmpl-test".to_string(), - object: "chat.completion.chunk".to_string(), - created: 1234567890, - model: "gpt-4o".to_string(), - choices: vec![ChatCompletionChunkChoice { - index: 0, - delta: ChatCompletionDelta { - content: Some("Hi".to_string()), - ..Default::default() - }, - finish_reason: None, - logprobs: None, - }], - usage: None, - }; - - let sse = encode_chunk_sse(&chunk); - assert!(sse.starts_with("data: {")); - assert!(sse.ends_with("\n\n")); - assert!(sse.contains("\"content\":\"Hi\"")); - } - - #[test] - fn test_done_frame() { - assert_eq!(DONE_FRAME, "data: [DONE]\n\n"); - } -} diff --git a/aimux-core/src/options.rs b/aimux-core/src/options.rs index 3d2891396..d7a4010ac 100644 --- a/aimux-core/src/options.rs +++ b/aimux-core/src/options.rs @@ -10,7 +10,7 @@ use ts_rs::TS; use crate::AbortSignal; use crate::language_model_message::LanguageModelPrompt; -pub use crate::tool::{FunctionTool, ProviderTool, Tool, ToolChoice}; +pub use crate::tool::{FunctionTool, FunctionToolInputExample, ProviderTool, Tool, ToolChoice}; use crate::types::ReasoningEffort; /// How the model should format its response. @@ -102,7 +102,7 @@ pub struct CallOptions { pub tools: Option>, /// How the model should choose tools. - pub tool_choice: ToolChoice, + pub tool_choice: Option, /// Extra HTTP headers. pub headers: Option>, @@ -203,7 +203,7 @@ impl CallOptions { response_format: None, seed: None, tools: None, - tool_choice: ToolChoice::default(), + tool_choice: None, headers: None, provider_options: None, reasoning: None, diff --git a/aimux-core/src/parse_tool_call.rs b/aimux-core/src/parse_tool_call.rs index 16a4ca6d8..053245b6e 100644 --- a/aimux-core/src/parse_tool_call.rs +++ b/aimux-core/src/parse_tool_call.rs @@ -14,28 +14,7 @@ use serde_json::Value; use ts_rs::TS; use crate::error::AiMuxError; -use crate::tool::{Tool, ToolCall}; -use crate::types::ProviderMetadata; - -/// Provider-facing tool call before Core parses and validates its input. -/// -/// The wire shape matches [`ToolCall`] field for field, except that `input` -/// is the provider's raw argument *text* rather than a parsed value. -#[derive(Debug, Clone, Serialize, Deserialize, TS)] -#[ts(export)] -pub struct RawToolCall { - pub tool_call_id: String, - pub tool_name: String, - pub input: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub provider_executed: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub dynamic: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub thought_signature: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub provider_metadata: Option, -} +use crate::tool::{RawToolCall, Tool, ToolCall}; /// Context supplied to a one-shot tool-call repair callback. #[derive(Debug, Clone)] @@ -134,13 +113,13 @@ pub async fn parse_tool_call( }) }; return match parsed { - Ok(input) => valid_tool_call(tool_call, input, Some(true)), + Ok(input) => parsed_tool_call(tool_call, input, Some(true)), Err(error) => invalid_tool_call(tool_call, error), }; }; match parse_and_validate_tool_call(&tool_call, tools) { - Ok((input, dynamic)) => valid_tool_call(tool_call, input, dynamic), + Ok((input, dynamic)) => parsed_tool_call(tool_call, input, dynamic), Err(original_error) => { let Some(repair_tool_call) = repair_tool_call else { return invalid_tool_call(tool_call, original_error); @@ -181,7 +160,7 @@ fn apply_repair_outcome( ) -> ToolCall { let cause = match outcome { RepairOutcome::Repaired(repaired) => match parse_and_validate_tool_call(&repaired, tools) { - Ok((input, dynamic)) => return valid_tool_call(repaired, input, dynamic), + Ok((input, dynamic)) => return parsed_tool_call(repaired, input, dynamic), Err(repaired_error) => repaired_error, }, RepairOutcome::Unchanged => return invalid_tool_call(tool_call, original_error), @@ -298,24 +277,13 @@ fn contains_forbidden_prototype(value: &Value) -> bool { } } -/// Recover the provider's raw argument text: string inputs pass through -/// verbatim (possibly malformed JSON awaiting parse/repair); anything already -/// structured re-serializes. -pub(crate) fn raw_tool_input(input: &Value) -> String { - match input { - Value::String(input) => input.clone(), - input => serde_json::to_string(input).expect("serializing serde_json::Value cannot fail"), - } -} - -fn valid_tool_call(tool_call: RawToolCall, input: Value, dynamic: Option) -> ToolCall { +fn parsed_tool_call(tool_call: RawToolCall, input: Value, dynamic: Option) -> ToolCall { ToolCall { tool_call_id: tool_call.tool_call_id, tool_name: tool_call.tool_name, input, provider_executed: tool_call.provider_executed, dynamic, - thought_signature: tool_call.thought_signature, provider_metadata: tool_call.provider_metadata, invalid: None, error: None, @@ -332,15 +300,9 @@ fn invalid_tool_call(tool_call: RawToolCall, error: AiMuxError) -> ToolCall { let input = serde_json::from_str(&tool_call.input) .unwrap_or_else(|_| Value::String(tool_call.input.clone())); ToolCall { - tool_call_id: tool_call.tool_call_id, - tool_name: tool_call.tool_name, - input, - provider_executed: tool_call.provider_executed, - dynamic: Some(true), - thought_signature: tool_call.thought_signature, - provider_metadata: tool_call.provider_metadata, invalid: Some(true), error: Some(error), + ..parsed_tool_call(tool_call, input, Some(true)) } } @@ -400,8 +362,8 @@ pub(crate) fn raw_tool_call_text(error: &AiMuxError) -> Option { /// and that loses the JSON string quoting of a bare literal (`"Tokyo"` would /// come back as `Tokyo`), duplicate keys, number spelling and whitespace, all /// of which matter when a host re-submits the text unchanged under a new tool -/// name. Only errors from before the text was recorded fall back to -/// [`raw_tool_input`]. +/// name. Only errors from before the text was recorded fall back to the +/// compact JSON of `input`. /// /// `dynamic` is not recovered: an invalid call always reports `Some(true)`. /// That only matters for re-deriving the original error, which is read off the @@ -423,10 +385,14 @@ fn raw_from_invalid(tool_call: &ToolCall) -> Result<(RawToolCall, AiMuxError), A RawToolCall { tool_call_id: tool_call.tool_call_id.clone(), tool_name: tool_call.tool_name.clone(), - input: raw_tool_call_text(&error).unwrap_or_else(|| raw_tool_input(&tool_call.input)), + // An invalid call keeps unparsable argument text as a JSON string + // (see `invalid_tool_call`); that text is the raw input. + input: raw_tool_call_text(&error).unwrap_or_else(|| match &tool_call.input { + Value::String(text) => text.clone(), + input => input.to_string(), + }), provider_executed: tool_call.provider_executed, dynamic: tool_call.dynamic, - thought_signature: tool_call.thought_signature.clone(), provider_metadata: tool_call.provider_metadata.clone(), }, error, diff --git a/aimux-core/src/recording.rs b/aimux-core/src/recording.rs index 65e5fce7d..22dff9ba9 100644 --- a/aimux-core/src/recording.rs +++ b/aimux-core/src/recording.rs @@ -1626,11 +1626,11 @@ impl RecordingOutcomeStream { } } -impl futures::Stream for RecordingOutcomeStream +impl futures::Stream for RecordingOutcomeStream where - S: futures::Stream> + Unpin, + S: futures::Stream, crate::AiMuxError>> + Unpin, { - type Item = Result; + type Item = Result, crate::AiMuxError>; fn poll_next( mut self: std::pin::Pin<&mut Self>, diff --git a/aimux-core/src/replay.rs b/aimux-core/src/replay.rs index 462965061..6246fa03d 100644 --- a/aimux-core/src/replay.rs +++ b/aimux-core/src/replay.rs @@ -20,10 +20,11 @@ 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::options::{CallOptions, ToolChoice}; +use crate::options::CallOptions; use crate::recording::Recording; use crate::result::{GenerateContent, GenerateResult, StreamResult}; use crate::stream_part::StreamPart; +use crate::tool::RawToolCall; use crate::types::{FinishReason, FinishReasonUnified, ResponseMetadata, Usage}; pub use crate::decision_replay::{MockDecisionReplayModel, replay_decision_with_model}; @@ -111,8 +112,7 @@ fn canonical_call_key(opts: &CallOptions) -> serde_json::Value { }) } -/// 从录制侧提取同样的规范键(向后兼容:缺失 Option 字段按 null,缺失 -/// `tool_choice` 按 `ToolChoice::default()`=Auto,与 `CallOptions` 缺省一致)。 +/// 从录制侧提取同样的规范键(缺失 Option 字段按 null)。 fn canonical_recording_key(rec: &Recording) -> serde_json::Value { let o = &rec.input.options; serde_json::json!({ @@ -122,10 +122,7 @@ fn canonical_recording_key(rec: &Recording) -> serde_json::Value { "seed": o.get("seed").cloned().unwrap_or_default(), "response_format": o.get("response_format").cloned().unwrap_or_default(), "tools": o.get("tools").cloned().unwrap_or_default(), - "tool_choice": o - .get("tool_choice") - .cloned() - .unwrap_or_else(|| serde_json::to_value(ToolChoice::default()).unwrap_or_default()), + "tool_choice": o.get("tool_choice").cloned().unwrap_or_default(), "headers": o.get("headers").cloned().unwrap_or_default(), "provider_options": o.get("provider_options").cloned().unwrap_or_default(), "body_overrides": o.get("body_overrides").cloned().unwrap_or_default(), @@ -601,15 +598,14 @@ fn rebuild_generate_result(rec: &Recording) -> Result Result Result { let mut final_usage = Usage::default(); let mut final_finish: Option = None; // tool_call 按 OpenAI `index` 稳定累积;`tool_order` 保插入顺序(确定性 emit)。 - let mut tool_calls: std::collections::HashMap = + let mut tool_calls: std::collections::HashMap = std::collections::HashMap::new(); let mut tool_order: Vec = Vec::new(); let mut saw_openai = false; @@ -791,10 +780,13 @@ fn rebuild_stream_result(rec: &Recording) -> Result { .to_string(); tool_calls.insert( idx, - ToolCallAccumulator { - id: id.clone(), - name: name.clone(), - arguments: String::new(), + RawToolCall { + tool_call_id: id.clone(), + tool_name: name.clone(), + input: String::new(), + provider_executed: None, + dynamic: None, + provider_metadata: None, }, ); tool_order.push(idx); @@ -813,9 +805,9 @@ fn rebuild_stream_result(rec: &Recording) -> Result { && (!is_new || !args.is_empty()) && let Some(acc) = tool_calls.get_mut(&idx) { - acc.arguments.push_str(args); + acc.input.push_str(args); parts.push(Ok(StreamPart::ToolInputDelta { - id: acc.id.clone(), + id: acc.tool_call_id.clone(), delta: args.to_string(), provider_metadata: None, })); @@ -861,22 +853,12 @@ fn rebuild_stream_result(rec: &Recording) -> Result { })); } for &i in &tool_order { - if let Some(acc) = tool_calls.get(&i) { + if let Some(acc) = tool_calls.remove(&i) { parts.push(Ok(StreamPart::ToolInputEnd { - id: acc.id.clone(), - provider_metadata: None, - })); - parts.push(Ok(StreamPart::ToolCall { - tool_call_id: acc.id.clone(), - tool_name: acc.name.clone(), - input: serde_json::Value::String(acc.arguments.clone()), - provider_executed: None, - dynamic: None, - thought_signature: None, - invalid: None, - error: None, + id: acc.tool_call_id.clone(), provider_metadata: None, })); + parts.push(Ok(StreamPart::ToolCall(acc))); } } @@ -1003,11 +985,7 @@ fn generate_options_from_call_options(o: CallOptions) -> GenerateTextOptions { response_format: o.response_format, seed: o.seed, tools: o.tools, - tool_choice: if o.tool_choice == crate::tool::ToolChoice::Auto { - None - } else { - Some(o.tool_choice) - }, + tool_choice: o.tool_choice, headers: o.headers, provider_options: o.provider_options, reasoning: o.reasoning, @@ -1626,18 +1604,18 @@ mod tests { .unwrap() }); assert_eq!(result.content.len(), 1); - let Some(GenerateContent::ToolCall { + let Some(GenerateContent::ToolCall(RawToolCall { tool_call_id, tool_name, input, .. - }) = result.content.first() + })) = result.content.first() else { panic!("expected ToolCall, got {:?}", result.content); }; assert_eq!(tool_call_id, "call_abc"); assert_eq!(tool_name, "get_weather"); - assert_eq!(input, &serde_json::json!(r#"{"city":"SF"}"#)); + assert_eq!(input, r#"{"city":"SF"}"#); assert_eq!(result.finish_reason.unified, FinishReasonUnified::ToolCalls); } @@ -1671,10 +1649,11 @@ mod tests { .await .unwrap() }); - let Some(GenerateContent::ToolCall { input, .. }) = result.content.first() else { + let Some(GenerateContent::ToolCall(RawToolCall { input, .. })) = result.content.first() + else { panic!("expected ToolCall"); }; - assert_eq!(input, &serde_json::json!("not-json{")); + assert_eq!(input, "not-json{"); } #[test] @@ -1739,19 +1718,19 @@ mod tests { let calls: Vec<_> = parts .iter() .filter_map(|p| match p { - StreamPart::ToolCall { + StreamPart::ToolCall(RawToolCall { tool_call_id, tool_name, input, .. - } => Some((tool_call_id.clone(), tool_name.clone(), input.clone())), + }) => Some((tool_call_id.clone(), tool_name.clone(), input.clone())), _ => None, }) .collect(); assert_eq!(calls.len(), 1); assert_eq!(calls[0].0, "call_1"); assert_eq!(calls[0].1, "get_weather"); - assert_eq!(calls[0].2, serde_json::json!(r#"{"city":"SF"}"#)); + assert_eq!(calls[0].2, r#"{"city":"SF"}"#); // finish_reason tool_calls。 assert!(parts.iter().any(|p| matches!( p, diff --git a/aimux-core/src/response_messages.rs b/aimux-core/src/response_messages.rs index 59c3e63ad..5c290702e 100644 --- a/aimux-core/src/response_messages.rs +++ b/aimux-core/src/response_messages.rs @@ -145,38 +145,27 @@ impl ResponseMessageBuilder { tool_name: call.tool_name.clone(), input: response_tool_call_input(&call.input, call.invalid), provider_executed: call.provider_executed, - thought_signature: call.thought_signature.clone(), provider_options: call.provider_metadata.clone(), }); } - #[allow(clippy::too_many_arguments)] - pub fn tool_result( - &mut self, - tool_call_id: String, - tool_name: String, - result: Value, - is_error: Option, - preliminary: Option, - dynamic: Option, - provider_options: Option, - ) { + pub fn tool_result(&mut self, result: crate::tool::ToolResult) { // Preliminary server-tool results are transient stream updates. The // provider contract requires a later final result, and only that // final value belongs in the replay transcript for the next turn. - if preliminary == Some(true) { + if result.preliminary == Some(true) { return; } self.flush_text(); self.flush_reasoning(); self.parts.push(ContentPart::ToolResult { - tool_call_id, - tool_name: Some(tool_name), - result, - is_error, - preliminary, - dynamic, - provider_options, + tool_call_id: result.tool_call_id, + tool_name: Some(result.tool_name), + result: result.result, + is_error: result.is_error, + preliminary: result.preliminary, + dynamic: result.dynamic, + provider_options: result.provider_metadata, }); } diff --git a/aimux-core/src/result.rs b/aimux-core/src/result.rs index c9d451aa0..c3c9811df 100644 --- a/aimux-core/src/result.rs +++ b/aimux-core/src/result.rs @@ -10,30 +10,9 @@ use ts_rs::TS; use crate::error::AiMuxError; use crate::shared::FileData; use crate::stream_part::StreamPart; +use crate::tool::{RawToolCall, ToolResult}; use crate::types::{FinishReason, ProviderMetadata, ResponseMetadata, Usage, Warning}; -use serde_json::Value; - -/// Compatibility deserializer for `GenerateContent::ToolCall.input`. -/// -/// The field used to be a `serde_json::Value` (the already-parsed arguments, -/// since providers parsed their own input) and became a `String` (the raw, -/// unparsed provider text) in the tool-input-parse-repair refactor, so a -/// `GenerateResult` persisted or recorded before it carries an -/// object/array/number/bool/null here. Accept both: a JSON string passes -/// through unchanged, and any other JSON value is re-serialized to its -/// compact JSON text so the field keeps meaning "the raw text a -/// schema-validating parse would run against". -fn deserialize_tool_call_input<'de, D>(deserializer: D) -> Result -where - D: serde::Deserializer<'de>, -{ - Ok(match Value::deserialize(deserializer)? { - Value::String(raw) => raw, - legacy => legacy.to_string(), - }) -} - /// A content item in the generation result. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] #[ts(export)] @@ -44,37 +23,9 @@ pub enum GenerateContent { #[serde(default, skip_serializing_if = "Option::is_none")] provider_metadata: Option, }, - /// A tool call requested by the model. - ToolCall { - tool_call_id: String, - tool_name: String, - /// The model's raw argument text, exactly as the provider delivered - /// it (possibly malformed). Providers never parse it — `generate_text` - /// owns parsing, schema validation, and repair. - /// - /// Always serializes as a JSON string. Deserializes a JSON string - /// (the current wire shape) unchanged, and also re-serializes the - /// pre-refactor shape — an already-parsed JSON value — to its compact - /// JSON text, so a `GenerateResult` persisted before this field - /// became a `String` keeps loading. See docs/api/gaps.md §9. - #[serde(deserialize_with = "deserialize_tool_call_input")] - input: String, - /// Whether the tool call will be executed by the provider. - /// If false/unset, the tool call is executed by the client. - #[serde(default, skip_serializing_if = "Option::is_none")] - provider_executed: Option, - /// Whether the tool is dynamic (defined at runtime, e.g. MCP tools). - #[serde(default, skip_serializing_if = "Option::is_none")] - dynamic: Option, - /// Provider-assigned thought signature (e.g. Google Gemini - /// `thoughtSignature`). Must be echoed back verbatim on the follow-up - /// turn when the tool result is sent. - #[serde(default, skip_serializing_if = "Option::is_none")] - thought_signature: Option, - /// Additional provider-specific metadata for the tool call. - #[serde(default, skip_serializing_if = "Option::is_none")] - provider_metadata: Option, - }, + /// A tool call requested by the model. `input` is the provider's raw + /// argument text; `generate_text` owns parsing, validation, and repair. + ToolCall(RawToolCall), /// A source / citation (e.g. URL citation from search-preview models). Source { id: String, @@ -106,23 +57,7 @@ pub enum GenerateContent { /// A tool result from a provider-executed tool (e.g. xAI file_search, /// web_search). Emitted alongside the preceding `ToolCall` when the /// provider executes the tool server-side. - ToolResult { - tool_call_id: String, - tool_name: String, - result: Value, - /// Whether the result is an error or error message. - #[serde(default, skip_serializing_if = "Option::is_none")] - is_error: Option, - /// Whether the result is preliminary (replaces prior, e.g. image previews). - #[serde(default, skip_serializing_if = "Option::is_none")] - preliminary: Option, - /// Whether the tool is dynamic (defined at runtime, e.g. MCP tools). - #[serde(default, skip_serializing_if = "Option::is_none")] - dynamic: Option, - /// Additional provider-specific metadata for the tool result. - #[serde(default, skip_serializing_if = "Option::is_none")] - provider_metadata: Option, - }, + ToolResult(ToolResult), } // ── M7: top-level aggregation types ───────────────────────────────────────── @@ -249,40 +184,3 @@ impl std::fmt::Debug for StreamResult { .finish() } } - -#[cfg(test)] -mod tests { - use super::*; - - /// Regression test for PR #165 review finding: `GenerateContent::ToolCall` - /// went from carrying `input: Value` (the parsed arguments) to - /// `input: String` (the raw text), so a `GenerateResult` persisted before - /// the refactor carries an object here and must still load rather than - /// fail closed. Serialization stays one-way — always a plain string. - #[test] - fn tool_call_input_loads_the_legacy_object_shape_and_the_current_string_shape() { - let wire = |input: serde_json::Value| { - serde_json::json!({ - "ToolCall": { - "tool_call_id": "call_1", - "tool_name": "get_weather", - "input": input, - } - }) - }; - let from_legacy: GenerateContent = - serde_json::from_value(wire(serde_json::json!({ "city": "Tokyo" }))).unwrap(); - let from_current: GenerateContent = - serde_json::from_value(wire(serde_json::json!(r#"{"city":"Tokyo"}"#))).unwrap(); - assert_eq!(from_legacy, from_current); - - let GenerateContent::ToolCall { input, .. } = &from_legacy else { - panic!("expected ToolCall, got {from_legacy:?}"); - }; - assert_eq!(input, r#"{"city":"Tokyo"}"#); - assert_eq!( - serde_json::to_value(&from_legacy).unwrap(), - wire(serde_json::json!(r#"{"city":"Tokyo"}"#)) - ); - } -} diff --git a/aimux-core/src/stream_part.rs b/aimux-core/src/stream_part.rs index 6a083920c..5e8888d6e 100644 --- a/aimux-core/src/stream_part.rs +++ b/aimux-core/src/stream_part.rs @@ -9,12 +9,17 @@ use ts_rs::TS; use crate::error::AiMuxError; use crate::shared::FileData; +use crate::tool::{RawToolCall, ToolCall, ToolResult}; use crate::types::{FinishReason, ProviderMetadata, Usage, Warning}; /// A single chunk in the stream returned by `do_stream`. +/// +/// Generic over the tool-call payload: providers emit the default +/// [`StreamPart`] (raw argument text); `stream_text` emits [`TextStreamPart`] +/// (parsed and validated [`ToolCall`]). #[derive(Debug, Clone, Serialize, Deserialize, TS)] -#[ts(export)] -pub enum StreamPart { +#[ts(export, concrete(C = ToolCall))] +pub enum StreamPart { // ── P0: text ── /// Start of a text segment. TextStart { @@ -79,50 +84,12 @@ pub enum StreamPart { provider_metadata: Option, }, /// A complete tool call (alternative to the start/delta/end flow). - ToolCall { - tool_call_id: String, - tool_name: String, - /// Serialized argument text in a `Value::String` from `do_stream`; - /// parsed input after `stream_text`. - input: Value, - /// Whether the tool call will be executed by the provider. - #[serde(default, skip_serializing_if = "Option::is_none")] - provider_executed: Option, - /// Whether the tool is dynamic (defined at runtime, e.g. MCP tools). - #[serde(default, skip_serializing_if = "Option::is_none")] - dynamic: Option, - /// Provider-assigned thought signature (e.g. Google Gemini - /// `thoughtSignature`). Must be echoed back verbatim on the follow-up - /// turn when the tool result is sent. - #[serde(default, skip_serializing_if = "Option::is_none")] - thought_signature: Option, - /// Set by Core when the call remains invalid after optional repair. - #[serde(default, skip_serializing_if = "Option::is_none")] - invalid: Option, - /// Typed lookup, parsing, schema, or repair failure. - #[serde(default, skip_serializing_if = "Option::is_none")] - error: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - provider_metadata: Option, - }, + /// + /// The provider layer emits [`RawToolCall`] (`input` is the raw argument + /// text); `stream_text` re-emits the parsed [`ToolCall`] ([`TextStreamPart`]). + ToolCall(C), /// A tool result (provider-executed tools). - ToolResult { - tool_call_id: String, - tool_name: String, - result: Value, - /// Whether the result is an error or error message. - #[serde(default, skip_serializing_if = "Option::is_none")] - is_error: Option, - /// Whether the result is preliminary (replaces prior, e.g. image previews). - #[serde(default, skip_serializing_if = "Option::is_none")] - preliminary: Option, - /// Whether the tool is dynamic (defined at runtime, e.g. MCP tools). - #[serde(default, skip_serializing_if = "Option::is_none")] - dynamic: Option, - /// Additional provider-specific metadata for the tool result. - #[serde(default, skip_serializing_if = "Option::is_none")] - provider_metadata: Option, - }, + ToolResult(ToolResult), // ── P2: file ── /// A file generated by the model (e.g. an image or document). @@ -178,3 +145,40 @@ pub enum StreamPart { /// A raw chunk from the provider (for debugging, when `include_raw_chunks` is set). Raw { raw_value: Value }, } + +/// The stream `stream_text` and later stages produce: tool calls are parsed. +pub type TextStreamPart = StreamPart; + +impl StreamPart { + /// Replace the tool-call payload, leaving every other variant untouched. + pub fn map_tool_call(self, f: impl FnOnce(C) -> D) -> StreamPart { + // The generic parameter forces every variant to be rebuilt. + macro_rules! carry { + ($($variant:ident { $($field:ident),* })*) => { + match self { + StreamPart::ToolCall(call) => StreamPart::ToolCall(f(call)), + StreamPart::ToolResult(result) => StreamPart::ToolResult(result), + $(StreamPart::$variant { $($field),* } => StreamPart::$variant { $($field),* },)* + } + }; + } + carry! { + TextStart { id, provider_metadata } + TextDelta { id, delta, provider_metadata } + TextEnd { id, provider_metadata } + StreamStart { warnings } + Finish { finish_reason, usage, provider_metadata } + Error { error } + ToolInputStart { id, tool_name, provider_executed, dynamic, title, provider_metadata } + ToolInputDelta { id, delta, provider_metadata } + ToolInputEnd { id, provider_metadata } + File { data, media_type, provider_metadata } + ReasoningStart { id, provider_metadata } + ReasoningDelta { id, delta, provider_metadata } + ReasoningEnd { id, provider_metadata } + ResponseMetadata { id, timestamp, model_id } + Source { id, source_type, url, title, provider_metadata } + Raw { raw_value } + } + } +} diff --git a/aimux-core/src/tool.rs b/aimux-core/src/tool.rs index 7c1176da1..421c26500 100644 --- a/aimux-core/src/tool.rs +++ b/aimux-core/src/tool.rs @@ -3,7 +3,7 @@ use std::collections::HashMap; use serde::{Deserialize, Serialize}; -use serde_json::Value; +use serde_json::{Map, Value}; use ts_rs::TS; use crate::error::AiMuxError; @@ -31,7 +31,14 @@ pub struct FunctionTool { /// Example inputs for the tool (V4 `inputExamples`), used by some providers /// to emit `input_examples` in the request body. #[serde(default, skip_serializing_if = "Option::is_none")] - pub input_examples: Option>, + pub input_examples: Option>, +} + +/// An example input for a function tool. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct FunctionToolInputExample { + pub input: Map, } impl FunctionTool { @@ -79,7 +86,7 @@ pub struct ProviderTool { pub name: String, /// The arguments for configuring the tool. Must match the expected /// arguments defined by the provider for this tool. - pub args: Value, + pub args: Map, } /// A tool that can be either a user-defined function tool or a @@ -101,6 +108,24 @@ impl From for Tool { } } +/// Provider-facing tool call before Core parses and validates its input. +/// +/// The wire shape matches [`ToolCall`] field for field, except that `input` +/// is the provider's raw argument *text* rather than a parsed value. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct RawToolCall { + pub tool_call_id: String, + pub tool_name: String, + pub input: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_executed: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dynamic: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_metadata: Option, +} + /// A tool call requested by the model. #[derive(Debug, Clone, Serialize, Deserialize, TS)] #[ts(export)] @@ -118,12 +143,6 @@ pub struct ToolCall { /// Whether the tool is dynamic (defined at runtime, e.g. MCP tools). #[serde(default, skip_serializing_if = "Option::is_none")] pub dynamic: Option, - /// Provider-assigned thought signature (e.g. Google Gemini - /// `thoughtSignature`). Must be echoed back verbatim on the follow-up turn - /// when the tool result is sent; thinking models reject the request - /// otherwise. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub thought_signature: Option, /// Additional provider-specific metadata associated with this call. #[serde(default, skip_serializing_if = "Option::is_none")] pub provider_metadata: Option, @@ -136,12 +155,14 @@ pub struct ToolCall { pub error: Option, } -/// The result of executing a tool call, to be sent back to the model. -#[derive(Debug, Clone, Serialize, Deserialize, TS)] +/// A tool result produced by a provider-executed tool (e.g. xAI `file_search`, +/// web search), carried next to the [`RawToolCall`] it answers. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] #[ts(export)] pub struct ToolResult { - /// Must match the corresponding `ToolCall::tool_call_id`. + /// Must match the corresponding call's `tool_call_id`. pub tool_call_id: String, + pub tool_name: String, /// The tool's output (usually a JSON-serializable value or plain text). pub result: Value, /// Whether the result is an error or error message. @@ -150,6 +171,12 @@ pub struct ToolResult { /// Whether the result is preliminary (replaces prior, e.g. image previews). #[serde(default, skip_serializing_if = "Option::is_none")] pub preliminary: Option, + /// Whether the tool is dynamic (defined at runtime, e.g. MCP tools). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dynamic: Option, + /// Additional provider-specific metadata for the tool result. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_metadata: Option, } /// How the model should choose tools. diff --git a/aimux-core/src/trace/layer.rs b/aimux-core/src/trace/layer.rs index a5b3b6e37..2ceec13a4 100644 --- a/aimux-core/src/trace/layer.rs +++ b/aimux-core/src/trace/layer.rs @@ -573,7 +573,7 @@ impl LanguageModel for TraceLayer { &item, Ok(StreamPart::TextDelta { .. }) | Ok(StreamPart::ReasoningDelta { .. }) - | Ok(StreamPart::ToolCall { .. }) + | Ok(StreamPart::ToolCall(_)) ); if is_model_output { ttft_obs.store(started.elapsed().as_millis() as u64, Ordering::Relaxed); diff --git a/aimux-core/tests/contract_test.rs b/aimux-core/tests/contract_test.rs deleted file mode 100644 index d02d64519..000000000 --- a/aimux-core/tests/contract_test.rs +++ /dev/null @@ -1,363 +0,0 @@ -//! Contract tests — validate wire format consistency across languages. -//! -//! These tests load shared JSON fixtures and verify that Rust serialization -//! matches the expected wire format. The same fixtures are used by Node/Python -//! tests to ensure cross-language consistency. - -use aimux_core::content::ContentPart; -use aimux_core::generate::GenerateTextOptions; -use aimux_core::message::{MessageContent, ModelMessage, Role}; -use aimux_core::options::{TimeoutConfiguration, ToolChoice}; -use aimux_core::result::GenerateContent; -use aimux_core::stream_part::StreamPart; -use aimux_core::types::{FinishReasonUnified, ReasoningEffort}; -use serde_json::Value; - -/// A single fixture from the shared JSON file. -#[derive(serde::Deserialize)] -struct Fixture { - name: String, - #[serde(rename = "type")] - ty: String, - json: String, - #[allow(dead_code)] - description: String, -} - -fn load_fixtures() -> Vec { - let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .join("..") - .join("contract-tests") - .join("fixtures") - .join("wire-format.json"); - let content = std::fs::read_to_string(&path) - .unwrap_or_else(|e| panic!("failed to read fixtures at {path:?}: {e}")); - serde_json::from_str(&content).expect("failed to parse fixtures JSON") -} - -/// Assert that a Rust value serializes to the expected JSON. -fn assert_serialize(value: &T, expected_json: &str, name: &str) { - let actual = - serde_json::to_string(value).unwrap_or_else(|e| panic!("{name}: serialize failed: {e}")); - let actual_val: Value = serde_json::from_str(&actual) - .unwrap_or_else(|e| panic!("{name}: actual is not valid JSON: {e}")); - let expected_val: Value = serde_json::from_str(expected_json) - .unwrap_or_else(|e| panic!("{name}: expected is not valid JSON: {e}")); - assert_eq!( - actual_val, expected_val, - "fixture '{name}': serialization mismatch\n expected: {expected_json}\n actual: {actual}" - ); -} - -/// Assert that expected JSON deserializes to a Rust value that re-serializes -/// to the same JSON (round-trip). -#[allow(dead_code)] -fn assert_roundtrip( - expected_json: &str, - name: &str, - parse: impl Fn(&Value) -> Option, -) { - let expected_val: Value = - serde_json::from_str(expected_json).expect("expected is not valid JSON"); - // We can't always construct the exact Rust value from the fixture, - // so we verify round-trip: deserialize → re-serialize → compare. - let roundtripped = match serde_json::from_str::(expected_json) { - Ok(v) => serde_json::to_string(&v).unwrap(), - Err(_) => { - // Some types need special handling — skip roundtrip for those - return; - } - }; - let rt_val: Value = serde_json::from_str(&roundtripped).unwrap(); - assert_eq!( - rt_val, expected_val, - "fixture '{name}': round-trip mismatch" - ); - // Suppress unused warning - let _ = parse; -} - -#[test] -fn tool_choice_wire_format() { - assert_serialize(&ToolChoice::Auto, "\"auto\"", "tool_choice_auto"); - assert_serialize(&ToolChoice::None, "\"none\"", "tool_choice_none"); - assert_serialize( - &ToolChoice::Required, - "\"required\"", - "tool_choice_required", - ); - assert_serialize( - &ToolChoice::Tool { - tool_name: "get_weather".into(), - }, - "{\"type\":\"tool\",\"toolName\":\"get_weather\"}", - "tool_choice_tool", - ); -} - -#[test] -fn role_wire_format() { - assert_serialize(&Role::User, "\"user\"", "role_user"); - assert_serialize(&Role::System, "\"system\"", "role_system"); -} - -#[test] -fn provider_executed_tool_transcript_message_wire_format() { - let expected = fixture_json("model_message_provider_executed_tool_transcript"); - let message: ModelMessage = serde_json::from_str(&expected).unwrap(); - let MessageContent::Parts(parts) = &message.content else { - panic!("expected multipart assistant message"); - }; - assert!(matches!( - &parts[0], - ContentPart::ToolCall { - provider_executed: Some(true), - .. - } - )); - assert!(matches!( - &parts[1], - ContentPart::ToolResult { - is_error: Some(false), - preliminary: Some(true), - dynamic: Some(true), - .. - } - )); - assert_serialize( - &message, - &expected, - "model_message_provider_executed_tool_transcript", - ); -} - -#[test] -fn finish_reason_unified_wire_format() { - assert_serialize( - &FinishReasonUnified::Stop, - "\"stop\"", - "finish_reason_unified_stop", - ); -} - -#[test] -fn reasoning_effort_wire_format() { - assert_serialize(&ReasoningEffort::High, "\"high\"", "reasoning_effort_high"); -} - -#[test] -fn generate_text_options_default_wire_format() { - let opts = GenerateTextOptions::default(); - let json = serde_json::to_string(&opts).unwrap(); - let val: Value = serde_json::from_str(&json).unwrap(); - assert!( - val.is_object(), - "GenerateTextOptions should serialize to object" - ); - // All fields should be null for default - if let Value::Object(obj) = &val { - for (_, v) in obj { - assert!(v.is_null(), "default field should be null, got {v}"); - } - } -} - -/// RFC-0016 M2 true-case: `include_raw_chunks: Some(true)` round-trips on the -/// wire (companion of the `generate_text_options_include_raw_chunks_true` -/// fixture). -#[test] -fn generate_text_options_include_raw_chunks_true_wire_format() { - let opts = GenerateTextOptions { - include_raw_chunks: Some(true), - ..Default::default() - }; - let json = serde_json::to_string(&opts).unwrap(); - let val: Value = serde_json::from_str(&json).unwrap(); - assert_eq!( - val.get("include_raw_chunks"), - Some(&Value::Bool(true)), - "include_raw_chunks must serialize as true, got {json}" - ); - // Round-trip: deserialize back to the same option. - let back: GenerateTextOptions = serde_json::from_str(&json).unwrap(); - assert_eq!(back.include_raw_chunks, Some(true)); -} - -/// RFC-0024: an explicit `session_id` crosses the wire as snake_case and -/// round-trips (companion of the `generate_text_options_with_session_id` -/// fixture). -#[test] -fn generate_text_options_session_id_wire_format() { - let opts = GenerateTextOptions { - session_id: Some("sess-1".into()), - ..Default::default() - }; - let json = serde_json::to_string(&opts).unwrap(); - let val: Value = serde_json::from_str(&json).unwrap(); - assert_eq!( - val.get("session_id"), - Some(&Value::String("sess-1".into())), - "session_id must serialize as snake_case string, got {json}" - ); - let back: GenerateTextOptions = serde_json::from_str(&json).unwrap(); - assert_eq!(back.session_id.as_deref(), Some("sess-1")); -} - -/// Look up a fixture's expected JSON by name, failing loudly if it is gone. -/// -/// This is the seam that makes Rust — the wire authority — actually consume the -/// shared fixture file instead of only hardcoding its own expectations. Without -/// it a fixture can drift away from Rust's real output and only the *other* -/// bindings go red, which points the blame at exactly the wrong side. -fn fixture_json(name: &str) -> String { - load_fixtures() - .into_iter() - .find(|f| f.name == name) - .unwrap_or_else(|| panic!("fixture '{name}' not found in wire-format.json")) - .json -} - -/// Every numeric field carries a non-null value, so each binding's declared -/// types are exercised rather than hidden behind `null`. -/// -/// `top_k` is deliberately fractional. Rust is `Option` — matching the -/// upstream AI SDK, whose `topK?: number` carries no integer constraint — so a -/// binding that declares it as an integer cannot round-trip this fixture. -#[test] -fn generate_text_options_numeric_types_wire_format() { - let opts = GenerateTextOptions { - max_output_tokens: Some(256), - temperature: Some(0.7), - top_p: Some(0.95), - top_k: Some(40.5), - presence_penalty: Some(0.5), - frequency_penalty: Some(-0.5), - seed: Some(42), - max_retries: Some(3), - ..Default::default() - }; - let expected = fixture_json("generate_text_options_numeric_types"); - assert_serialize(&opts, &expected, "generate_text_options_numeric_types"); - - // Round-trip: the fractional top_k must survive as a fraction. An integer - // field would truncate 40.5 to 40 and still "pass" a name-only check. - let back: GenerateTextOptions = serde_json::from_str(&expected).unwrap(); - assert_eq!(back.top_k, Some(40.5), "top_k must round-trip as f64"); - assert_eq!( - back.frequency_penalty, - Some(-0.5), - "penalties must stay signed" - ); - assert_eq!(back.max_output_tokens, Some(256)); - assert_eq!(back.seed, Some(42)); - assert_eq!(back.max_retries, Some(3)); -} - -#[test] -fn stream_part_text_delta_wire_format() { - let part = StreamPart::TextDelta { - id: "tx1".into(), - delta: "Hello".into(), - provider_metadata: None, - }; - let json = serde_json::to_string(&part).unwrap(); - let val: Value = serde_json::from_str(&json).unwrap(); - assert!( - val.get("TextDelta").is_some(), - "expected TextDelta variant, got {json}" - ); -} - -#[test] -fn stream_part_stream_start_wire_format() { - let part = StreamPart::StreamStart { warnings: vec![] }; - let json = serde_json::to_string(&part).unwrap(); - let val: Value = serde_json::from_str(&json).unwrap(); - assert!( - val.get("StreamStart").is_some(), - "expected StreamStart variant, got {json}" - ); -} - -#[test] -fn stream_part_raw_wire_format() { - let part = StreamPart::Raw { - raw_value: serde_json::json!({ "id": "c1", "choices": [] }), - }; - let json = serde_json::to_string(&part).unwrap(); - let val: Value = serde_json::from_str(&json).unwrap(); - assert_eq!( - val.get("Raw").and_then(|r| r.get("raw_value")), - Some(&serde_json::json!({ "id": "c1", "choices": [] })), - "expected Raw variant with raw_value, got {json}" - ); -} - -#[test] -fn model_message_text_wire_format() { - let msg = ModelMessage::user("Hello"); - assert_serialize( - &msg, - "{\"role\":\"user\",\"content\":\"Hello\"}", - "model_message_text", - ); -} - -/// Deserialize one fixture into its Rust type, re-serialize, and compare the -/// result against the fixture's own JSON. -fn assert_fixture_matches_rust(fixture: &Fixture) -where - T: serde::Serialize + serde::de::DeserializeOwned, -{ - let parsed: T = serde_json::from_str(&fixture.json).unwrap_or_else(|e| { - panic!( - "fixture '{}' ({}) does not deserialize into its Rust type: {e}\n fixture: {}", - fixture.name, fixture.ty, fixture.json - ) - }); - let rust_json = serde_json::to_string(&parsed).unwrap(); - let actual: Value = serde_json::from_str(&rust_json).unwrap(); - let expected: Value = serde_json::from_str(&fixture.json).unwrap(); - assert_eq!( - actual, expected, - "fixture '{}' does not match Rust's own serialization\n fixture: {}\n rust: {}", - fixture.name, fixture.json, rust_json - ); -} - -/// Every fixture must round-trip through the Rust type it claims to describe. -/// -/// Rust is the wire authority: the other bindings validate themselves against -/// this file, so a fixture describing a shape Rust never emits sends four -/// bindings chasing a fiction. The predecessor of this test only checked that -/// the fixture's JSON *parsed*, which is why the `session_id: null` drift -/// survived — `session_id` is `skip_serializing_if = "Option::is_none"`, so -/// Rust omits the key entirely. -/// -/// A fixture whose `type` has no arm here fails rather than being skipped: -/// silent skipping is how a net grows holes. -#[test] -fn all_fixtures_match_rust_serialization() { - let fixtures = load_fixtures(); - assert!(!fixtures.is_empty(), "no fixtures loaded"); - - for f in &fixtures { - match f.ty.as_str() { - "ToolChoice" => assert_fixture_matches_rust::(f), - "StreamPart" => assert_fixture_matches_rust::(f), - "GenerateContent" => assert_fixture_matches_rust::(f), - "GenerateTextOptions" => assert_fixture_matches_rust::(f), - "TimeoutConfiguration" => assert_fixture_matches_rust::(f), - "Role" => assert_fixture_matches_rust::(f), - "ModelMessage" => assert_fixture_matches_rust::(f), - "FinishReasonUnified" => assert_fixture_matches_rust::(f), - "ReasoningEffort" => assert_fixture_matches_rust::(f), - other => panic!( - "fixture '{}' declares type '{other}', which has no round-trip arm in \ - all_fixtures_match_rust_serialization — wire it up so the fixture is \ - actually pinned to Rust", - f.name - ), - } - } -} diff --git a/aimux-core/tests/host_side_tool_call_repair_test.rs b/aimux-core/tests/host_side_tool_call_repair_test.rs deleted file mode 100644 index 390587ce4..000000000 --- a/aimux-core/tests/host_side_tool_call_repair_test.rs +++ /dev/null @@ -1,421 +0,0 @@ -//! Host-side tool-call repair (RFC-0035). -//! -//! The closure path (`ToolCallRepair`) and the data path -//! (`apply_tool_call_repair`) must be indistinguishable: the bindings rely on -//! the second producing exactly what Rust callers get from the first. - -use aimux_core::error::AiMuxError; -use aimux_core::generate::{ - GenerateTextOptions, GenerateTextResult, generate_text_result_to_chat_completion, -}; -use aimux_core::message::ModelPrompt; -use aimux_core::parse_tool_call::{ - RawToolCall, ToolCallRepair, ToolCallRepairReply, apply_tool_call_repair, - apply_tool_call_repair_to_result, parse_tool_call, tool_call_repair_context, - tool_call_repair_inputs, -}; -use aimux_core::tool::{FunctionTool, Tool, ToolCall}; -use serde_json::{Value, json}; - -fn weather_tool() -> Tool { - FunctionTool::new( - "weather", - json!({ - "type": "object", - "properties": { "city": { "type": "string" } }, - "required": ["city"], - }), - ) - .into() -} - -fn raw(name: &str, input: &str) -> RawToolCall { - RawToolCall { - tool_call_id: "call-1".into(), - tool_name: name.into(), - input: input.into(), - provider_executed: None, - dynamic: None, - thought_signature: None, - provider_metadata: None, - } -} - -/// The invalid `ToolCall` a host reads off `GenerateTextResult.tool_calls`. -async fn invalid_call(input: &str) -> ToolCall { - let call = parse_tool_call( - raw("weather", input), - Some(&[weather_tool()]), - None, - &[], - None, - ) - .await; - assert_eq!(call.invalid, Some(true)); - call -} - -/// Run the closure path with the callback result the given reply models. -async fn via_closure( - input: &str, - outcome: Result, AiMuxError>, -) -> aimux_core::tool::ToolCall { - let repair = ToolCallRepair::new(move |_| { - let outcome = outcome.clone(); - async move { outcome } - }); - parse_tool_call( - raw("weather", input), - Some(&[weather_tool()]), - Some(&repair), - &[], - None, - ) - .await -} - -const BAD: &str = r#"{"town":"Singapore"}"#; - -// ── closure / host-side equivalence ───────────────────────────────────────── - -#[tokio::test] -async fn both_paths_agree_on_every_branch() { - let repaired = raw("weather", r#"{"city":"Singapore"}"#); - let still_bad = raw("weather", r#"{"city":42}"#); - let cases: Vec<(Result, AiMuxError>, ToolCallRepairReply)> = vec![ - ( - Ok(Some(repaired.clone())), - ToolCallRepairReply::Repaired { - tool_call: repaired, - }, - ), - ( - Ok(Some(still_bad.clone())), - ToolCallRepairReply::Repaired { - tool_call: still_bad, - }, - ), - (Ok(None), ToolCallRepairReply::Unchanged), - ( - Err(AiMuxError::Other("boom".into())), - ToolCallRepairReply::Failed { - message: "boom".into(), - }, - ), - ]; - - for (outcome, reply) in cases { - let from_closure = via_closure(BAD, outcome).await; - let from_data = - apply_tool_call_repair(&invalid_call(BAD).await, Some(&[weather_tool()]), reply) - .unwrap(); - assert_eq!( - serde_json::to_value(&from_closure).unwrap(), - serde_json::to_value(&from_data).unwrap(), - ); - } -} - -// ── repair context ────────────────────────────────────────────────────────── - -#[tokio::test] -async fn context_of_an_unknown_tool_falls_back_to_an_empty_schema() { - let unknown = parse_tool_call( - raw("wether", r#"{"city":"Singapore"}"#), - Some(&[weather_tool()]), - None, - &[], - None, - ) - .await; - let context = tool_call_repair_context(&unknown, Some(&[weather_tool()]), &[], None) - .unwrap() - .expect("a tool set was supplied"); - assert_eq!(context["input_schema"]["properties"], json!({})); - assert!(context["error"]["NoSuchTool"].is_object()); -} - -// ── result patching ───────────────────────────────────────────────────────── - -fn result_with(call: &ToolCall) -> Value { - json!({ - "tool_calls": [serde_json::to_value(call).unwrap()], - "response_messages": [{ - "role": "assistant", - "content": [ - { "type": "text", "text": "checking" }, - { - "type": "tool_call", - "tool_call_id": call.tool_call_id, - "tool_name": call.tool_name, - "input": call.input, - }, - ], - }], - }) -} - -#[tokio::test] -async fn patching_a_generate_object_result_reaches_into_raw() { - let result = json!({ "object": {"ok": true}, "raw": result_with(&invalid_call(BAD).await) }); - let patched = apply_tool_call_repair_to_result( - &result, - Some(&[weather_tool()]), - "call-1", - ToolCallRepairReply::Repaired { - tool_call: raw("weather", r#"{"city":"Singapore"}"#), - }, - ) - .unwrap(); - assert_eq!(patched["object"], json!({"ok": true})); - assert_eq!( - patched["raw"]["tool_calls"][0]["input"], - json!({"city":"Singapore"}) - ); -} - -#[tokio::test] -async fn patching_an_aggregated_stream_result_round_trips_through_its_type() { - // Bindings patch the output of `consume_stream_text` with the same - // function; the patched document must still decode as that type. - let call = invalid_call(BAD).await; - let mut aggregated: aimux_core::result::StreamTextResultAggregated = serde_json::from_value( - json!({ "text": "", "finish_reason": { "unified": "tool-calls", "raw": null } }), - ) - .unwrap(); - let shape = result_with(&call); - aggregated.tool_calls = vec![call]; - aggregated.response_messages = - serde_json::from_value(shape["response_messages"].clone()).unwrap(); - - let patched = apply_tool_call_repair_to_result( - &serde_json::to_value(&aggregated).unwrap(), - Some(&[weather_tool()]), - "call-1", - ToolCallRepairReply::Repaired { - tool_call: raw("weather", r#"{"city":"Singapore"}"#), - }, - ) - .unwrap(); - let decoded: aimux_core::result::StreamTextResultAggregated = - serde_json::from_value(patched).unwrap(); - assert_eq!(decoded.tool_calls[0].invalid, None); - assert_eq!(decoded.tool_calls[0].input, json!({"city":"Singapore"})); -} - -#[tokio::test] -async fn context_keeps_the_provider_argument_text_verbatim() { - // A bare JSON string literal parses fine, so `input` holds `Tokyo` - // without quotes; the host must still see the provider's `"Tokyo"`, or a - // repair that only renames the tool re-validates the wrong text. - let call = invalid_call(r#""Tokyo""#).await; - assert_eq!(call.input, json!("Tokyo")); - let context = tool_call_repair_context(&call, Some(&[weather_tool()]), &[], None) - .unwrap() - .expect("a tool set was supplied"); - assert_eq!(context["tool_call"]["input"], json!(r#""Tokyo""#)); - - let spaced = invalid_call(r#"{ "town" : "Tokyo" }"#).await; - let context = tool_call_repair_context(&spaced, Some(&[weather_tool()]), &[], None) - .unwrap() - .unwrap(); - assert_eq!( - context["tool_call"]["input"], - json!(r#"{ "town" : "Tokyo" }"#) - ); -} - -#[tokio::test] -async fn patching_refuses_a_duplicated_tool_call_id() { - let call = invalid_call(BAD).await; - let mut result = result_with(&call); - let dup = result["tool_calls"][0].clone(); - result["tool_calls"].as_array_mut().unwrap().push(dup); - let error = apply_tool_call_repair_to_result( - &result, - Some(&[weather_tool()]), - "call-1", - ToolCallRepairReply::Unchanged, - ) - .unwrap_err(); - assert!( - matches!(error, AiMuxError::InvalidArgument(ref m) if m.contains("not unique")), - "{error:?}" - ); -} - -#[tokio::test] -async fn patching_refuses_a_replacement_id_that_already_exists() { - let call = invalid_call(BAD).await; - let mut result = result_with(&call); - let mut other = result["tool_calls"][0].clone(); - other["tool_call_id"] = json!("call-2"); - result["tool_calls"].as_array_mut().unwrap().push(other); - - let error = apply_tool_call_repair_to_result( - &result, - Some(&[weather_tool()]), - "call-1", - ToolCallRepairReply::Repaired { - tool_call: RawToolCall { - tool_call_id: "call-2".into(), - ..raw("weather", r#"{"city":"Singapore"}"#) - }, - }, - ) - .unwrap_err(); - - assert!( - matches!(error, AiMuxError::InvalidArgument(ref m) if m.contains("already exists")), - "{error:?}" - ); -} - -#[tokio::test] -async fn patching_a_primitive_input_is_not_replayed_into_the_transcript() { - // A still-invalid call whose input is a bare string: `tool_calls` keeps it, - // the replay transcript must not. - let result = result_with(&invalid_call("not json at all").await); - let patched = apply_tool_call_repair_to_result( - &result, - Some(&[weather_tool()]), - "call-1", - ToolCallRepairReply::Unchanged, - ) - .unwrap(); - assert_eq!(patched["tool_calls"][0]["input"], json!("not json at all")); - assert_eq!( - patched["response_messages"][0]["content"][1]["input"], - json!({}) - ); -} - -#[tokio::test] -async fn a_patched_result_converts_to_a_chat_completion_that_reflects_the_repair() { - // The OpenAI non-streaming path on a host: repair the native result, then - // convert it. The provider's raw text in `raw.content` stays malformed; - // the completion must take its arguments from the patched `tool_calls`. - let finish = json!({ "unified": "tool-calls", "raw": null }); - let usage = json!({ "input_tokens": { "total": 1 }, "output_tokens": { "total": 1 } }); - let mut result = result_with(&invalid_call(BAD).await); - let object = result.as_object_mut().unwrap(); - object.insert("text".into(), json!("")); - object.insert("finish_reason".into(), finish.clone()); - object.insert("usage".into(), usage.clone()); - object.insert("warnings".into(), json!([])); - object.insert( - "raw".into(), - json!({ - "content": [{ "ToolCall": { - "tool_call_id": "call-1", "tool_name": "weather", "input": BAD, - } }], - "finish_reason": finish, - "usage": usage, - "warnings": [], - "provider_metadata": null, - "response": { "id": null, "timestamp": null, "model_id": null }, - "request_body": null, - "response_headers": null, - }), - ); - - let patched = apply_tool_call_repair_to_result( - &result, - Some(&[weather_tool()]), - "call-1", - ToolCallRepairReply::Repaired { - tool_call: raw("weather", r#"{"city":"Singapore"}"#), - }, - ) - .unwrap(); - let decoded: GenerateTextResult = serde_json::from_value(patched).unwrap(); - let completion = generate_text_result_to_chat_completion(&decoded, "fallback-model"); - - assert_eq!(completion.model, "fallback-model"); - let calls = completion.choices[0].message.tool_calls.as_ref().unwrap(); - assert_eq!(calls.len(), 1); - assert_eq!(calls[0].id, "call-1"); - assert_eq!( - serde_json::from_str::(&calls[0].function.arguments).unwrap(), - json!({"city":"Singapore"}) - ); -} - -// ── wire shapes ───────────────────────────────────────────────────────────── - -#[test] -fn reply_wire_shapes_round_trip_and_reject_unknown_fields() { - let repaired: ToolCallRepairReply = serde_json::from_str( - r#"{"type":"repaired","tool_call":{"tool_call_id":"c1","tool_name":"weather","input":"{}"}}"#, - ) - .unwrap(); - assert!(matches!(repaired, ToolCallRepairReply::Repaired { .. })); - assert!(serde_json::from_str::(r#"{"type":"unchanged"}"#).is_ok()); - assert!(serde_json::from_str::(r#"{"type":"failed"}"#).is_err()); - // serde's `deny_unknown_fields` reaches the struct variants of an - // internally tagged enum, but not its unit variants. - assert!( - serde_json::from_str::(r#"{"type":"failed","message":"m","x":1}"#) - .is_err() - ); -} - -// ── shared contract fixture ───────────────────────────────────────────────── - -/// Run `contract-tests/fixtures/tool-call-repair.json` against the Rust -/// implementation, so the file every binding asserts against cannot drift. -#[test] -fn contract_fixture_matches_the_implementation() { - let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../contract-tests/fixtures/tool-call-repair.json"); - let fixture: Value = serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap(); - - for case in fixture["cases"].as_array().unwrap() { - let name = case["name"].as_str().unwrap(); - let input = &case["input"]; - // Every case carries the prompt and options the call was generated - // with, exactly as a host would hand them back. - let options: GenerateTextOptions = match &input["opts"] { - Value::Null => GenerateTextOptions::default(), - opts => serde_json::from_value(opts.clone()).unwrap(), - }; - let prompt: ModelPrompt = match &input["prompt"] { - Value::Object(object) if object.len() == 1 && object.contains_key("prompt") => { - serde_json::from_value(object["prompt"].clone()).unwrap() - } - Value::Null => ModelPrompt::Text(String::new()), - prompt => serde_json::from_value(prompt.clone()).unwrap(), - }; - let (messages, instructions, tools) = tool_call_repair_inputs(prompt, &options); - let reply = || serde_json::from_value(input["reply"].clone()).unwrap(); - let tool_call = || serde_json::from_value::(input["tool_call"].clone()).unwrap(); - - let actual = match case["function"].as_str().unwrap() { - "tool_call_repair_context" => { - tool_call_repair_context(&tool_call(), tools, &messages, instructions) - .map(|context| serde_json::to_value(context).unwrap()) - } - "apply_tool_call_repair" => apply_tool_call_repair(&tool_call(), tools, reply()) - .map(|call| serde_json::to_value(call).unwrap()), - "apply_tool_call_repair_to_result" => apply_tool_call_repair_to_result( - &input["result"], - tools, - input["tool_call_id"].as_str().unwrap(), - reply(), - ), - other => panic!("{name}: unknown function {other}"), - }; - - match case.get("expected_error").and_then(Value::as_str) { - Some(variant) => { - let error = actual.unwrap_err(); - let key = serde_json::to_value(&error).unwrap(); - assert!( - key.get(variant).is_some(), - "{name}: expected {variant}, got {error}" - ); - } - None => assert_eq!(actual.unwrap(), case["expected"], "{name}"), - } - } -} diff --git a/aimux-core/tests/m7_aggregation_test.rs b/aimux-core/tests/m7_aggregation_test.rs index 51fecfe5f..a44eb0a9e 100644 --- a/aimux-core/tests/m7_aggregation_test.rs +++ b/aimux-core/tests/m7_aggregation_test.rs @@ -10,6 +10,7 @@ use aimux_core::message::{MessageContent, ModelPrompt, Role}; use aimux_core::options::CallOptions; use aimux_core::result::{GenerateContent, GenerateResult, StreamResult}; use aimux_core::shared::{FileBytes, FileData}; +use aimux_core::tool::RawToolCall; use aimux_core::types::{FinishReason, FinishReasonUnified, Usage}; use async_trait::async_trait; @@ -38,15 +39,14 @@ impl LanguageModel for RichModel { text: "Hello!".into(), provider_metadata: None, }, - GenerateContent::ToolCall { + GenerateContent::ToolCall(RawToolCall { tool_call_id: "tc-1".into(), tool_name: "search".into(), input: r#"{"q":"test"}"#.to_string(), provider_executed: None, dynamic: None, - thought_signature: None, provider_metadata: None, - }, + }), GenerateContent::Source { id: "src-1".into(), source_type: "url".into(), diff --git a/aimux-core/tests/response_messages_tool_test.rs b/aimux-core/tests/response_messages_tool_test.rs index f93a2c8ee..30209ba59 100644 --- a/aimux-core/tests/response_messages_tool_test.rs +++ b/aimux-core/tests/response_messages_tool_test.rs @@ -5,6 +5,7 @@ use aimux_core::message::{MessageContent, Role}; use aimux_core::options::CallOptions; use aimux_core::result::{GenerateContent, GenerateResult, StreamResult}; use aimux_core::stream_part::StreamPart; +use aimux_core::tool::{RawToolCall, ToolResult}; use aimux_core::types::{FinishReason, FinishReasonUnified, Usage}; use async_trait::async_trait; use serde_json::json; @@ -35,18 +36,17 @@ impl LanguageModel for ProviderTranscriptModel { text: "before".to_string(), provider_metadata: None, }, - GenerateContent::ToolCall { + GenerateContent::ToolCall(RawToolCall { tool_call_id: "srv-1".to_string(), tool_name: "server_search".to_string(), input: r#"{"query":"Rust"}"#.to_string(), provider_executed: Some(true), dynamic: Some(true), - thought_signature: None, provider_metadata: Some(json!({ "mock": { "serverCallId": "wire-1" } })), - }, - GenerateContent::ToolResult { + }), + GenerateContent::ToolResult(ToolResult { tool_call_id: "srv-1".to_string(), tool_name: "server_search".to_string(), result: json!({ "answer": "still running" }), @@ -56,8 +56,8 @@ impl LanguageModel for ProviderTranscriptModel { provider_metadata: Some(json!({ "mock": { "serverResultId": "wire-preliminary-1" } })), - }, - GenerateContent::ToolResult { + }), + GenerateContent::ToolResult(ToolResult { tool_call_id: "srv-1".to_string(), tool_name: "server_search".to_string(), result: json!({ "answer": 42 }), @@ -67,7 +67,7 @@ impl LanguageModel for ProviderTranscriptModel { provider_metadata: Some(json!({ "mock": { "serverResultId": "wire-result-1" } })), - }, + }), GenerateContent::Text { text: "after".to_string(), provider_metadata: None, @@ -91,20 +91,17 @@ impl LanguageModel for ProviderTranscriptModel { delta: "before".to_string(), provider_metadata: None, }), - Ok(StreamPart::ToolCall { + Ok(StreamPart::ToolCall(RawToolCall { tool_call_id: "srv-1".to_string(), tool_name: "server_search".to_string(), - input: json!(r#"{"query":"Rust"}"#), + input: r#"{"query":"Rust"}"#.to_string(), provider_executed: Some(true), dynamic: Some(true), - thought_signature: None, - invalid: None, - error: None, provider_metadata: Some(json!({ "mock": { "serverCallId": "wire-1" } })), - }), - Ok(StreamPart::ToolResult { + })), + Ok(StreamPart::ToolResult(ToolResult { tool_call_id: "srv-1".to_string(), tool_name: "server_search".to_string(), result: json!({ "answer": "still running" }), @@ -114,8 +111,8 @@ impl LanguageModel for ProviderTranscriptModel { provider_metadata: Some(json!({ "mock": { "serverResultId": "wire-preliminary-1" } })), - }), - Ok(StreamPart::ToolResult { + })), + Ok(StreamPart::ToolResult(ToolResult { tool_call_id: "srv-1".to_string(), tool_name: "server_search".to_string(), result: json!({ "answer": 42 }), @@ -125,7 +122,7 @@ impl LanguageModel for ProviderTranscriptModel { provider_metadata: Some(json!({ "mock": { "serverResultId": "wire-result-1" } })), - }), + })), Ok(StreamPart::TextDelta { id: "text-2".to_string(), delta: "after".to_string(), @@ -238,15 +235,14 @@ impl LanguageModel for InvalidToolInputModel { async fn do_generate(&self, _options: &CallOptions) -> Result { Ok(GenerateResult { - content: vec![GenerateContent::ToolCall { + content: vec![GenerateContent::ToolCall(RawToolCall { tool_call_id: "bad-1".to_string(), tool_name: "server_search".to_string(), input: "{".to_string(), provider_executed: Some(true), dynamic: Some(true), - thought_signature: None, provider_metadata: None, - }], + })], finish_reason: finish_reason(), usage: Usage::default(), warnings: vec![], diff --git a/aimux-core/tests/tool_input_test.rs b/aimux-core/tests/tool_input_test.rs index cbfcec813..655d94d3c 100644 --- a/aimux-core/tests/tool_input_test.rs +++ b/aimux-core/tests/tool_input_test.rs @@ -8,10 +8,10 @@ use aimux_core::generate::{ use aimux_core::language_model::LanguageModel; use aimux_core::openai_output::OpenAiStreamOptions; use aimux_core::options::CallOptions; -use aimux_core::parse_tool_call::{RawToolCall, ToolCallRepair, parse_tool_call}; +use aimux_core::parse_tool_call::{ToolCallRepair, parse_tool_call}; use aimux_core::result::{GenerateContent, GenerateResult, StreamResult}; use aimux_core::stream_part::StreamPart; -use aimux_core::tool::{FunctionTool, Tool}; +use aimux_core::tool::{FunctionTool, RawToolCall, Tool, ToolCall}; use aimux_core::types::{FinishReason, FinishReasonUnified, Usage}; use async_trait::async_trait; use futures::StreamExt; @@ -28,7 +28,6 @@ fn raw(name: &str, input: &str) -> RawToolCall { input: input.into(), provider_executed: None, dynamic: None, - thought_signature: None, provider_metadata: None, } } @@ -345,15 +344,14 @@ impl LanguageModel for RawToolModel { async fn do_generate(&self, _options: &CallOptions) -> Result { Ok(GenerateResult { - content: vec![GenerateContent::ToolCall { + content: vec![GenerateContent::ToolCall(RawToolCall { tool_call_id: "call-1".into(), tool_name: self.tool_name.into(), input: self.input.to_string(), provider_executed: None, dynamic: None, - thought_signature: None, provider_metadata: None, - }], + })], finish_reason: FinishReason { unified: FinishReasonUnified::ToolCalls, raw: Some("tool_calls".into()), @@ -398,17 +396,14 @@ impl LanguageModel for RawToolModel { ]); } parts.extend([ - Ok(StreamPart::ToolCall { + Ok(StreamPart::ToolCall(RawToolCall { tool_call_id: "call-1".into(), tool_name: self.tool_name.into(), - input: json!(self.input), + input: self.input.to_string(), provider_executed: None, dynamic: None, - thought_signature: None, - invalid: None, - error: None, provider_metadata: None, - }), + })), Ok(StreamPart::Finish { finish_reason: FinishReason { unified: FinishReasonUnified::ToolCalls, @@ -444,8 +439,8 @@ async fn generate_text_parses_provider_raw_input_at_the_core_boundary() { assert_eq!(result.tool_calls[0].invalid, None); assert!(matches!( &result.raw.content[0], - GenerateContent::ToolCall { input, .. } - if input == &json!(r#"{"city":"Singapore"}"#) + GenerateContent::ToolCall(RawToolCall { input, .. }) + if input == r#"{"city":"Singapore"}"# )); } @@ -464,7 +459,7 @@ async fn stream_text_parses_provider_raw_input_at_the_core_boundary() { .unwrap(); while let Some(part) = result.stream.next().await { - if let StreamPart::ToolCall { input, invalid, .. } = part.unwrap() { + if let StreamPart::ToolCall(ToolCall { input, invalid, .. }) = part.unwrap() { assert_eq!(input, json!({ "city": "Singapore" })); assert_eq!(invalid, None); return; diff --git a/aimux-ffi/src/lib.rs b/aimux-ffi/src/lib.rs index 3afa8a1c4..ab0916288 100644 --- a/aimux-ffi/src/lib.rs +++ b/aimux-ffi/src/lib.rs @@ -2102,7 +2102,9 @@ fn stream_text_with_signal( let cstr = match item { Ok(part) => stream_part_cstring(&part)?, Err(e) if e.is_recoverable_stream_error() => { - stream_part_cstring(&aimux_core::stream_part::StreamPart::Error { error: e })? + stream_part_cstring(&aimux_core::stream_part::TextStreamPart::Error { + error: e, + })? } Err(e) => return Err(e.into()), }; diff --git a/aimux-provider-utils/src/streaming_tool_call_tracker.rs b/aimux-provider-utils/src/streaming_tool_call_tracker.rs index ce9666dd8..cfbd25b94 100644 --- a/aimux-provider-utils/src/streaming_tool_call_tracker.rs +++ b/aimux-provider-utils/src/streaming_tool_call_tracker.rs @@ -31,6 +31,7 @@ use thiserror::Error; use aimux_core::error::AiMuxError; use aimux_core::stream_part::StreamPart; +use aimux_core::tool::RawToolCall; use aimux_core::types::ProviderMetadata; use crate::streaming_tool_call_argument_state::{ @@ -623,18 +624,15 @@ impl StreamingToolCallTracker { .as_ref() .and_then(|build| build(tool_call.metadata.as_ref())); - parts.push(StreamPart::ToolCall { + parts.push(StreamPart::ToolCall(RawToolCall { tool_call_id: tool_call.id.clone(), tool_name: tool_call.function_name.clone(), // Raw argument text; Core parses it after `stream_text`. - input: Value::String(tool_call.arguments.clone()), + input: tool_call.arguments.clone(), provider_executed: None, dynamic: None, - thought_signature: None, - invalid: None, - error: None, provider_metadata, - }); + })); } } diff --git a/aimux-provider-utils/tests/streaming_tool_call_tracker_test.rs b/aimux-provider-utils/tests/streaming_tool_call_tracker_test.rs index 238a0ecb9..263b6c26a 100644 --- a/aimux-provider-utils/tests/streaming_tool_call_tracker_test.rs +++ b/aimux-provider-utils/tests/streaming_tool_call_tracker_test.rs @@ -8,6 +8,7 @@ use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; use aimux_core::stream_part::StreamPart; +use aimux_core::tool::RawToolCall; use aimux_provider_utils::{ StreamingToolCallDelta, StreamingToolCallFunction, StreamingToolCallTracker, TrackerError, TypeValidation, @@ -36,8 +37,7 @@ enum Part { ToolCall { tool_call_id: String, tool_name: String, - /// The raw argument text (`StreamPart::ToolCall.input` is a - /// `Value::String` from the tracker). + /// The raw argument text. input: String, provider_metadata: Option, }, @@ -62,17 +62,14 @@ fn project(part: StreamPart) -> Part { id, provider_metadata: None, } => Part::ToolInputEnd { id }, - StreamPart::ToolCall { + StreamPart::ToolCall(RawToolCall { tool_call_id, tool_name, - input: Value::String(input), + input, provider_executed: None, dynamic: None, - thought_signature: None, - invalid: None, - error: None, provider_metadata, - } => Part::ToolCall { + }) => Part::ToolCall { tool_call_id, tool_name, input, diff --git a/aimux-providers/src/anthropic/convert.rs b/aimux-providers/src/anthropic/convert.rs index c6e98ca52..670501354 100644 --- a/aimux-providers/src/anthropic/convert.rs +++ b/aimux-providers/src/anthropic/convert.rs @@ -1919,7 +1919,7 @@ fn apply_anthropic_tools( } else { None }, - Some(&options.tool_choice), + options.tool_choice.as_ref(), disable_parallel_tool_use, caps.supports_structured_output, caps.supports_structured_output, diff --git a/aimux-providers/src/anthropic/prepare_tools.rs b/aimux-providers/src/anthropic/prepare_tools.rs index 2d42c8887..c7b4e606a 100644 --- a/aimux-providers/src/anthropic/prepare_tools.rs +++ b/aimux-providers/src/anthropic/prepare_tools.rs @@ -13,7 +13,7 @@ use std::collections::BTreeSet; use aimux_core::tool::FunctionTool; use aimux_core::tool::ToolChoice; use aimux_core::types::Warning; -use serde_json::{Value, json}; +use serde_json::{Map, Value, json}; /// Result of [`prepare_tools`] / [`prepare_tools_with_provider`]. #[derive(Debug, Clone)] @@ -121,13 +121,13 @@ pub enum AnthropicTool { Provider { id: String, name: String, - args: Value, + args: Map, }, } /// Read a camelCase arg from the `args` object, returning `Value::Null` when /// absent (mirroring the TS `undefined`-when-absent field behaviour). -fn arg(args: &Value, key: &str) -> Value { +fn arg(args: &Map, key: &str) -> Value { args.get(key).cloned().unwrap_or(Value::Null) } @@ -302,10 +302,7 @@ fn prepare_function_tool( def["allowed_callers"] = ac.clone(); } if let Some(ref examples) = tool.input_examples { - let inputs: Vec = examples - .iter() - .map(|e| e.get("input").cloned().unwrap_or(e.clone())) - .collect(); + let inputs: Vec<&Map> = examples.iter().map(|e| &e.input).collect(); def["input_examples"] = json!(inputs); } @@ -323,7 +320,7 @@ fn prepare_function_tool( /// `None` (so the caller emits an "unsupported" warning) when the id is unknown. pub(crate) fn prepare_provider_tool( id: &str, - args: &Value, + args: &Map, betas: &mut BTreeSet, ) -> Option { Some(match id { diff --git a/aimux-providers/src/anthropic/stream.rs b/aimux-providers/src/anthropic/stream.rs index 604222d7a..4f5e8dc71 100644 --- a/aimux-providers/src/anthropic/stream.rs +++ b/aimux-providers/src/anthropic/stream.rs @@ -15,6 +15,8 @@ //! - `parse_anthropic_content` — shared content-block → `GenerateContent` //! mapping used by the non-streaming path. +use aimux_core::tool::RawToolCall; +use aimux_core::tool::ToolResult; use std::collections::HashMap; use std::sync::atomic::{AtomicU64, Ordering}; @@ -399,7 +401,7 @@ pub(crate) fn stream_parts_for_result_block( ) -> Vec { let tool_result = |tool_name: String, (result, is_error): (Value, Option), tool_use_id: &str| { - StreamPart::ToolResult { + StreamPart::ToolResult(ToolResult { tool_call_id: tool_use_id.to_string(), tool_name, result, @@ -407,7 +409,7 @@ pub(crate) fn stream_parts_for_result_block( preliminary: None, dynamic: None, provider_metadata: None, - } + }) }; match block { @@ -417,7 +419,7 @@ pub(crate) fn stream_parts_for_result_block( } => { let name = names.to_custom_tool_name("web_search").to_string(); let Some(results) = content.as_array() else { - return vec![StreamPart::ToolResult { + return vec![StreamPart::ToolResult(ToolResult { tool_call_id: tool_use_id.clone(), tool_name: name, result: json!({ @@ -428,7 +430,7 @@ pub(crate) fn stream_parts_for_result_block( preliminary: None, dynamic: None, provider_metadata: None, - }]; + })]; }; // The tool result, then one Source per hit — the same pair the // non-streaming path emits. @@ -508,7 +510,7 @@ pub(crate) fn stream_parts_for_result_block( is_error, } => { let call = mcp_tool_calls.get(tool_use_id); - vec![StreamPart::ToolResult { + vec![StreamPart::ToolResult(ToolResult { tool_call_id: tool_use_id.clone(), tool_name: call.map(|(name, _)| name.clone()).unwrap_or_default(), result: content.clone(), @@ -520,7 +522,7 @@ pub(crate) fn stream_parts_for_result_block( "anthropic": { "type": "mcp-tool-use", "serverName": server } }) }), - }] + })] } _ => Vec::new(), } @@ -575,15 +577,14 @@ pub(crate) fn parse_anthropic_content( input, caller, } => { - content.push(GenerateContent::ToolCall { + content.push(GenerateContent::ToolCall(RawToolCall { tool_call_id: id.clone(), tool_name: names.to_custom_tool_name(name).to_string(), input: input.to_string(), provider_executed: None, dynamic: None, - thought_signature: None, provider_metadata: tool_call_caller_metadata(caller.as_ref()), - }); + })); } ContentBlock::Thinking { thinking, @@ -600,7 +601,7 @@ pub(crate) fn parse_anthropic_content( // calls so they round-trip on follow-up turns. ContentBlock::ServerToolUse { id, name, input } => { let provider_name = server_tool_provider_name(name); - content.push(GenerateContent::ToolCall { + content.push(GenerateContent::ToolCall(RawToolCall { tool_call_id: id.clone(), tool_name: names.to_custom_tool_name(provider_name).to_string(), input: normalized_server_tool_input(name, input).to_string(), @@ -608,9 +609,8 @@ pub(crate) fn parse_anthropic_content( dynamic: (provider_name == "code_execution" && names.mark_code_execution_dynamic()) .then_some(true), - thought_signature: None, provider_metadata: None, - }); + })); } // MCP tool use — provider-executed + dynamic (upstream :1166-1182). ContentBlock::McpToolUse { @@ -619,17 +619,16 @@ pub(crate) fn parse_anthropic_content( input, server_name, } => { - content.push(GenerateContent::ToolCall { + content.push(GenerateContent::ToolCall(RawToolCall { tool_call_id: id.clone(), tool_name: name.clone(), input: input.to_string(), provider_executed: Some(true), dynamic: Some(true), - thought_signature: None, provider_metadata: Some(json!({ "anthropic": { "type": "mcp-tool-use", "serverName": server_name } })), - }); + })); } // Redacted thinking — upstream emits as reasoning with redactedData ContentBlock::RedactedThinking { data } => { @@ -653,7 +652,7 @@ pub(crate) fn parse_anthropic_content( // object (upstream branches on `Array.isArray`). match payload.as_array() { Some(results) => { - content.push(GenerateContent::ToolResult { + content.push(GenerateContent::ToolResult(ToolResult { tool_call_id: tool_use_id.clone(), tool_name: names.to_custom_tool_name("web_search").to_string(), result: Value::Array( @@ -663,7 +662,7 @@ pub(crate) fn parse_anthropic_content( preliminary: None, dynamic: None, provider_metadata: None, - }); + })); // Each result also becomes a `Source` — that is how the // URLs and titles reach `result.sources`. for result in results { @@ -681,7 +680,7 @@ pub(crate) fn parse_anthropic_content( }); } } - None => content.push(GenerateContent::ToolResult { + None => content.push(GenerateContent::ToolResult(ToolResult { tool_call_id: tool_use_id.clone(), tool_name: names.to_custom_tool_name("web_search").to_string(), result: json!({ @@ -693,7 +692,7 @@ pub(crate) fn parse_anthropic_content( preliminary: None, dynamic: None, provider_metadata: None, - }), + })), } } ContentBlock::WebFetchToolResult { @@ -701,7 +700,7 @@ pub(crate) fn parse_anthropic_content( content: payload, } => { let (result, is_error) = map_web_fetch_result(payload); - content.push(GenerateContent::ToolResult { + content.push(GenerateContent::ToolResult(ToolResult { tool_call_id: tool_use_id.clone(), tool_name: names.to_custom_tool_name("web_fetch").to_string(), result, @@ -709,14 +708,14 @@ pub(crate) fn parse_anthropic_content( preliminary: None, dynamic: None, provider_metadata: None, - }); + })); } ContentBlock::CodeExecutionToolResult { tool_use_id, content: payload, } => { let (result, is_error) = map_code_execution_result(payload); - content.push(GenerateContent::ToolResult { + content.push(GenerateContent::ToolResult(ToolResult { tool_call_id: tool_use_id.clone(), tool_name: names.to_custom_tool_name("code_execution").to_string(), result, @@ -724,7 +723,7 @@ pub(crate) fn parse_anthropic_content( preliminary: None, dynamic: None, provider_metadata: None, - }); + })); } // Upstream shares one arm for these two and passes `content` // through unmapped (anthropic-language-model.ts:1323-1334). @@ -736,7 +735,7 @@ pub(crate) fn parse_anthropic_content( tool_use_id, content: payload, } => { - content.push(GenerateContent::ToolResult { + content.push(GenerateContent::ToolResult(ToolResult { tool_call_id: tool_use_id.clone(), tool_name: names.to_custom_tool_name("code_execution").to_string(), result: payload.clone(), @@ -744,7 +743,7 @@ pub(crate) fn parse_anthropic_content( preliminary: None, dynamic: None, provider_metadata: None, - }); + })); } ContentBlock::ToolSearchToolResult { tool_use_id, @@ -755,7 +754,7 @@ pub(crate) fn parse_anthropic_content( names, server_tool_calls.get(tool_use_id.as_str()).copied(), ); - content.push(GenerateContent::ToolResult { + content.push(GenerateContent::ToolResult(ToolResult { tool_call_id: tool_use_id.clone(), tool_name: names.to_custom_tool_name(provider_name).to_string(), result, @@ -763,14 +762,14 @@ pub(crate) fn parse_anthropic_content( preliminary: None, dynamic: None, provider_metadata: None, - }); + })); } ContentBlock::AdvisorToolResult { tool_use_id, content: payload, } => { let (result, is_error) = map_advisor_result(payload); - content.push(GenerateContent::ToolResult { + content.push(GenerateContent::ToolResult(ToolResult { tool_call_id: tool_use_id.clone(), tool_name: names.to_custom_tool_name("advisor").to_string(), result, @@ -778,7 +777,7 @@ pub(crate) fn parse_anthropic_content( preliminary: None, dynamic: None, provider_metadata: None, - }); + })); } // MCP tool result — dynamic, and it inherits the name and metadata // of the `mcp_tool_use` block it answers (upstream :1184-1194). @@ -788,7 +787,7 @@ pub(crate) fn parse_anthropic_content( is_error, } => { let call = mcp_tool_calls.get(tool_use_id.as_str()); - content.push(GenerateContent::ToolResult { + content.push(GenerateContent::ToolResult(ToolResult { tool_call_id: tool_use_id.clone(), tool_name: call .map(|(name, _)| (*name).to_string()) @@ -802,7 +801,7 @@ pub(crate) fn parse_anthropic_content( "anthropic": { "type": "mcp-tool-use", "serverName": server } }) }), - }); + })); } _ => {} } @@ -1088,22 +1087,19 @@ pub(crate) async fn anthropic_stream_core( ContentBlock::McpToolUse { id, name, input, server_name } => { mcp_tool_calls .insert(id.clone(), (name.clone(), server_name.clone())); - yield Ok(StreamPart::ToolCall { + yield Ok(StreamPart::ToolCall(RawToolCall { tool_call_id: id.clone(), tool_name: name.clone(), - input: Value::String(input.to_string()), + input: input.to_string(), provider_executed: Some(true), dynamic: Some(true), - thought_signature: None, - invalid: None, - error: None, provider_metadata: Some(json!({ "anthropic": { "type": "mcp-tool-use", "serverName": server_name, } })), - }); + })); } // Redacted thinking — emit as ReasoningStart. ContentBlock::RedactedThinking { data } => { @@ -1299,23 +1295,19 @@ pub(crate) async fn anthropic_stream_core( id: id.clone(), provider_metadata: None, }); - let input = - Value::String(finalize_streamed_tool_input( + let input = finalize_streamed_tool_input( accumulated_json, provider_tool_name.as_deref(), provider_tool_input_type.as_deref(), - )); - yield Ok(StreamPart::ToolCall { + ); + yield Ok(StreamPart::ToolCall(RawToolCall { tool_call_id: id, tool_name: name, input, provider_executed, dynamic, - thought_signature: None, - invalid: None, - error: None, provider_metadata, - }); + })); } } } diff --git a/aimux-providers/src/anthropic/tool_name_mapping.rs b/aimux-providers/src/anthropic/tool_name_mapping.rs index adb611521..9bf67eaa0 100644 --- a/aimux-providers/src/anthropic/tool_name_mapping.rs +++ b/aimux-providers/src/anthropic/tool_name_mapping.rs @@ -115,26 +115,9 @@ impl ToolNameMapping { #[cfg(test)] mod tests { use super::*; - use aimux_core::tool::{FunctionTool, ProviderTool}; + use aimux_core::tool::FunctionTool; use serde_json::json; - fn provider_tool(id: &str, name: &str) -> Tool { - Tool::Provider(ProviderTool { - id: id.to_string(), - name: name.to_string(), - args: json!({}), - }) - } - - #[test] - fn maps_renamed_provider_tool_both_ways() { - let tools = vec![provider_tool("anthropic.web_search_20250305", "mySearch")]; - let mapping = ToolNameMapping::new(Some(&tools)); - - assert_eq!(mapping.to_custom_tool_name("web_search"), "mySearch"); - assert_eq!(mapping.to_provider_tool_name("mySearch"), "web_search"); - } - #[test] fn unmapped_names_pass_through() { let mapping = ToolNameMapping::new(None); @@ -149,76 +132,4 @@ mod tests { // A function tool named `web_search` must not hijack the provider name. assert_eq!(mapping.to_custom_tool_name("web_search"), "web_search"); } - - #[test] - fn all_code_execution_versions_share_one_provider_name() { - for id in [ - "anthropic.code_execution_20250522", - "anthropic.code_execution_20250825", - "anthropic.code_execution_20260120", - ] { - let tools = vec![provider_tool(id, "runCode")]; - let mapping = ToolNameMapping::new(Some(&tools)); - assert_eq!( - mapping.to_custom_tool_name("code_execution"), - "runCode", - "{id} must map to the code_execution provider name" - ); - } - } - - #[test] - fn marks_implicit_code_execution_dynamic_only_for_2026_web_tools() { - let web_only = vec![provider_tool("anthropic.web_search_20260209", "search")]; - assert!(ToolNameMapping::new(Some(&web_only)).mark_code_execution_dynamic()); - - let web_and_code = vec![ - provider_tool("anthropic.web_search_20260209", "search"), - provider_tool("anthropic.code_execution_20260120", "runCode"), - ]; - assert!(!ToolNameMapping::new(Some(&web_and_code)).mark_code_execution_dynamic()); - - let old_web = vec![provider_tool("anthropic.web_search_20250305", "search")]; - assert!(!ToolNameMapping::new(Some(&old_web)).mark_code_execution_dynamic()); - } - - /// Every provider tool id this crate can build a request for must also be - /// mappable, and to the same name the request body uses. The two tables are - /// written out separately, so without this they drift silently: a renamed - /// tool would round-trip under the wrong name. - #[test] - fn every_known_provider_tool_id_maps_to_its_request_name() { - use crate::anthropic::prepare_tools::prepare_provider_tool; - use std::collections::BTreeSet; - - // Ids are recovered from the source rather than duplicated here, so a - // newly supported tool cannot be added without this test seeing it. - let src = include_str!("prepare_tools.rs"); - let ids: BTreeSet<&str> = src - .match_indices("\"anthropic.") - .filter_map(|(i, _)| { - let rest = &src[i + 1..]; - rest.find('"').map(|end| &rest[..end]) - }) - .collect(); - assert!( - ids.len() > 10, - "expected to recover the id table, got {ids:?}" - ); - - for id in ids { - let mut betas = BTreeSet::new(); - let Some(def) = prepare_provider_tool(id, &json!({}), &mut betas) else { - continue; - }; - let request_name = def["name"].as_str().expect("provider tool has a name"); - let tools = vec![provider_tool(id, "myCustomName")]; - let mapping = ToolNameMapping::new(Some(&tools)); - assert_eq!( - mapping.to_custom_tool_name(request_name), - "myCustomName", - "{id} is sent as `{request_name}` but is not in the name mapping" - ); - } - } } diff --git a/aimux-providers/src/bedrock/convert.rs b/aimux-providers/src/bedrock/convert.rs index 9b7d8dc5c..2b5842dc4 100644 --- a/aimux-providers/src/bedrock/convert.rs +++ b/aimux-providers/src/bedrock/convert.rs @@ -453,7 +453,7 @@ pub fn supports_strict_tools(model_id: &str) -> bool { #[must_use] pub fn prepare_tools( tools: &Option>, - tool_choice: &ToolChoice, + tool_choice: Option<&ToolChoice>, model_id: &str, ) -> Value { let non_empty = tools.as_ref().filter(|t| !t.is_empty()); @@ -462,13 +462,15 @@ pub fn prepare_tools( }; // `toolChoice: none` clears tools entirely (matches TS). - if matches!(tool_choice, ToolChoice::None) { + if matches!(tool_choice, Some(ToolChoice::None)) { return json!({}); } // `toolChoice: tool` filters function tools to the named one. let filtered: Vec<&FunctionTool> = match tool_choice { - ToolChoice::Tool { tool_name } => tools.iter().filter(|t| &t.name == tool_name).collect(), + Some(ToolChoice::Tool { tool_name }) => { + tools.iter().filter(|t| &t.name == tool_name).collect() + } _ => tools.iter().collect(), }; @@ -498,10 +500,11 @@ pub fn prepare_tools( } let tool_choice_val = match tool_choice { - ToolChoice::Auto => json!({ "auto": {} }), - ToolChoice::Required => json!({ "any": {} }), - ToolChoice::Tool { tool_name } => json!({ "tool": { "name": tool_name } }), - ToolChoice::None => unreachable!(), + Some(ToolChoice::Auto) => json!({ "auto": {} }), + Some(ToolChoice::Required) => json!({ "any": {} }), + Some(ToolChoice::Tool { tool_name }) => json!({ "tool": { "name": tool_name } }), + Some(ToolChoice::None) => unreachable!(), + None => return json!({ "tools": tool_specs }), }; json!({ "tools": tool_specs, "toolChoice": tool_choice_val }) @@ -603,7 +606,7 @@ pub fn build_request_body(model_id: &str, options: &CallOptions) -> Value { }) .collect() }); - let tool_config = prepare_tools(&function_tools, &options.tool_choice, model_id); + let tool_config = prepare_tools(&function_tools, options.tool_choice.as_ref(), model_id); if tool_config .as_object() .map(|o| !o.is_empty()) diff --git a/aimux-providers/src/bedrock/model.rs b/aimux-providers/src/bedrock/model.rs index b1c0c96ec..8d515761c 100644 --- a/aimux-providers/src/bedrock/model.rs +++ b/aimux-providers/src/bedrock/model.rs @@ -5,6 +5,7 @@ //! across all Bedrock-backed models (Anthropic Claude, Meta Llama, Mistral, //! etc.). +use aimux_core::tool::RawToolCall; use std::collections::HashMap; use async_trait::async_trait; @@ -480,22 +481,19 @@ impl LanguageModel for BedrockModel { yield Ok(StreamPart::ToolInputEnd { id: id.clone(), provider_metadata: None}); // Empty input normalizes to "{}" per the upstream // provider. - let input = serde_json::Value::String(if acc.is_empty() { + let input = if acc.is_empty() { "{}".to_string() } else { acc - }); - yield Ok(StreamPart::ToolCall { + }; + yield Ok(StreamPart::ToolCall(RawToolCall { tool_call_id: id, tool_name: name, input, provider_executed: None, dynamic: None, - thought_signature: None, - invalid: None, - error: None, provider_metadata: None, - }); + })); } else if reasoning_id.is_some() { let id = idx.to_string(); if reasoning_id.as_deref() == Some(id.as_str()) { @@ -649,15 +647,14 @@ fn extract_content(block: &BedrockContentBlock, content: &mut Vec GooglePrompt { convert_to_google_messages_for_namespace(prompt, ProviderMetadataNamespace::Google) @@ -261,7 +260,6 @@ fn convert_assistant_parts( tool_call_id, tool_name, input, - thought_signature, provider_options, .. } => { @@ -272,11 +270,9 @@ fn convert_assistant_parts( let server_tool_type = google_options .and_then(|options| options.get("serverToolType")) .and_then(|value| value.as_str()); - let signature = thought_signature.as_deref().or_else(|| { - google_options - .and_then(|options| options.get("thoughtSignature")) - .and_then(|value| value.as_str()) - }); + let signature = google_options + .and_then(|options| options.get("thoughtSignature")) + .and_then(|value| value.as_str()); let mut part_value = if let (Some(server_id), Some(server_type)) = (server_tool_call_id, server_tool_type) @@ -421,7 +417,10 @@ pub struct PreparedTools { /// tools (`google_search`, `code_execution`, …) are out of scope for the Rust /// port — only `FunctionTool`s are supported. #[must_use] -pub fn prepare_tools(tools: &Option>, tool_choice: &ToolChoice) -> PreparedTools { +pub fn prepare_tools( + tools: &Option>, + tool_choice: Option<&ToolChoice>, +) -> PreparedTools { // Coerce empty arrays to None (matches TS `tools?.length ? tools : undefined`). let non_empty = tools.as_ref().filter(|&t| !t.is_empty()); @@ -454,22 +453,23 @@ pub fn prepare_tools(tools: &Option>, tool_choice: &ToolChoice ]); let tool_config = match tool_choice { - ToolChoice::Auto => { + None => has_strict.then(|| json!({ "functionCallingConfig": { "mode": "VALIDATED" } })), + Some(ToolChoice::Auto) => { if has_strict { Some(json!({ "functionCallingConfig": { "mode": "VALIDATED" } })) } else { Some(json!({ "functionCallingConfig": { "mode": "AUTO" } })) } } - ToolChoice::None => Some(json!({ "functionCallingConfig": { "mode": "NONE" } })), - ToolChoice::Required => { + Some(ToolChoice::None) => Some(json!({ "functionCallingConfig": { "mode": "NONE" } })), + Some(ToolChoice::Required) => { if has_strict { Some(json!({ "functionCallingConfig": { "mode": "VALIDATED" } })) } else { Some(json!({ "functionCallingConfig": { "mode": "ANY" } })) } } - ToolChoice::Tool { tool_name } => { + Some(ToolChoice::Tool { tool_name }) => { if has_strict { Some(json!({ "functionCallingConfig": { @@ -612,7 +612,7 @@ pub struct PreparedToolsWithWarnings { #[must_use] pub fn prepare_all_tools( tools: &Option>, - tool_choice: &ToolChoice, + tool_choice: Option<&ToolChoice>, model_id: &str, ) -> PreparedToolsWithWarnings { let caps = get_google_model_capabilities(model_id); @@ -656,23 +656,23 @@ pub fn prepare_all_tools( let mut combined_config = Map::new(); let fc = match tool_choice { - ToolChoice::None => { + Some(ToolChoice::None) => { let mut m = Map::new(); m.insert("mode".to_string(), json!("NONE")); m } - ToolChoice::Required => { + Some(ToolChoice::Required) => { let mut m = Map::new(); m.insert("mode".to_string(), json!("ANY")); m } - ToolChoice::Tool { tool_name } => { + Some(ToolChoice::Tool { tool_name }) => { let mut m = Map::new(); m.insert("mode".to_string(), json!("ANY")); m.insert("allowedFunctionNames".to_string(), json!([tool_name])); m } - ToolChoice::Auto => { + None | Some(ToolChoice::Auto) => { let mut m = Map::new(); m.insert("mode".to_string(), json!("VALIDATED")); m @@ -1099,7 +1099,7 @@ fn build_request_body_with_warnings_for_namespace( ); } - let prepared = prepare_all_tools(&options.tools, &options.tool_choice, model_id); + let prepared = prepare_all_tools(&options.tools, options.tool_choice.as_ref(), model_id); if let Some(tools) = prepared.tools { body.insert("tools".to_string(), Value::Array(tools)); } @@ -1322,7 +1322,6 @@ mod tests { 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 { @@ -1364,7 +1363,6 @@ mod tests { tool_name: "weather".to_string(), input: json!({}), provider_executed: None, - thought_signature: None, provider_options: Some(json!({ "googleVertex": { "thoughtSignature": "google-vertex" }, "vertex": { "thoughtSignature": "vertex" }, @@ -1392,7 +1390,6 @@ mod tests { tool_name: "weather".to_string(), input: json!({}), provider_executed: None, - thought_signature: None, provider_options: Some(json!({ "google": { "thoughtSignature": "gateway-signature" }, })), diff --git a/aimux-providers/src/google/model.rs b/aimux-providers/src/google/model.rs index aaa923a58..8100748c4 100644 --- a/aimux-providers/src/google/model.rs +++ b/aimux-providers/src/google/model.rs @@ -1,5 +1,7 @@ //! Google Gemini language model — implements `LanguageModel`. +use aimux_core::tool::RawToolCall; +use aimux_core::tool::ToolResult; use std::collections::HashMap; use async_trait::async_trait; @@ -412,10 +414,6 @@ impl LanguageModel for GoogleModel { .unwrap_or_else(|| format!("call-{block_counter}")); block_counter += 1; let args = fc.get("args").cloned().unwrap_or(json!({})); - let thought_signature = part - .get("thoughtSignature") - .and_then(|v| v.as_str()) - .map(std::string::ToString::to_string); yield Ok(StreamPart::ToolInputStart { id: id.clone(), @@ -423,26 +421,23 @@ impl LanguageModel for GoogleModel { provider_executed: None, dynamic: None, title: None, - provider_metadata: None, + provider_metadata: thought_sig_meta.clone(), }); let args_str = args.to_string(); yield Ok(StreamPart::ToolInputDelta { id: id.clone(), delta: args_str, - provider_metadata: None, + provider_metadata: thought_sig_meta.clone(), }); - yield Ok(StreamPart::ToolInputEnd { id: id.clone(), provider_metadata: None}); - yield Ok(StreamPart::ToolCall { + yield Ok(StreamPart::ToolInputEnd { id: id.clone(), provider_metadata: thought_sig_meta.clone()}); + yield Ok(StreamPart::ToolCall(RawToolCall { tool_call_id: id, tool_name: name.to_string(), - input: Value::String(args.to_string()), + input: args.to_string(), provider_executed: None, dynamic: None, - thought_signature, - invalid: None, - error: None, provider_metadata: thought_sig_meta.clone(), - }); + })); has_tool_calls = true; } else if let Some(ec) = part.get("executableCode") { // Provider-executed code execution. @@ -455,21 +450,18 @@ impl LanguageModel for GoogleModel { let id = format!("call-{block_counter}"); block_counter += 1; last_code_execution_tool_call_id = Some(id.clone()); - yield Ok(StreamPart::ToolCall { + yield Ok(StreamPart::ToolCall(RawToolCall { tool_call_id: id.clone(), tool_name: code_execution_tool_name.clone(), - input: Value::String(ec.to_string()), + input: ec.to_string(), provider_executed: Some(true), dynamic: None, - thought_signature: None, - invalid: None, - error: None, provider_metadata: Some(server_tool_metadata( &id, "code_execution", None, )), - }); + })); // provider-executed → does NOT set has_tool_calls } } else if let Some(cer) = part.get("codeExecutionResult") { @@ -487,7 +479,7 @@ impl LanguageModel for GoogleModel { .and_then(|v| v.as_str()) .map(std::string::ToString::to_string) .unwrap_or_default(); - yield Ok(StreamPart::ToolResult { + yield Ok(StreamPart::ToolResult(ToolResult { tool_call_id: call_id.clone(), tool_name: code_execution_tool_name.clone(), result: json!({ "outcome": outcome, "output": output }), @@ -499,7 +491,7 @@ impl LanguageModel for GoogleModel { "code_execution", None, )), - }); + })); } } else if let Some(tc) = part.get("toolCall") { // Server-side tool call (provider-executed). @@ -521,17 +513,14 @@ impl LanguageModel for GoogleModel { if let Some(s) = part.get("thoughtSignature").and_then(|v| v.as_str()) { server_meta["google"]["thoughtSignature"] = json!(s); } - yield Ok(StreamPart::ToolCall { + yield Ok(StreamPart::ToolCall(RawToolCall { tool_call_id: id, tool_name: format!("server:{tool_type}"), - input: Value::String(args.to_string()), + input: args.to_string(), provider_executed: Some(true), dynamic: Some(true), - thought_signature: None, - invalid: None, - error: None, provider_metadata: Some(server_meta), - }); + })); // provider-executed → does NOT set has_tool_calls } else if let Some(tr) = part.get("toolResponse") { // Server-side tool response. @@ -553,7 +542,7 @@ impl LanguageModel for GoogleModel { if let Some(s) = part.get("thoughtSignature").and_then(|v| v.as_str()) { server_meta["google"]["thoughtSignature"] = json!(s); } - yield Ok(StreamPart::ToolResult { + yield Ok(StreamPart::ToolResult(ToolResult { tool_call_id: id, tool_name: format!("server:{tool_type}"), result: response, @@ -561,7 +550,7 @@ impl LanguageModel for GoogleModel { preliminary: None, dynamic: None, provider_metadata: Some(server_meta), - }); + })); } else if let Some(inline) = part.get("inlineData") { // File output — upstream :847-877. // Close any open text/reasoning block before file. @@ -735,15 +724,14 @@ fn extract_content_from_candidate( if has_code { let id = format!("call-{}", content.len()); last_code_execution_tool_call_id = Some(id.clone()); - content.push(GenerateContent::ToolCall { + content.push(GenerateContent::ToolCall(RawToolCall { tool_call_id: id.clone(), tool_name: code_execution_tool_name.to_string(), input: ec.to_string(), provider_executed: Some(true), dynamic: None, - thought_signature: None, provider_metadata: Some(server_tool_metadata(&id, "code_execution", None)), - }); + })); } } else if let Some(cer) = part.get("codeExecutionResult") { // One executableCode may be followed by multiple results. @@ -754,7 +742,7 @@ fn extract_content_from_candidate( .and_then(|v| v.as_str()) .map(std::string::ToString::to_string) .unwrap_or_default(); - content.push(GenerateContent::ToolResult { + content.push(GenerateContent::ToolResult(ToolResult { tool_call_id: call_id.clone(), tool_name: code_execution_tool_name.to_string(), result: json!({ "outcome": outcome, "output": output }), @@ -766,7 +754,7 @@ fn extract_content_from_candidate( "code_execution", None, )), - }); + })); } } else if let Some(text) = part.get("text").and_then(|v| v.as_str()) { if text.is_empty() { @@ -804,19 +792,14 @@ fn extract_content_from_candidate( .unwrap_or("") .to_string(); let input = fc.get("args").cloned().unwrap_or(json!({})); - let thought_signature = part - .get("thoughtSignature") - .and_then(|v| v.as_str()) - .map(std::string::ToString::to_string); - content.push(GenerateContent::ToolCall { + content.push(GenerateContent::ToolCall(RawToolCall { tool_call_id: id, tool_name: name, input: input.to_string(), provider_executed: None, dynamic: None, - thought_signature, provider_metadata: thought_sig_meta.clone(), - }); + })); has_tool_calls = true; } else if let Some(inline) = part.get("inlineData") { // File output (e.g. gemini-2.5-flash-image) — upstream :478-490. @@ -844,25 +827,20 @@ fn extract_content_from_candidate( .to_string(); last_server_tool_call_id = Some(id.clone()); let input = tc.get("args").cloned().unwrap_or(json!({})); - let thought_signature = part - .get("thoughtSignature") - .and_then(|v| v.as_str()) - .map(std::string::ToString::to_string); let mut server_meta = json!({ "google": { "serverToolCallId": id, "serverToolType": tool_type } }); if let Some(s) = part.get("thoughtSignature").and_then(|v| v.as_str()) { server_meta["google"]["thoughtSignature"] = json!(s); } - content.push(GenerateContent::ToolCall { + content.push(GenerateContent::ToolCall(RawToolCall { tool_call_id: id, tool_name: format!("server:{tool_type}"), input: input.to_string(), provider_executed: Some(true), dynamic: Some(true), - thought_signature, provider_metadata: Some(server_meta), - }); + })); // provider-executed → does NOT set has_tool_calls } else if let Some(tr) = part.get("toolResponse") { // Server-side tool response (upstream :512-533). @@ -882,7 +860,7 @@ fn extract_content_from_candidate( if let Some(s) = part.get("thoughtSignature").and_then(|v| v.as_str()) { server_meta["google"]["thoughtSignature"] = json!(s); } - content.push(GenerateContent::ToolResult { + content.push(GenerateContent::ToolResult(ToolResult { tool_call_id: id, tool_name: format!("server:{tool_type}"), result: response, @@ -890,7 +868,7 @@ fn extract_content_from_candidate( preliminary: None, dynamic: None, provider_metadata: Some(server_meta), - }); + })); last_server_tool_call_id = None; } } @@ -913,18 +891,18 @@ fn set_provider_metadata(item: &mut GenerateContent, meta: ProviderMetadata) { | GenerateContent::Reasoning { provider_metadata, .. } - | GenerateContent::ToolCall { + | GenerateContent::ToolCall(RawToolCall { provider_metadata, .. - } + }) | GenerateContent::File { provider_metadata, .. } | GenerateContent::Source { provider_metadata, .. } - | GenerateContent::ToolResult { + | GenerateContent::ToolResult(ToolResult { provider_metadata, .. - } => { + }) => { *provider_metadata = Some(meta); } } diff --git a/aimux-providers/src/huggingface/responses.rs b/aimux-providers/src/huggingface/responses.rs index 2c8ccd8c9..3ff7391f6 100644 --- a/aimux-providers/src/huggingface/responses.rs +++ b/aimux-providers/src/huggingface/responses.rs @@ -14,6 +14,8 @@ //! - `map-huggingface-responses-finish-reason.ts` — finish-reason mapping //! - `huggingface-responses-language-model-options.ts` — provider options +use aimux_core::tool::RawToolCall; +use aimux_core::tool::ToolResult; use std::collections::HashMap; use async_trait::async_trait; @@ -419,28 +421,25 @@ impl LanguageModel for HuggingFaceResponsesModel { .get("arguments") .and_then(|v| v.as_str()) .unwrap_or("{}"); - let input = Value::String(arguments.to_string()); + let input = arguments.to_string(); yield Ok(StreamPart::ToolInputEnd { id: call_id.clone(), provider_metadata: None, }); - yield Ok(StreamPart::ToolCall { + yield Ok(StreamPart::ToolCall(RawToolCall { tool_call_id: call_id.clone(), tool_name: name.clone(), input, provider_executed: None, dynamic: None, - thought_signature: None, - invalid: None, - error: None, provider_metadata: None, - }); + })); if let Some(output) = item.get("output").and_then(|v| v.as_str()) { - yield Ok(StreamPart::ToolResult { + yield Ok(StreamPart::ToolResult(ToolResult { tool_call_id: call_id, tool_name: name.clone(), result: Value::String(output.to_string()), @@ -448,7 +447,7 @@ impl LanguageModel for HuggingFaceResponsesModel { preliminary: None, dynamic: None, provider_metadata: None, - }); + })); } } _ => {} @@ -643,7 +642,7 @@ pub fn build_request_body_with_warnings( // Prepare tools. let (tools, tool_choice, tool_warnings) = - prepare_responses_tools(&options.tools, &options.tool_choice); + prepare_responses_tools(&options.tools, options.tool_choice.as_ref()); warnings.extend(tool_warnings); // Assemble the body. Key insertion order follows the TS `baseArgs` object @@ -1018,7 +1017,7 @@ fn detect_media_type_from_base64(data: &str, top_level: &str) -> Option #[must_use] pub fn prepare_responses_tools( tools: &Option>, - tool_choice: &ToolChoice, + tool_choice: Option<&ToolChoice>, ) -> (Option>, Option, Vec) { let tools = match tools { Some(t) if !t.is_empty() => t, @@ -1050,10 +1049,10 @@ pub fn prepare_responses_tools( } let mapped_tool_choice = match tool_choice { - ToolChoice::Auto => Some(json!("auto")), - ToolChoice::Required => Some(json!("required")), - ToolChoice::None => None, // not supported, ignore - ToolChoice::Tool { tool_name } => Some(json!({ + Some(ToolChoice::Auto) => Some(json!("auto")), + Some(ToolChoice::Required) => Some(json!("required")), + None | Some(ToolChoice::None) => None, // not supported, ignore + Some(ToolChoice::Tool { tool_name }) => Some(json!({ "type": "function", "function": { "name": tool_name } })), @@ -1140,15 +1139,14 @@ fn build_generate_content(response: &Value) -> Result, AiMu .and_then(|v| v.as_str()) .unwrap_or("{}"); let input = arguments.to_string(); - content.push(GenerateContent::ToolCall { + content.push(GenerateContent::ToolCall(RawToolCall { tool_call_id: call_id.to_string(), tool_name: name.to_string(), input, provider_executed: None, dynamic: None, - thought_signature: None, provider_metadata: None, - }); + })); } "mcp_call" => { @@ -1159,18 +1157,17 @@ fn build_generate_content(response: &Value) -> Result, AiMu .and_then(|v| v.as_str()) .unwrap_or("{}"); let input = arguments.to_string(); - content.push(GenerateContent::ToolCall { + content.push(GenerateContent::ToolCall(RawToolCall { tool_call_id: id.to_string(), tool_name: name.to_string(), input, provider_executed: Some(true), dynamic: Some(true), - thought_signature: None, provider_metadata: None, - }); + })); if let Some(output) = part.get("output").filter(|v| !v.is_null()) { - content.push(GenerateContent::ToolResult { + content.push(GenerateContent::ToolResult(ToolResult { tool_call_id: id.to_string(), tool_name: name.to_string(), result: output.clone(), @@ -1178,25 +1175,24 @@ fn build_generate_content(response: &Value) -> Result, AiMu preliminary: None, dynamic: Some(true), provider_metadata: None, - }); + })); } } "mcp_list_tools" => { let id = part.get("id").and_then(|v| v.as_str()).unwrap_or(""); let server_label = part.get("server_label").cloned().unwrap_or(Value::Null); - content.push(GenerateContent::ToolCall { + content.push(GenerateContent::ToolCall(RawToolCall { tool_call_id: id.to_string(), tool_name: "list_tools".to_string(), input: json!({ "server_label": server_label }).to_string(), provider_executed: Some(true), dynamic: Some(true), - thought_signature: None, provider_metadata: None, - }); + })); if let Some(tools) = part.get("tools").filter(|v| !v.is_null()) { - content.push(GenerateContent::ToolResult { + content.push(GenerateContent::ToolResult(ToolResult { tool_call_id: id.to_string(), tool_name: "list_tools".to_string(), result: json!({ "tools": tools }), @@ -1204,7 +1200,7 @@ fn build_generate_content(response: &Value) -> Result, AiMu preliminary: None, dynamic: Some(true), provider_metadata: None, - }); + })); } } diff --git a/aimux-providers/src/mistral/convert.rs b/aimux-providers/src/mistral/convert.rs index 88d737c86..a224a99c9 100644 --- a/aimux-providers/src/mistral/convert.rs +++ b/aimux-providers/src/mistral/convert.rs @@ -356,7 +356,7 @@ pub fn build_request_body(model_id: &str, options: &CallOptions, stream: bool) - }) .collect() }); - let prepared = prepare_tools(&function_tools, Some(&options.tool_choice)); + let prepared = prepare_tools(&function_tools, options.tool_choice.as_ref()); if let Some(tools) = prepared.tools { body["tools"] = json!(tools); if let Some(tc) = prepared.tool_choice { diff --git a/aimux-providers/src/mistral/model.rs b/aimux-providers/src/mistral/model.rs index 52d63e874..ddcc02018 100644 --- a/aimux-providers/src/mistral/model.rs +++ b/aimux-providers/src/mistral/model.rs @@ -10,6 +10,7 @@ //! - Usage supports `num_cached_tokens` / `prompt_tokens_details.cached_tokens`. //! - Finish reasons include `model_length`. +use aimux_core::tool::RawToolCall; use std::collections::HashMap; use async_trait::async_trait; @@ -275,15 +276,14 @@ impl LanguageModel for MistralModel { if let Some(tool_calls) = choice.message.tool_calls { for tc in tool_calls { let input = tc.function.arguments; - content.push(GenerateContent::ToolCall { + content.push(GenerateContent::ToolCall(RawToolCall { tool_call_id: tc.id, tool_name: tc.function.name, input, provider_executed: None, dynamic: None, - thought_signature: None, provider_metadata: None, - }); + })); } } @@ -520,18 +520,15 @@ impl LanguageModel for MistralModel { provider_metadata: None, }); - let input = Value::String(args); - yield Ok(StreamPart::ToolCall { + let input = args; + yield Ok(StreamPart::ToolCall(RawToolCall { tool_call_id: tool_id, tool_name, input, provider_executed: None, dynamic: None, - thought_signature: None, - invalid: None, - error: None, provider_metadata: None, - }); + })); } } diff --git a/aimux-providers/src/open_responses.rs b/aimux-providers/src/open_responses.rs index 340237e52..27cc52115 100644 --- a/aimux-providers/src/open_responses.rs +++ b/aimux-providers/src/open_responses.rs @@ -6,6 +6,7 @@ //! //! Translation of `reference/ai/packages/open-responses/src/responses/`. +use aimux_core::tool::RawToolCall; use std::collections::HashMap; use std::sync::Arc; @@ -396,15 +397,14 @@ impl LanguageModel for OpenResponsesModel { .and_then(|a| a.as_str()) .unwrap_or("{}"); let input = arguments.to_string(); - content.push(GenerateContent::ToolCall { + content.push(GenerateContent::ToolCall(RawToolCall { tool_call_id: call_id, tool_name: name, input, provider_executed: None, dynamic: None, - thought_signature: None, provider_metadata: None, - }); + })); } _ => {} } @@ -681,18 +681,15 @@ impl LanguageModel for OpenResponsesModel { .map(std::string::ToString::to_string) }) .unwrap_or_default(); - let input = Value::String(arguments); - yield Ok(StreamPart::ToolCall { + let input = arguments; + yield Ok(StreamPart::ToolCall(RawToolCall { tool_call_id, tool_name, input, provider_executed: None, dynamic: None, - thought_signature: None, - invalid: None, - error: None, provider_metadata: None, - }); + })); has_tool_calls = true; } "reasoning" => { @@ -931,15 +928,12 @@ fn build_request_body( }) .unwrap_or_default(); - // Convert tool choice. - // Only emit when not the default (Auto) — Rust's ToolChoice::Auto is the - // default and indistinguishable from "not set", matching the TS behavior - // where undefined toolChoice is omitted from the body. let converted_tool_choice: Option = match &options.tool_choice { - ToolChoice::Auto => None, - ToolChoice::None => Some(json!("none")), - ToolChoice::Required => Some(json!("required")), - ToolChoice::Tool { tool_name } => Some(json!({ + None => None, + Some(ToolChoice::Auto) => Some(json!("auto")), + Some(ToolChoice::None) => Some(json!("none")), + Some(ToolChoice::Required) => Some(json!("required")), + Some(ToolChoice::Tool { tool_name }) => Some(json!({ "type": "function", "name": tool_name, })), diff --git a/aimux-providers/src/openai/convert.rs b/aimux-providers/src/openai/convert.rs index 4c018dba1..aab1998e6 100644 --- a/aimux-providers/src/openai/convert.rs +++ b/aimux-providers/src/openai/convert.rs @@ -1360,11 +1360,11 @@ fn apply_tools( prepare_tools_groq( &function_tools, options.tools.as_ref(), - Some(&options.tool_choice), + options.tool_choice.as_ref(), model_id, ) } else { - prepare_tools(&function_tools, Some(&options.tool_choice)) + prepare_tools(&function_tools, options.tool_choice.as_ref()) }; if let Some(tools) = prepared.tools { diff --git a/aimux-providers/src/openai/model.rs b/aimux-providers/src/openai/model.rs index 107f984ee..890335938 100644 --- a/aimux-providers/src/openai/model.rs +++ b/aimux-providers/src/openai/model.rs @@ -7,6 +7,7 @@ //! speak the OpenAI chat-completions wire format (notably Azure OpenAI) reuse //! the conversion + streaming logic while supplying their own URL and auth. +use aimux_core::tool::RawToolCall; use std::collections::HashMap; use async_trait::async_trait; @@ -319,15 +320,14 @@ pub async fn execute_generate( if let Some(tool_calls) = choice.message.tool_calls { for tc in tool_calls { let input = tc.function.arguments; - content.push(GenerateContent::ToolCall { + content.push(GenerateContent::ToolCall(RawToolCall { tool_call_id: tc.id, tool_name: tc.function.name, input, provider_executed: None, dynamic: None, - thought_signature: None, provider_metadata: None, - }); + })); } } // Parse annotations (URL citations) → Source content items. diff --git a/aimux-providers/src/openai/responses/convert.rs b/aimux-providers/src/openai/responses/convert.rs index fa7c99f7d..4f0aad772 100644 --- a/aimux-providers/src/openai/responses/convert.rs +++ b/aimux-providers/src/openai/responses/convert.rs @@ -922,7 +922,7 @@ pub fn build_responses_request_body( ); // -- Tools -- - let prepared = prepare_responses_tools(&options.tools, Some(&options.tool_choice)); + let prepared = prepare_responses_tools(&options.tools, options.tool_choice.as_ref()); if let Some(tools) = prepared.tools { body["tools"] = json!(tools); if let Some(tc) = prepared.tool_choice { diff --git a/aimux-providers/src/openai/responses/responses_convert.rs b/aimux-providers/src/openai/responses/responses_convert.rs index ea51c2478..cbd5dcb17 100644 --- a/aimux-providers/src/openai/responses/responses_convert.rs +++ b/aimux-providers/src/openai/responses/responses_convert.rs @@ -13,6 +13,7 @@ //! genuinely different streaming loops are **not** force-merged into one //! function — only the shared framework is extracted. +use aimux_core::tool::RawToolCall; use std::collections::HashMap; use std::pin::Pin; @@ -190,19 +191,18 @@ pub fn build_responses_generate_result( .unwrap_or("{}") .to_string(); let input = arguments; - content.push(GenerateContent::ToolCall { + content.push(GenerateContent::ToolCall(RawToolCall { tool_call_id: call_id, tool_name: name, input, provider_executed: None, dynamic: None, - thought_signature: None, provider_metadata: Some(json!({ (provider_key.clone()): { "itemId": part.get("id").cloned().unwrap_or(Value::Null), } })), - }); + })); } Some("custom_tool_call") => { has_function_call = true; @@ -219,19 +219,18 @@ pub fn build_responses_generate_result( let input_str = part.get("input").and_then(|v| v.as_str()).unwrap_or("{}"); let input = serde_json::to_string(input_str) .expect("serializing a custom-tool input string cannot fail"); - content.push(GenerateContent::ToolCall { + content.push(GenerateContent::ToolCall(RawToolCall { tool_call_id: call_id, tool_name: name, input, provider_executed: None, dynamic: None, - thought_signature: None, provider_metadata: Some(json!({ (provider_key.clone()): { "itemId": part.get("id").cloned().unwrap_or(Value::Null), } })), - }); + })); } Some("reasoning") => { let summary = part.get("summary").and_then(|v| v.as_array()); @@ -793,18 +792,15 @@ where id: call_id.clone(), provider_metadata: None, }); - let input = Value::String(arguments); - yield Ok(StreamPart::ToolCall { + let input = arguments; + yield Ok(StreamPart::ToolCall(RawToolCall { tool_call_id: call_id, tool_name: name, input, provider_executed: None, dynamic: None, - thought_signature: None, - invalid: None, - error: None, provider_metadata: None, - }); + })); } "custom_tool_call" => { has_function_call = true; @@ -827,22 +823,18 @@ where id: call_id.clone(), provider_metadata: None, }); - let input = Value::String( + let input = serde_json::to_string(input_str).expect( "serializing a custom-tool input string cannot fail", - ), - ); - yield Ok(StreamPart::ToolCall { + ); + yield Ok(StreamPart::ToolCall(RawToolCall { tool_call_id: call_id, tool_name: name, input, provider_executed: None, dynamic: None, - thought_signature: None, - invalid: None, - error: None, provider_metadata: None, - }); + })); } "reasoning" => { let id = item diff --git a/aimux-providers/src/vertex/anthropic_model.rs b/aimux-providers/src/vertex/anthropic_model.rs index f707441e8..4856cf9dd 100644 --- a/aimux-providers/src/vertex/anthropic_model.rs +++ b/aimux-providers/src/vertex/anthropic_model.rs @@ -14,6 +14,7 @@ //! //! Reference: +use aimux_core::tool::RawToolCall; use std::collections::HashMap; use async_trait::async_trait; @@ -386,22 +387,19 @@ impl LanguageModel for VertexAnthropicModel { ContentBlock::McpToolUse { id, name, input, server_name } => { mcp_tool_calls .insert(id.clone(), (name.clone(), server_name.clone())); - yield Ok(StreamPart::ToolCall { + yield Ok(StreamPart::ToolCall(RawToolCall { tool_call_id: id.clone(), tool_name: name.clone(), - input: Value::String(input.to_string()), + input: input.to_string(), provider_executed: Some(true), dynamic: Some(true), - thought_signature: None, - invalid: None, - error: None, provider_metadata: Some(json!({ "anthropic": { "type": "mcp-tool-use", "serverName": server_name, } })), - }); + })); } ContentBlock::RedactedThinking { data } => { yield Ok(StreamPart::ReasoningStart { @@ -535,23 +533,19 @@ impl LanguageModel for VertexAnthropicModel { .. } => { yield Ok(StreamPart::ToolInputEnd { id: id.clone(), provider_metadata: None}); - let input = - Value::String(finalize_streamed_tool_input( + let input = finalize_streamed_tool_input( accumulated_json, provider_tool_name.as_deref(), provider_tool_input_type.as_deref(), - )); - yield Ok(StreamPart::ToolCall { + ); + yield Ok(StreamPart::ToolCall(RawToolCall { tool_call_id: id, tool_name: name, input, provider_executed, dynamic, - thought_signature: None, - invalid: None, - error: None, provider_metadata, - }); + })); } } } diff --git a/aimux-providers/src/vertex/model.rs b/aimux-providers/src/vertex/model.rs index e151adfdb..4b2366946 100644 --- a/aimux-providers/src/vertex/model.rs +++ b/aimux-providers/src/vertex/model.rs @@ -4,6 +4,8 @@ //! [`crate::google::types`] response types. Only the endpoint construction and //! authentication differ from the public Gemini API provider. +use aimux_core::tool::RawToolCall; +use aimux_core::tool::ToolResult; use std::collections::HashMap; use async_trait::async_trait; @@ -433,17 +435,14 @@ impl LanguageModel for VertexModel { id: id.clone(), provider_metadata: tool_metadata.clone(), }); - yield Ok(StreamPart::ToolCall { + yield Ok(StreamPart::ToolCall(RawToolCall { tool_call_id: id, tool_name: name.to_string(), - input: Value::String(args.to_string()), + input: args.to_string(), provider_executed: None, dynamic: None, - thought_signature, - invalid: None, - error: None, provider_metadata: tool_metadata, - }); + })); has_tool_calls = true; } else if let Some(ec) = part.get("executableCode") { // Provider-executed code execution. @@ -456,21 +455,18 @@ impl LanguageModel for VertexModel { let id = format!("call-{block_counter}"); block_counter += 1; last_code_execution_tool_call_id = Some(id.clone()); - yield Ok(StreamPart::ToolCall { + yield Ok(StreamPart::ToolCall(RawToolCall { tool_call_id: id.clone(), tool_name: code_execution_tool_name.clone(), - input: Value::String(ec.to_string()), + input: ec.to_string(), provider_executed: Some(true), dynamic: None, - thought_signature: None, - invalid: None, - error: None, provider_metadata: Some(vertex_server_tool_metadata( &id, "code_execution", None, )), - }); + })); // provider-executed → does NOT set has_tool_calls } } else if let Some(cer) = part.get("codeExecutionResult") { @@ -488,7 +484,7 @@ impl LanguageModel for VertexModel { .and_then(|v| v.as_str()) .map(std::string::ToString::to_string) .unwrap_or_default(); - yield Ok(StreamPart::ToolResult { + yield Ok(StreamPart::ToolResult(ToolResult { tool_call_id: call_id.clone(), tool_name: code_execution_tool_name.clone(), result: json!({ "outcome": outcome, "output": output }), @@ -500,7 +496,7 @@ impl LanguageModel for VertexModel { "code_execution", None, )), - }); + })); } } else if let Some(tc) = part.get("toolCall") { // Server-side tool call (provider-executed). @@ -525,17 +521,14 @@ impl LanguageModel for VertexModel { tool_type, thought_signature.as_deref(), ); - yield Ok(StreamPart::ToolCall { + yield Ok(StreamPart::ToolCall(RawToolCall { tool_call_id: id, tool_name: format!("server:{tool_type}"), - input: Value::String(args.to_string()), + input: args.to_string(), provider_executed: Some(true), dynamic: Some(true), - thought_signature, - invalid: None, - error: None, provider_metadata: Some(server_meta), - }); + })); // provider-executed → does NOT set has_tool_calls } else if let Some(tr) = part.get("toolResponse") { // Server-side tool response. @@ -559,7 +552,7 @@ impl LanguageModel for VertexModel { tool_type, part.get("thoughtSignature").and_then(|v| v.as_str()), ); - yield Ok(StreamPart::ToolResult { + yield Ok(StreamPart::ToolResult(ToolResult { tool_call_id: id, tool_name: format!("server:{tool_type}"), result: response, @@ -567,7 +560,7 @@ impl LanguageModel for VertexModel { preliminary: None, dynamic: None, provider_metadata: Some(server_meta), - }); + })); } } } @@ -695,19 +688,18 @@ fn extract_content_from_candidate( if has_code { let id = format!("call-{}", content.len()); last_code_execution_tool_call_id = Some(id.clone()); - content.push(GenerateContent::ToolCall { + content.push(GenerateContent::ToolCall(RawToolCall { tool_call_id: id.clone(), tool_name: code_execution_tool_name.to_string(), input: ec.to_string(), provider_executed: Some(true), dynamic: None, - thought_signature: None, provider_metadata: Some(vertex_server_tool_metadata( &id, "code_execution", None, )), - }); + })); } } else if let Some(cer) = part.get("codeExecutionResult") { // One executableCode may be followed by multiple results. @@ -717,7 +709,7 @@ fn extract_content_from_candidate( .get("output") .and_then(|v| v.as_str()) .unwrap_or_default(); - content.push(GenerateContent::ToolResult { + content.push(GenerateContent::ToolResult(ToolResult { tool_call_id: call_id.clone(), tool_name: code_execution_tool_name.to_string(), result: json!({ "outcome": outcome, "output": output }), @@ -729,7 +721,7 @@ fn extract_content_from_candidate( "code_execution", None, )), - }); + })); } } else if let Some(text) = part.get("text").and_then(|v| v.as_str()) { if !text.is_empty() { @@ -763,15 +755,14 @@ fn extract_content_from_candidate( let provider_metadata = thought_signature.as_deref().map(|signature| { vertex_provider_metadata(json!({ "thoughtSignature": signature })) }); - content.push(GenerateContent::ToolCall { + content.push(GenerateContent::ToolCall(RawToolCall { tool_call_id: id, tool_name: name, input: input.to_string(), provider_executed: None, dynamic: None, - thought_signature, provider_metadata, - }); + })); has_tool_calls = true; } else if let Some(tc) = part.get("toolCall") { let tool_type = tc.get("toolType").and_then(|v| v.as_str()).unwrap_or(""); @@ -788,15 +779,14 @@ fn extract_content_from_candidate( .map(std::string::ToString::to_string); let server_meta = vertex_server_tool_metadata(&id, tool_type, thought_signature.as_deref()); - content.push(GenerateContent::ToolCall { + content.push(GenerateContent::ToolCall(RawToolCall { tool_call_id: id, tool_name: format!("server:{tool_type}"), input: input.to_string(), provider_executed: Some(true), dynamic: Some(true), - thought_signature, provider_metadata: Some(server_meta), - }); + })); } else if let Some(tr) = part.get("toolResponse") { let tool_type = tr.get("toolType").and_then(|v| v.as_str()).unwrap_or(""); let id = last_server_tool_call_id @@ -813,7 +803,7 @@ fn extract_content_from_candidate( tool_type, part.get("thoughtSignature").and_then(|v| v.as_str()), ); - content.push(GenerateContent::ToolResult { + content.push(GenerateContent::ToolResult(ToolResult { tool_call_id: id, tool_name: format!("server:{tool_type}"), result: response, @@ -821,7 +811,7 @@ fn extract_content_from_candidate( preliminary: None, dynamic: None, provider_metadata: Some(server_meta), - }); + })); } } } diff --git a/aimux-providers/src/xai/convert.rs b/aimux-providers/src/xai/convert.rs index 3662539d6..bfd304ed9 100644 --- a/aimux-providers/src/xai/convert.rs +++ b/aimux-providers/src/xai/convert.rs @@ -492,7 +492,7 @@ pub fn build_request_body_with_warnings( let (messages, message_warnings) = convert_to_xai_messages(&options.prompt)?; warnings.extend(message_warnings); - let prepared = prepare_tools(&options.tools, Some(&options.tool_choice)); + let prepared = prepare_tools(&options.tools, options.tool_choice.as_ref()); for tw in &prepared.tool_warnings { warnings.push(tw.clone()); } diff --git a/aimux-providers/src/xai/model.rs b/aimux-providers/src/xai/model.rs index 99cce1322..a2948521d 100644 --- a/aimux-providers/src/xai/model.rs +++ b/aimux-providers/src/xai/model.rs @@ -6,6 +6,7 @@ //! cached tokens, reasoning-effort model gating, 200-status errors) to warrant //! its own model implementation rather than reusing `OpenAIModel`. +use aimux_core::tool::RawToolCall; use std::collections::HashMap; use std::sync::atomic::{AtomicU64, Ordering}; @@ -171,15 +172,14 @@ impl LanguageModel for XaiModel { if let Some(tool_calls) = choice.message.tool_calls { for tc in tool_calls { let input = tc.function.arguments; - content.push(GenerateContent::ToolCall { + content.push(GenerateContent::ToolCall(RawToolCall { tool_call_id: tc.id, tool_name: tc.function.name, input, provider_executed: None, dynamic: None, - thought_signature: None, provider_metadata: None, - }); + })); } } @@ -503,18 +503,15 @@ impl LanguageModel for XaiModel { id: id.clone(), provider_metadata: None, }); - let input = Value::String(args.clone()); - yield Ok(StreamPart::ToolCall { + let input = args.clone(); + yield Ok(StreamPart::ToolCall(RawToolCall { tool_call_id: id.clone(), tool_name: name.clone(), input, provider_executed: None, dynamic: None, - thought_signature: None, - invalid: None, - error: None, provider_metadata: None, - }); + })); } } tool_calls.clear(); @@ -557,18 +554,15 @@ impl LanguageModel for XaiModel { id: id.clone(), provider_metadata: None, }); - let input = Value::String(args.clone()); - yield Ok(StreamPart::ToolCall { + let input = args.clone(); + yield Ok(StreamPart::ToolCall(RawToolCall { tool_call_id: id.clone(), tool_name: name.clone(), input, provider_executed: None, dynamic: None, - thought_signature: None, - invalid: None, - error: None, provider_metadata: None, - }); + })); } } diff --git a/aimux-providers/src/xai/responses/convert.rs b/aimux-providers/src/xai/responses/convert.rs index 3226bf6e5..48c2a54b2 100644 --- a/aimux-providers/src/xai/responses/convert.rs +++ b/aimux-providers/src/xai/responses/convert.rs @@ -599,7 +599,7 @@ pub fn build_responses_request_body( let (input, input_warnings) = convert_to_xai_responses_input(&options.prompt)?; warnings.extend(input_warnings); - let prepared = prepare_responses_tools(&options.tools, Some(&options.tool_choice)); + let prepared = prepare_responses_tools(&options.tools, options.tool_choice.as_ref()); for tw in &prepared.tool_warnings { warnings.push(tw.clone()); } diff --git a/aimux-providers/src/xai/responses/mod.rs b/aimux-providers/src/xai/responses/mod.rs index e8beb3682..1076ff719 100644 --- a/aimux-providers/src/xai/responses/mod.rs +++ b/aimux-providers/src/xai/responses/mod.rs @@ -15,6 +15,8 @@ pub mod convert; pub mod types; +use aimux_core::tool::RawToolCall; +use aimux_core::tool::ToolResult; use std::collections::HashMap; use std::sync::atomic::{AtomicU64, Ordering}; @@ -132,15 +134,14 @@ impl LanguageModel for XaiResponsesModel { let tool_name = resolve_tool_name(part_type, None, &provider_tool_names); let part_id = part.get("id").and_then(|v| v.as_str()).unwrap_or(""); - content.push(GenerateContent::ToolCall { + content.push(GenerateContent::ToolCall(RawToolCall { tool_call_id: part_id.to_string(), tool_name: tool_name.clone(), input: String::new(), provider_executed: Some(true), dynamic: None, - thought_signature: None, provider_metadata: None, - }); + })); let queries = part .get("queries") @@ -160,7 +161,7 @@ impl LanguageModel for XaiResponsesModel { }) .unwrap_or(Value::Null); - content.push(GenerateContent::ToolResult { + content.push(GenerateContent::ToolResult(ToolResult { tool_call_id: part_id.to_string(), tool_name, result: json!({ "queries": queries, "results": results }), @@ -168,7 +169,7 @@ impl LanguageModel for XaiResponsesModel { preliminary: None, dynamic: None, provider_metadata: None, - }); + })); continue; } @@ -189,15 +190,14 @@ impl LanguageModel for XaiResponsesModel { let tool_input = get_tool_input(part_type, part); let part_id = part.get("id").and_then(|v| v.as_str()).unwrap_or(""); - content.push(GenerateContent::ToolCall { + content.push(GenerateContent::ToolCall(RawToolCall { tool_call_id: part_id.to_string(), tool_name, input: tool_input, provider_executed: Some(true), dynamic: None, - thought_signature: None, provider_metadata: None, - }); + })); continue; } @@ -246,15 +246,14 @@ impl LanguageModel for XaiResponsesModel { let name = part.get("name").and_then(|v| v.as_str()).unwrap_or(""); let arguments = part.get("arguments").and_then(|v| v.as_str()).unwrap_or(""); let input = arguments.to_string(); - content.push(GenerateContent::ToolCall { + content.push(GenerateContent::ToolCall(RawToolCall { tool_call_id: call_id.to_string(), tool_name: name.to_string(), input, provider_executed: None, dynamic: None, - thought_signature: None, provider_metadata: None, - }); + })); } "reasoning" => { let summary = part.get("summary").and_then(|v| v.as_array()); @@ -709,17 +708,14 @@ impl LanguageModel for XaiResponsesModel { id: part_id.to_string(), provider_metadata: None, }); - yield Ok(StreamPart::ToolCall { + yield Ok(StreamPart::ToolCall(RawToolCall { tool_call_id: part_id.to_string(), tool_name: tool_name.clone(), - input: Value::String(String::new()), + input: String::new(), provider_executed: Some(true), dynamic: None, - thought_signature: None, - invalid: None, - error: None, provider_metadata: None, - }); + })); } if event_type == "response.output_item.done" { @@ -732,7 +728,7 @@ impl LanguageModel for XaiResponsesModel { "text": r.get("text").cloned().unwrap_or(Value::Null), })).collect::>()) }).unwrap_or(Value::Null); - yield Ok(StreamPart::ToolResult { + yield Ok(StreamPart::ToolResult(ToolResult { tool_call_id: part_id.to_string(), tool_name, result: json!({ "queries": queries, "results": results }), @@ -740,7 +736,7 @@ impl LanguageModel for XaiResponsesModel { preliminary: None, dynamic: None, provider_metadata: None, - }); + })); } continue; } @@ -782,21 +778,18 @@ impl LanguageModel for XaiResponsesModel { id: part_id.to_string(), provider_metadata: None, }); - yield Ok(StreamPart::ToolCall { + yield Ok(StreamPart::ToolCall(RawToolCall { tool_call_id: part_id.to_string(), tool_name: tool_name.clone(), - input: Value::String(tool_input), + input: tool_input, provider_executed: Some(true), dynamic: None, - thought_signature: None, - invalid: None, - error: None, provider_metadata: None, - }); + })); } if event_type == "response.output_item.done" { - yield Ok(StreamPart::ToolResult { + yield Ok(StreamPart::ToolResult(ToolResult { tool_call_id: part_id.to_string(), tool_name, result: json!({}), @@ -804,7 +797,7 @@ impl LanguageModel for XaiResponsesModel { preliminary: None, dynamic: None, provider_metadata: None, - }); + })); } continue; } @@ -874,18 +867,15 @@ impl LanguageModel for XaiResponsesModel { id: call_id.to_string(), provider_metadata: None, }); - let input = Value::String(arguments.to_string()); - yield Ok(StreamPart::ToolCall { + let input = arguments.to_string(); + yield Ok(StreamPart::ToolCall(RawToolCall { tool_call_id: call_id.to_string(), tool_name: name.to_string(), input, provider_executed: None, dynamic: None, - thought_signature: None, - invalid: None, - error: None, provider_metadata: None, - }); + })); } continue; } diff --git a/aimux-providers/tests/alibaba_test.rs b/aimux-providers/tests/alibaba_test.rs index a4323ca6a..db2c913ff 100644 --- a/aimux-providers/tests/alibaba_test.rs +++ b/aimux-providers/tests/alibaba_test.rs @@ -24,6 +24,7 @@ //! DashScope's OpenAI-compatible mode. Those TS-specific behaviours are //! therefore not asserted here. +use aimux_core::tool::RawToolCall; use futures::StreamExt; use serde_json::{Value, json}; use serial_test::serial; @@ -305,7 +306,6 @@ async fn converts_tool_call_and_tool_result_messages() { tool_name: "get_weather".to_string(), input: json!({"location": "SF"}), provider_executed: None, - thought_signature: None, provider_options: None, }], ..Default::default() @@ -524,15 +524,15 @@ async fn do_generate_extracts_tool_call() { assert_eq!(result.content.len(), 1); match &result.content[0] { - GenerateContent::ToolCall { + 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, &Value::String(r#"{"city":"SF"}"#.into())); + assert_eq!(input, r#"{"city":"SF"}"#); } other => panic!("expected ToolCall, got {other:?}"), } 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 363b29444..000000000 --- a/aimux-providers/tests/anthropic_assistant_tool_result_test.rs +++ /dev/null @@ -1,816 +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 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(json!({ - "anthropic": { - "caller": { - "type": "code_execution_20260120", - "toolId": "srvtoolu_code", - } - } - })), - }; - - 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(json!({ - "anthropic": { "type": "mcp-tool-use", "serverName": "my-server" } - })), - }; - - 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 { - 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 index 8b618fdb8..d36943a0d 100644 --- a/aimux-providers/tests/anthropic_aws_model_test.rs +++ b/aimux-providers/tests/anthropic_aws_model_test.rs @@ -7,6 +7,7 @@ //! - API key authentication //! - Error handling +use aimux_core::tool::RawToolCall; use futures::StreamExt; use serde_json::{Value, json}; use wiremock::matchers::{header, method, path}; @@ -88,12 +89,12 @@ fn as_text(item: &GenerateContent) -> &str { fn as_tool_call(item: &GenerateContent) -> (&str, &str, &str) { match item { - GenerateContent::ToolCall { + GenerateContent::ToolCall(RawToolCall { tool_call_id, tool_name, input, .. - } => (tool_call_id, tool_name, input), + }) => (tool_call_id, tool_name, input), _ => panic!("expected ToolCall content, got {item:?}"), } } @@ -189,7 +190,7 @@ async fn anthropic_aws_generate_tool_call() { let (id, name, input) = as_tool_call(&result.content[1]); assert_eq!(id, "toolu_01A"); assert_eq!(name, "getWeather"); - assert_eq!(input, &json!(r#"{"location":"Paris"}"#)); + assert_eq!(input, r#"{"location":"Paris"}"#); assert_eq!(result.finish_reason.unified, FinishReasonUnified::ToolCalls); } @@ -374,22 +375,19 @@ async fn anthropic_aws_stream_tool_call() { let tool_calls: Vec<_> = parts .iter() .filter_map(|p| match p { - StreamPart::ToolCall { + StreamPart::ToolCall(RawToolCall { tool_call_id, tool_name, input, .. - } => Some((tool_call_id.clone(), tool_name.clone(), input.clone())), + }) => 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, - Value::String(r#"{"location":"Berlin"}"#.into()) - ); + assert_eq!(tool_calls[0].2, r#"{"location":"Berlin"}"#); } /// Test: SigV4 authentication adds Authorization header. diff --git a/aimux-providers/tests/anthropic_cache_control_test.rs b/aimux-providers/tests/anthropic_cache_control_test.rs index ae63e2380..f9f8993c6 100644 --- a/aimux-providers/tests/anthropic_cache_control_test.rs +++ b/aimux-providers/tests/anthropic_cache_control_test.rs @@ -238,7 +238,6 @@ mod assistant_message { 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" }))), }], ), diff --git a/aimux-providers/tests/anthropic_convert_test.rs b/aimux-providers/tests/anthropic_convert_test.rs index 8dcba218f..58092a649 100644 --- a/aimux-providers/tests/anthropic_convert_test.rs +++ b/aimux-providers/tests/anthropic_convert_test.rs @@ -1,4 +1,4 @@ -// Panic convert wrappers are #[deprecated]; these tests still use them. +// Panic convert wrappers are #[deprecated]; these tests still use them. #![allow(deprecated)] //! Rust port of the Anthropic provider pure-function tests. //! @@ -386,34 +386,6 @@ mod prepare_tools_tests { 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] diff --git a/aimux-providers/tests/anthropic_model_test.rs b/aimux-providers/tests/anthropic_model_test.rs index d567328c7..86e6befd2 100644 --- a/aimux-providers/tests/anthropic_model_test.rs +++ b/aimux-providers/tests/anthropic_model_test.rs @@ -20,20 +20,21 @@ //! field, `providerMetadata` on the finish part) are intentionally omitted and //! documented in the task summary. +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::generate::{GenerateTextOptions, generate_text, stream_text}; +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::{MessageContent, Role}; -use aimux_core::options::{CallOptions, Tool, ToolChoice}; +use aimux_core::options::{CallOptions, Tool}; use aimux_core::result::{GenerateContent, StreamResult}; use aimux_core::stream_part::StreamPart; -use aimux_core::tool::{FunctionTool, ProviderTool}; +use aimux_core::tool::FunctionTool; use aimux_core::types::FinishReasonUnified; use aimux_providers::anthropic::AnthropicConfig; @@ -135,12 +136,12 @@ fn as_text(item: &GenerateContent) -> &str { /// Helper to destructure a `GenerateContent::ToolCall`. fn as_tool_call(item: &GenerateContent) -> (&str, &str, &str) { match item { - GenerateContent::ToolCall { + GenerateContent::ToolCall(RawToolCall { tool_call_id, tool_name, input, .. - } => (tool_call_id, tool_name, input), + }) => (tool_call_id, tool_name, input), _ => panic!("expected ToolCall content, got {item:?}"), } } @@ -299,7 +300,7 @@ mod do_generate { let (id, name, input) = as_tool_call(&result.content[1]); assert_eq!(id, "toolu_1"); assert_eq!(name, "test-tool"); - assert_eq!(input, &json!(r#"{"value":"example value"}"#)); + 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")); @@ -460,11 +461,11 @@ mod do_generate { 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, &json!(r#"{"x":1}"#)); + 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, &json!(r#"{"y":2}"#)); + assert_eq!(input_b, r#"{"y":2}"#); } /// Tool input may be a nested object. @@ -503,7 +504,7 @@ mod do_generate { let (_, _, input) = as_tool_call(&result.content[0]); assert_eq!( input, - &json!(r#"{"nested":{"arr":[1,2,3],"flag":true},"items":["a","b"]}"#) + r#"{"nested":{"arr":[1,2,3],"flag":true},"items":["a","b"]}"# ); } @@ -742,39 +743,6 @@ mod do_generate { 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; @@ -1177,21 +1145,18 @@ mod do_stream { let tool_call = parts .iter() .find_map(|p| match p { - StreamPart::ToolCall { + StreamPart::ToolCall(RawToolCall { tool_call_id, tool_name, input, .. - } => Some((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, - &Value::String(r#"{"value":"Sparkle Day"}"#.into()) - ); + assert_eq!(tool_call.2, r#"{"value":"Sparkle Day"}"#); // Finish reason reflects tool_use. let finish = parts @@ -1210,188 +1175,6 @@ mod do_stream { 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 { 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 { - 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 - .as_str() - .expect("provider input remains raw JSON"), - ) - .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. @@ -1433,11 +1216,11 @@ mod do_stream { let tool_call = parts .iter() .find_map(|p| match p { - StreamPart::ToolCall { input, .. } => Some(input.clone()), + StreamPart::ToolCall(RawToolCall { input, .. }) => Some(input.clone()), _ => None, }) .expect("a ToolCall part"); - assert_eq!(tool_call, Value::String("{}".into())); + assert_eq!(tool_call, "{}"); } // ── misc SSE scenarios ────────────────────────────────────────────────── @@ -1581,18 +1364,18 @@ mod do_stream { let tool_call = parts .iter() .find_map(|p| match p { - StreamPart::ToolCall { + StreamPart::ToolCall(RawToolCall { tool_call_id, tool_name, input, .. - } => Some((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, &Value::String(r#"{"value":"x"}"#.into())); + assert_eq!(tool_call.2, r#"{"value":"x"}"#); } /// Two parallel tool_use blocks (index 0 and index 1) each produce their @@ -1628,22 +1411,22 @@ mod do_stream { }; let parts = collect_stream(model.do_stream(&opts).await.unwrap()).await; - let tool_calls: Vec<(String, Value)> = parts + let tool_calls: Vec<(String, String)> = parts .iter() .filter_map(|p| match p { - StreamPart::ToolCall { + StreamPart::ToolCall(RawToolCall { tool_call_id, input, .. - } => Some((tool_call_id.clone(), input.clone())), + }) => 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, Value::String(r#"{"a":1}"#.into())); + assert_eq!(tool_calls[0].1, r#"{"a":1}"#); assert_eq!(tool_calls[1].0, "toolu_b"); - assert_eq!(tool_calls[1].1, Value::String(r#"{"b":2}"#.into())); + assert_eq!(tool_calls[1].1, r#"{"b":2}"#); } // ── pre-stream HTTP errors ────────────────────────────────────────────── @@ -1799,113 +1582,6 @@ mod do_stream { ); } - #[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 { - 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; @@ -1949,9 +1625,9 @@ mod do_stream { let parts = collect_stream(model.do_stream(&options).await.unwrap()).await; let metadata = parts.iter().find_map(|part| match part { - StreamPart::ToolCall { + StreamPart::ToolCall(RawToolCall { provider_metadata, .. - } => provider_metadata.as_ref(), + }) => provider_metadata.as_ref(), _ => None, }); assert_eq!( diff --git a/aimux-providers/tests/anthropic_prepare_tools_test.rs b/aimux-providers/tests/anthropic_prepare_tools_test.rs deleted file mode 100644 index d63090d76..000000000 --- a/aimux-providers/tests/anthropic_prepare_tools_test.rs +++ /dev/null @@ -1,408 +0,0 @@ -//! Rust port of the provider-defined-tools section of -//! `anthropic-prepare-tools.test.ts`. -//! -//! These exercise [`prepare_tools_with_provider`], which handles the Vercel AI -//! SDK `type: 'provider'` tools (computer use, text editor, bash, web search, -//! web fetch, tool search, code execution, advisor) in addition to function -//! tools. Cache-control resolution and response-schema validation tests remain -//! out of scope (they need a `CacheControlValidator` / response-schema utility). - -use aimux_core::tool::ToolChoice; -use aimux_core::types::Warning; -use aimux_providers::anthropic::prepare_tools::{AnthropicTool, prepare_tools_with_provider}; -use serde_json::{Value, json}; - -fn provider(id: &str, name: &str, args: Value) -> AnthropicTool { - AnthropicTool::Provider { - id: id.to_string(), - name: name.to_string(), - args, - } -} - -fn run(tools: Vec) -> aimux_providers::anthropic::prepare_tools::PreparedTools { - prepare_tools_with_provider(Some(&tools), None, false, true, true, false) -} - -fn assert_unsupported_warning(warnings: &[Warning], feature: &str) { - let found = warnings.iter().any(|w| match w { - Warning::Unsupported { feature: f, .. } => f == feature, - _ => false, - }); - assert!( - found, - "expected Unsupported warning for {feature:?}, got {warnings:?}" - ); -} - -// --------------------------------------------------------------------------- -// computer use -// --------------------------------------------------------------------------- - -#[test] -fn should_correctly_prepare_computer_20241022_tool() { - let result = run(vec![provider( - "anthropic.computer_20241022", - "computer", - json!({ "displayWidthPx": 800, "displayHeightPx": 600, "displayNumber": 1 }), - )]); - assert_eq!( - result.tools.unwrap()[0], - json!({ - "name": "computer", - "type": "computer_20241022", - "display_width_px": 800, - "display_height_px": 600, - "display_number": 1, - }) - ); - assert!(result.betas.contains("computer-use-2024-10-22")); -} - -#[test] -fn should_correctly_prepare_computer_20250124_tool() { - let result = run(vec![provider( - "anthropic.computer_20250124", - "computer", - json!({ "displayWidthPx": 1024, "displayHeightPx": 768, "displayNumber": 1 }), - )]); - assert_eq!( - result.tools.unwrap()[0], - json!({ - "name": "computer", - "type": "computer_20250124", - "display_width_px": 1024, - "display_height_px": 768, - "display_number": 1, - }) - ); - assert!(result.betas.contains("computer-use-2025-01-24")); -} - -#[test] -fn should_correctly_prepare_computer_20251124_tool() { - let result = run(vec![provider( - "anthropic.computer_20251124", - "computer", - json!({ "displayWidthPx": 1024, "displayHeightPx": 768, "displayNumber": 1 }), - )]); - assert_eq!( - result.tools.unwrap()[0], - json!({ - "name": "computer", - "type": "computer_20251124", - "display_width_px": 1024, - "display_height_px": 768, - "display_number": 1, - "enable_zoom": Value::Null, - }) - ); - assert!(result.betas.contains("computer-use-2025-11-24")); -} - -#[test] -fn should_correctly_prepare_computer_20251124_tool_with_enable_zoom() { - let result = run(vec![provider( - "anthropic.computer_20251124", - "computer", - json!({ "displayWidthPx": 1024, "displayHeightPx": 768, "displayNumber": 1, "enableZoom": true }), - )]); - assert_eq!(result.tools.unwrap()[0]["enable_zoom"], json!(true)); -} - -#[test] -fn should_correctly_prepare_computer_20251124_tool_with_enable_zoom_false() { - let result = run(vec![provider( - "anthropic.computer_20251124", - "computer", - json!({ "displayWidthPx": 1024, "displayHeightPx": 768, "displayNumber": 1, "enableZoom": false }), - )]); - assert_eq!(result.tools.unwrap()[0]["enable_zoom"], json!(false)); -} - -// --------------------------------------------------------------------------- -// text editor / bash -// --------------------------------------------------------------------------- - -#[test] -fn should_correctly_prepare_text_editor_20241022_tool() { - let result = run(vec![provider( - "anthropic.text_editor_20241022", - "text_editor", - json!({}), - )]); - assert_eq!( - result.tools.unwrap()[0], - json!({ "name": "str_replace_editor", "type": "text_editor_20241022" }) - ); - assert!(result.betas.contains("computer-use-2024-10-22")); -} - -#[test] -fn should_correctly_prepare_bash_20241022_tool() { - let result = run(vec![provider("anthropic.bash_20241022", "bash", json!({}))]); - assert_eq!( - result.tools.unwrap()[0], - json!({ "name": "bash", "type": "bash_20241022" }) - ); - assert!(result.betas.contains("computer-use-2024-10-22")); -} - -#[test] -fn should_correctly_prepare_text_editor_20250728_with_max_characters() { - let result = run(vec![provider( - "anthropic.text_editor_20250728", - "str_replace_based_edit_tool", - json!({ "maxCharacters": 10000 }), - )]); - assert_eq!( - result.tools.unwrap()[0], - json!({ - "name": "str_replace_based_edit_tool", - "type": "text_editor_20250728", - "max_characters": 10000, - }) - ); - assert!(result.betas.is_empty()); -} - -#[test] -fn should_correctly_prepare_text_editor_20250728_without_max_characters() { - let result = run(vec![provider( - "anthropic.text_editor_20250728", - "str_replace_based_edit_tool", - json!({}), - )]); - assert_eq!( - result.tools.unwrap()[0], - json!({ - "name": "str_replace_based_edit_tool", - "type": "text_editor_20250728", - "max_characters": Value::Null, - }) - ); -} - -// --------------------------------------------------------------------------- -// web search / web fetch -// --------------------------------------------------------------------------- - -#[test] -fn should_correctly_prepare_web_search_20250305() { - let result = run(vec![provider( - "anthropic.web_search_20250305", - "web_search", - json!({ - "maxUses": 10, - "allowedDomains": ["https://www.google.com"], - "userLocation": { "type": "approximate", "city": "New York" }, - }), - )]); - assert_eq!( - result.tools.unwrap()[0], - json!({ - "type": "web_search_20250305", - "name": "web_search", - "max_uses": 10, - "allowed_domains": ["https://www.google.com"], - "blocked_domains": Value::Null, - "user_location": { "city": "New York", "type": "approximate" }, - }) - ); - assert!(result.betas.is_empty()); -} - -#[test] -fn should_correctly_prepare_web_search_20260209() { - let result = run(vec![provider( - "anthropic.web_search_20260209", - "web_search", - json!({ - "maxUses": 10, - "allowedDomains": ["https://www.google.com"], - "userLocation": { "type": "approximate", "city": "New York" }, - }), - )]); - assert_eq!( - result.tools.unwrap()[0]["type"], - json!("web_search_20260209") - ); - assert!(result.betas.contains("code-execution-web-tools-2026-02-09")); -} - -#[test] -fn should_correctly_prepare_web_fetch_20250910() { - let result = run(vec![provider( - "anthropic.web_fetch_20250910", - "web_fetch", - json!({ - "maxUses": 10, - "allowedDomains": ["https://www.google.com"], - "citations": { "enabled": true }, - "maxContentTokens": 1000, - }), - )]); - assert_eq!( - result.tools.unwrap()[0], - json!({ - "type": "web_fetch_20250910", - "name": "web_fetch", - "max_uses": 10, - "allowed_domains": ["https://www.google.com"], - "blocked_domains": Value::Null, - "citations": { "enabled": true }, - "max_content_tokens": 1000, - }) - ); - assert!(result.betas.contains("web-fetch-2025-09-10")); -} - -#[test] -fn should_correctly_prepare_web_fetch_20260209() { - let result = run(vec![provider( - "anthropic.web_fetch_20260209", - "web_fetch", - json!({ - "maxUses": 10, - "allowedDomains": ["https://www.google.com"], - "citations": { "enabled": true }, - "maxContentTokens": 1000, - }), - )]); - assert_eq!( - result.tools.unwrap()[0]["type"], - json!("web_fetch_20260209") - ); - assert!(result.betas.contains("code-execution-web-tools-2026-02-09")); -} - -// --------------------------------------------------------------------------- -// tool search / code execution -// --------------------------------------------------------------------------- - -#[test] -fn should_correctly_prepare_tool_search_regex_20251119() { - let result = run(vec![provider( - "anthropic.tool_search_regex_20251119", - "tool_search", - json!({}), - )]); - assert_eq!( - result.tools.unwrap()[0], - json!({ "name": "tool_search_tool_regex", "type": "tool_search_tool_regex_20251119" }) - ); - assert!(result.betas.is_empty()); -} - -#[test] -fn should_correctly_prepare_tool_search_bm25_20251119() { - let result = run(vec![provider( - "anthropic.tool_search_bm25_20251119", - "tool_search", - json!({}), - )]); - assert_eq!( - result.tools.unwrap()[0], - json!({ "name": "tool_search_tool_bm25", "type": "tool_search_tool_bm25_20251119" }) - ); - assert!(result.betas.is_empty()); -} - -#[test] -fn should_correctly_prepare_code_execution_20260120_without_beta_header() { - let result = run(vec![provider( - "anthropic.code_execution_20260120", - "code_execution", - json!({}), - )]); - assert_eq!( - result.tools.unwrap()[0], - json!({ "type": "code_execution_20260120", "name": "code_execution" }) - ); - assert!(result.betas.is_empty()); -} - -// --------------------------------------------------------------------------- -// advisor -// --------------------------------------------------------------------------- - -#[test] -fn should_correctly_prepare_advisor_20260301_with_only_the_required_model() { - let result = run(vec![provider( - "anthropic.advisor_20260301", - "advisor", - json!({ "model": "claude-opus-4-7" }), - )]); - assert_eq!( - result.tools.unwrap()[0], - json!({ - "type": "advisor_20260301", - "name": "advisor", - "model": "claude-opus-4-7", - }) - ); - assert!(result.betas.contains("advisor-tool-2026-03-01")); -} - -#[test] -fn should_correctly_prepare_advisor_20260301_with_all_optional_args() { - let result = run(vec![provider( - "anthropic.advisor_20260301", - "advisor", - json!({ - "model": "claude-opus-4-7", - "maxUses": 5, - "caching": { "type": "ephemeral", "ttl": "1h" }, - }), - )]); - assert_eq!( - result.tools.unwrap()[0], - json!({ - "type": "advisor_20260301", - "name": "advisor", - "model": "claude-opus-4-7", - "max_uses": 5, - "caching": { "type": "ephemeral", "ttl": "1h" }, - }) - ); - assert!(result.betas.contains("advisor-tool-2026-03-01")); -} - -// --------------------------------------------------------------------------- -// unsupported tools -// --------------------------------------------------------------------------- - -#[test] -fn should_add_warnings_for_unsupported_tools() { - let result = run(vec![provider( - "unsupported.tool", - "unsupported_tool", - json!({}), - )]); - // The unsupported tool is dropped from the tools array (which stays empty, - // not None, because the input was non-empty). - assert_eq!(result.tools, Some(vec![])); - assert!(result.tool_choice.is_none()); - assert_eq!(result.tool_warnings.len(), 1); - assert_unsupported_warning( - &result.tool_warnings, - "provider-defined tool unsupported.tool", - ); -} - -// --------------------------------------------------------------------------- -// tool choice still works with provider tools -// --------------------------------------------------------------------------- - -#[test] -fn should_handle_tool_choice_tool_with_provider_tools() { - let tools = vec![provider("anthropic.bash_20241022", "bash", json!({}))]; - let tc = ToolChoice::Tool { - tool_name: "bash".to_string(), - }; - let result = prepare_tools_with_provider(Some(&tools), Some(&tc), false, true, true, false); - assert_eq!( - result.tool_choice.unwrap(), - json!({ "type": "tool", "name": "bash" }) - ); -} 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 68425824f..000000000 --- a/aimux-providers/tests/anthropic_provider_tools_test.rs +++ /dev/null @@ -1,2078 +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 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::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> { - let mut map = HashMap::new(); - map.insert("anthropic".to_string(), value); - Some(map) -} - -/// 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 { - let mut m = HashMap::new(); - m.insert( - "anthropic".to_string(), - json!({ "eagerInputStreaming": true }), - ); - Some(m) - } 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 { 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 { - 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 { - let mut po = HashMap::new(); - po.insert("anthropic".to_string(), json!({ "deferLoading": true })); - FunctionTool { - name: name.to_string(), - description: Some(desc.to_string()), - input_schema: json!({ - "type": "object", - "properties": { "location": { "type": "string" } }, - }), - strict: None, - provider_options: Some(po), - 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 { - 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/azure_model_test.rs b/aimux-providers/tests/azure_model_test.rs index a1da57d9c..c25609097 100644 --- a/aimux-providers/tests/azure_model_test.rs +++ b/aimux-providers/tests/azure_model_test.rs @@ -1,4 +1,4 @@ -//! Wiremock tests for the Azure OpenAI provider. +//! 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 @@ -13,6 +13,7 @@ //! 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}; @@ -666,15 +667,15 @@ async fn should_extract_tool_call() { assert_eq!(result.content.len(), 1); match &result.content[0] { - GenerateContent::ToolCall { + 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, &Value::String(r#"{"city":"SF"}"#.into())); + assert_eq!(input, r#"{"city":"SF"}"#); } other => panic!("expected ToolCall, got {other:?}"), } @@ -705,18 +706,18 @@ async fn should_stream_tool_call() { let parts = collect_stream(result).await; let tool_call = parts.iter().find_map(|p| match p { - StreamPart::ToolCall { + StreamPart::ToolCall(RawToolCall { tool_call_id, tool_name, input, .. - } => Some((tool_call_id.clone(), tool_name.clone(), input.clone())), + }) => 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, Value::String(r#"{"city":"SF"}"#.into())); + assert_eq!(input, r#"{"city":"SF"}"#); } /// TS: "should send a json_schema response format for structured output" diff --git a/aimux-providers/tests/azure_responses_test.rs b/aimux-providers/tests/azure_responses_test.rs index f3b5de543..cb570c599 100644 --- a/aimux-providers/tests/azure_responses_test.rs +++ b/aimux-providers/tests/azure_responses_test.rs @@ -1,4 +1,4 @@ -//! Wiremock tests for the Azure OpenAI Responses API provider. +//! Wiremock tests for the Azure OpenAI Responses API provider. //! //! These cover the Azure-specific differences from the OpenAI Responses //! provider: @@ -23,6 +23,7 @@ //! (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}; @@ -525,22 +526,19 @@ async fn should_extract_tool_call_content() { .content .iter() .filter_map(|c| match c { - GenerateContent::ToolCall { + GenerateContent::ToolCall(RawToolCall { tool_call_id, tool_name, input, .. - } => Some((tool_call_id.clone(), tool_name.clone(), input.clone())), + }) => 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, - Value::String(r#"{"location": "San Francisco"}"#.into()) - ); + assert_eq!(tool_calls[0].2, r#"{"location": "San Francisco"}"#); } /// Usage is extracted from the `usage` field. diff --git a/aimux-providers/tests/bedrock_convert_test.rs b/aimux-providers/tests/bedrock_convert_test.rs index fc7945103..092f5680a 100644 --- a/aimux-providers/tests/bedrock_convert_test.rs +++ b/aimux-providers/tests/bedrock_convert_test.rs @@ -1,4 +1,4 @@ -//! Pure-function tests for the Amazon Bedrock provider's conversion layer. +//! 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) @@ -36,7 +36,7 @@ use aimux_core::options::ToolChoice; use aimux_core::tool::FunctionTool; use aimux_providers::bedrock::convert::{ - BedrockUsage, convert_prompt_to_bedrock, convert_usage, prepare_tools, supports_strict_tools, + BedrockUsage, convert_prompt_to_bedrock, convert_usage, prepare_tools, }; // ── prompt builders ───────────────────────────────────────────────────────── @@ -110,9 +110,6 @@ 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 // ════════════════════════════════════════════════════════════════════════════ @@ -950,6 +947,8 @@ fn media_type_route_to_document_text_plain() { // amazon-bedrock-prepare-tools // ════════════════════════════════════════════════════════════════════════════ +const NON_ANTHROPIC_MODEL: &str = "meta.llama3-70b-instruct-v1:0"; + fn func_tool(name: &str, description: Option<&str>, input_schema: Value) -> FunctionTool { FunctionTool { name: name.to_string(), @@ -961,108 +960,11 @@ fn func_tool(name: &str, description: Option<&str>, input_schema: Value) -> Func } } -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); + let config = prepare_tools(&tools, Some(&ToolChoice::Auto), NON_ANTHROPIC_MODEL); assert_eq!(config["toolChoice"], json!({ "auto": {} })); } @@ -1070,7 +972,7 @@ fn prepare_tools_tool_choice_auto() { #[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); + let config = prepare_tools(&tools, Some(&ToolChoice::Required), NON_ANTHROPIC_MODEL); assert_eq!(config["toolChoice"], json!({ "any": {} })); } @@ -1078,7 +980,7 @@ fn prepare_tools_tool_choice_required() { #[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); + let config = prepare_tools(&tools, Some(&ToolChoice::None), NON_ANTHROPIC_MODEL); assert_eq!(config, json!({})); } @@ -1088,9 +990,9 @@ fn prepare_tools_tool_choice_tool() { let tools = Some(vec![func_tool("testFunction", Some("Test"), json!({}))]); let config = prepare_tools( &tools, - &ToolChoice::Tool { + Some(&ToolChoice::Tool { tool_name: "testFunction".to_string(), - }, + }), NON_ANTHROPIC_MODEL, ); assert_eq!( @@ -1112,176 +1014,15 @@ fn prepare_tools_tool_choice_filters_to_named() { ]); let config = prepare_tools( &tools, - &ToolChoice::Tool { + Some(&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 // ════════════════════════════════════════════════════════════════════════════ diff --git a/aimux-providers/tests/bedrock_model_test.rs b/aimux-providers/tests/bedrock_model_test.rs index eebc91f39..3739f1d6a 100644 --- a/aimux-providers/tests/bedrock_model_test.rs +++ b/aimux-providers/tests/bedrock_model_test.rs @@ -1,4 +1,4 @@ -//! Wiremock tests for the Amazon Bedrock provider. +//! Wiremock tests for the Amazon Bedrock provider. //! //! Tests cover: //! - Non-streaming text generation (Converse API JSON response) @@ -6,6 +6,7 @@ //! - 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}; @@ -15,10 +16,9 @@ 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::options::CallOptions; 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}; @@ -67,12 +67,12 @@ fn as_text(item: &GenerateContent) -> &str { fn as_tool_call(item: &GenerateContent) -> (&str, &str, &str) { match item { - GenerateContent::ToolCall { + GenerateContent::ToolCall(RawToolCall { tool_call_id, tool_name, input, .. - } => (tool_call_id, tool_name, input), + }) => (tool_call_id, tool_name, input), _ => panic!("expected ToolCall content, got {item:?}"), } } @@ -175,7 +175,7 @@ async fn bedrock_generate_tool_call() { let (id, name, input) = as_tool_call(&result.content[1]); assert_eq!(id, "tool_use_123"); assert_eq!(name, "getWeather"); - assert_eq!(input, &json!(r#"{"location":"San Francisco"}"#)); + assert_eq!(input, r#"{"location":"San Francisco"}"#); assert_eq!(result.finish_reason.unified, FinishReasonUnified::ToolCalls); } @@ -373,22 +373,19 @@ async fn bedrock_stream_tool_call() { let tool_calls: Vec<_> = parts .iter() .filter_map(|p| match p { - StreamPart::ToolCall { + StreamPart::ToolCall(RawToolCall { tool_call_id, tool_name, input, .. - } => Some((tool_call_id.clone(), tool_name.clone(), input.clone())), + }) => 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, - Value::String(r#"{"location":"SF"}"#.into()) - ); + assert_eq!(tool_calls[0].2, r#"{"location":"SF"}"#); } /// Test: SigV4 authentication adds Authorization header. @@ -675,62 +672,6 @@ async fn bedrock_generate_settings() { 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() { diff --git a/aimux-providers/tests/bedrock_remaining_test.rs b/aimux-providers/tests/bedrock_remaining_test.rs index c17fbb90c..b101c677a 100644 --- a/aimux-providers/tests/bedrock_remaining_test.rs +++ b/aimux-providers/tests/bedrock_remaining_test.rs @@ -1,4 +1,4 @@ -//! Remaining (not-yet-covered) Amazon Bedrock TS test cases, translated to Rust. +//! 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` @@ -19,6 +19,7 @@ //! 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 std::collections::HashMap; use futures::StreamExt; @@ -31,11 +32,10 @@ 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::options::CallOptions; 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::{ @@ -99,12 +99,12 @@ fn as_text(item: &GenerateContent) -> &str { fn as_tool_call(item: &GenerateContent) -> (&str, &str, &str) { match item { - GenerateContent::ToolCall { + GenerateContent::ToolCall(RawToolCall { tool_call_id, tool_name, input, .. - } => (tool_call_id, tool_name, input), + }) => (tool_call_id, tool_name, input), _ => panic!("expected ToolCall content, got {item:?}"), } } @@ -934,50 +934,23 @@ async fn stream_tool_call_empty_input() { let tool_calls: Vec<_> = parts .iter() .filter_map(|p| match p { - StreamPart::ToolCall { + StreamPart::ToolCall(RawToolCall { tool_call_id, tool_name, input, .. - } => Some((tool_call_id.clone(), tool_name.clone(), input.clone())), + }) => 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, Value::String("{}".into())); + 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". /// @@ -1205,70 +1178,6 @@ async fn finish_reason_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 diff --git a/aimux-providers/tests/codex_test.rs b/aimux-providers/tests/codex_test.rs index c0974477f..cd957ac24 100644 --- a/aimux-providers/tests/codex_test.rs +++ b/aimux-providers/tests/codex_test.rs @@ -18,11 +18,9 @@ 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, Tool, ToolChoice}; +use aimux_core::options::CallOptions; 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::{CODEX_OAUTH_TOKEN_URL, CodexConfig, CodexProvider, codex_refresh_at}; @@ -40,22 +38,6 @@ fn default_options(prompt: LanguageModelPrompt) -> CallOptions { CallOptions::new(prompt) } -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, - } -} - fn sse_event(json_str: &str) -> String { format!("data: {json_str}\n\n") } @@ -161,17 +143,6 @@ const TEXT_STREAM_EVENTS: &[&str] = &[ r#"{"type":"response.completed","response":{"id":"resp_1","created_at":1741269019,"model":"gpt-5.2-codex","incomplete_details":null,"usage":{"input_tokens":543,"input_tokens_details":{"cached_tokens":234},"output_tokens":478,"output_tokens_details":{"reasoning_tokens":123}}}}"#, ]; -/// The tool-call streaming event sequence. -const TOOL_STREAM_EVENTS: &[&str] = &[ - r#"{"type":"response.created","response":{"id":"resp_tc","created_at":1741362087,"model":"gpt-5.2-codex"}}"#, - 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"}}"#, - r#"{"type":"response.function_call_arguments.delta","item_id":"fc_1","output_index":0,"delta":"{\"location\":"}"#, - r#"{"type":"response.function_call_arguments.delta","item_id":"fc_1","output_index":0,"delta":"\"Rome\"}"}"#, - r#"{"type":"response.function_call_arguments.done","item_id":"fc_1","output_index":0,"arguments":"{\"location\":\"Rome\"}"}"#, - 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"}}"#, - r#"{"type":"response.completed","response":{"id":"resp_tc","created_at":1741362087,"model":"gpt-5.2-codex","incomplete_details":null,"usage":{"input_tokens":0,"input_tokens_details":{"cached_tokens":0},"output_tokens":0,"output_tokens_details":{"reasoning_tokens":0}}}}"#, -]; - /// A `response.completed` event whose nested response object carries the full /// output —what the subscription channel emits at the end of a stream. fn subscription_completed_event() -> String { @@ -249,44 +220,6 @@ async fn api_key_streams_text() { } } -#[tokio::test] -async fn api_key_streams_tool_calls() { - let server = MockServer::start().await; - mock_sse_response(&server, &sse_body(TOOL_STREAM_EVENTS)).await; - - let config = CodexConfig::new("test-key").with_base_url(server.uri()); - let model = CodexProvider::new(config).model("gpt-5.2-codex"); - - 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; - - match &parts[6] { - StreamPart::ToolCall { - tool_call_id, - tool_name, - input, - .. - } => { - assert_eq!(tool_call_id, "call_done"); - assert_eq!(tool_name, "weather"); - assert_eq!(input, &Value::String(r#"{"location":"Rome"}"#.into())); - } - other => panic!("expected ToolCall, got {other:?}"), - } - match &parts[7] { - StreamPart::Finish { finish_reason, .. } => { - assert_eq!(finish_reason.unified, FinishReasonUnified::ToolCalls); - } - other => panic!("expected Finish, got {other:?}"), - } -} - // Path B: subscription mode #[tokio::test] diff --git a/aimux-providers/tests/cohere_model_test.rs b/aimux-providers/tests/cohere_model_test.rs index c61f75f5a..f79e6b145 100644 --- a/aimux-providers/tests/cohere_model_test.rs +++ b/aimux-providers/tests/cohere_model_test.rs @@ -5,6 +5,7 @@ //! - 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}; @@ -16,7 +17,7 @@ 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::options::{CallOptions, Tool}; use aimux_core::result::{GenerateContent, StreamResult}; use aimux_core::stream_part::StreamPart; use aimux_core::tool::FunctionTool; @@ -261,23 +262,20 @@ async fn should_extract_tool_calls() { assert_eq!(result.content.len(), 2); match &result.content[0] { - GenerateContent::ToolCall { + GenerateContent::ToolCall(RawToolCall { tool_name, input, .. - } => { + }) => { assert_eq!(tool_name, "weather"); - assert_eq!( - input, - &Value::String(r#"{"location":"San Francisco"}"#.into()) - ); + assert_eq!(input, r#"{"location":"San Francisco"}"#); } other => panic!("expected ToolCall, got {other:?}"), } match &result.content[1] { - GenerateContent::ToolCall { + GenerateContent::ToolCall(RawToolCall { tool_name, input, .. - } => { + }) => { assert_eq!(tool_name, "cityAttractions"); - assert_eq!(input, &Value::String(r#"{"city":"San Francisco"}"#.into())); + assert_eq!(input, r#"{"city":"San Francisco"}"#); } other => panic!("expected ToolCall, got {other:?}"), } @@ -323,9 +321,9 @@ async fn should_handle_null_tool_call_arguments() { assert_eq!(result.content.len(), 1); match &result.content[0] { - GenerateContent::ToolCall { input, .. } => { + GenerateContent::ToolCall(RawToolCall { input, .. }) => { // "null" should be replaced with "{}". - assert_eq!(input, &Value::String("{}".into())); + assert_eq!(input, "{}"); } other => panic!("expected ToolCall, got {other:?}"), } @@ -413,44 +411,6 @@ async fn should_send_correct_request_body() { 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 // ════════════════════════════════════════════════════════════════════════════ @@ -541,12 +501,12 @@ async fn should_stream_tool_call_deltas() { // Should have ToolInputStart, ToolInputDelta(s), ToolInputEnd, ToolCall. let tool_call = parts.iter().find_map(|p| match p { - StreamPart::ToolCall { + StreamPart::ToolCall(RawToolCall { tool_call_id, tool_name, input, .. - } => Some((tool_call_id, tool_name, input)), + }) => Some((tool_call_id, tool_name, input)), _ => None, }); let (id, name, input) = tool_call.expect("should have ToolCall"); @@ -555,10 +515,7 @@ async fn should_stream_tool_call_deltas() { // 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, - &Value::String(r#"{"location": "San Francisco"}"#.into()) - ); + assert_eq!(input, r#"{"location": "San Francisco"}"#); // Verify the accumulated deltas. let deltas: Vec = parts @@ -617,12 +574,12 @@ async fn malformed_streamed_tool_call_arguments_do_not_error_the_stream() { "provider must not parse tool input itself: {raw_parts:?}" ); let raw_input = raw_parts.iter().find_map(|p| match p { - StreamPart::ToolCall { input, .. } => Some(input.clone()), + StreamPart::ToolCall(RawToolCall { input, .. }) => Some(input.clone()), _ => None, }); assert_eq!( raw_input, - Some(Value::String(r#"{"value":"#.to_string())), + Some(r#"{"value":"#.to_string()), "provider must forward the malformed text verbatim" ); @@ -958,55 +915,6 @@ async fn should_pass_response_format() { ); } -/// 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). @@ -1550,18 +1458,18 @@ async fn should_stream_empty_tool_call_arguments() { let parts = collect_stream(result).await; let tool_call = parts.iter().find_map(|p| match p { - StreamPart::ToolCall { + StreamPart::ToolCall(RawToolCall { tool_call_id, tool_name, input, .. - } => Some((tool_call_id.clone(), tool_name.clone(), input.clone())), + }) => 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, Value::String("{}".into())); + assert_eq!(input, "{}"); let finish = parts.last().expect("should have finish"); match finish { diff --git a/aimux-providers/tests/cohere_remaining_test.rs b/aimux-providers/tests/cohere_remaining_test.rs index 51a93b103..1c68169ba 100644 --- a/aimux-providers/tests/cohere_remaining_test.rs +++ b/aimux-providers/tests/cohere_remaining_test.rs @@ -19,7 +19,7 @@ use aimux_core::language_model_message::{LanguageModelPrompt, LanguageModelPromp use aimux_core::message::Role; use aimux_core::options::{CallOptions, Tool, ToolChoice}; use aimux_core::result::GenerateContent; -use aimux_core::tool::{FunctionTool, ProviderTool}; +use aimux_core::tool::FunctionTool; use aimux_core::types::{FinishReasonUnified, ReasoningEffort}; use aimux_providers::cohere::convert::{ @@ -115,28 +115,6 @@ fn prepare_tools_function_tool() { 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() { @@ -442,7 +420,6 @@ fn convert_assistant_tool_call_message() { tool_name: "tool-1".to_string(), input: json!({ "test": "This is a tool message" }), provider_executed: None, - thought_signature: None, provider_options: None, }, ], diff --git a/aimux-providers/tests/conformance_test.rs b/aimux-providers/tests/conformance_test.rs index 098f2fc04..87388af58 100644 --- a/aimux-providers/tests/conformance_test.rs +++ b/aimux-providers/tests/conformance_test.rs @@ -67,7 +67,7 @@ fn has_text(content: &[GenerateContent]) -> bool { fn has_tool_call(content: &[GenerateContent]) -> bool { content .iter() - .any(|c| matches!(c, GenerateContent::ToolCall { .. })) + .any(|c| matches!(c, GenerateContent::ToolCall(_))) } fn has_reasoning(content: &[GenerateContent]) -> bool { @@ -89,9 +89,7 @@ fn has_finish(parts: &[StreamPart]) -> bool { #[allow(dead_code)] fn has_tool_call_part(parts: &[StreamPart]) -> bool { - parts - .iter() - .any(|p| matches!(p, StreamPart::ToolCall { .. })) + parts.iter().any(|p| matches!(p, StreamPart::ToolCall(_))) } /// True when an error signals the request never matched a cassette — i.e. a diff --git a/aimux-providers/tests/convert_golden_test.rs b/aimux-providers/tests/convert_golden_test.rs index b0d197ffa..f6a4f9eef 100644 --- a/aimux-providers/tests/convert_golden_test.rs +++ b/aimux-providers/tests/convert_golden_test.rs @@ -11,11 +11,9 @@ 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::options::{CallOptions, ResponseFormat, ToolChoice}; -use aimux_core::tool::{FunctionTool, Tool}; +use aimux_core::options::{CallOptions, ResponseFormat}; use aimux_providers::anthropic::convert::build_request_body_with_warnings as anthropic_build; -use aimux_providers::openai::convert::build_request_body_with_warnings as openai_build; use aimux_providers::openai::responses::convert::build_responses_request_body; fn user_prompt() -> LanguageModelPrompt { @@ -26,22 +24,6 @@ fn user_prompt() -> LanguageModelPrompt { }] } -fn weather_tool() -> FunctionTool { - FunctionTool { - name: "weather".to_string(), - description: Some("current weather".to_string()), - input_schema: json!({ - "type": "object", - "properties": { "location": { "type": "string" } }, - "required": ["location"], - "additionalProperties": false - }), - strict: None, - provider_options: None, - input_examples: None, - } -} - fn json_schema_format() -> ResponseFormat { ResponseFormat::Json { schema: Some(json!({ @@ -56,80 +38,6 @@ fn json_schema_format() -> ResponseFormat { // ── OpenAI Chat Completions ───────────────────────────────────────────────── -#[test] -fn openai_chat_golden() { - let options = CallOptions { - prompt: user_prompt(), - max_output_tokens: Some(2048), - temperature: Some(0.7), - reasoning: Some(aimux_core::types::ReasoningEffort::High), - tools: Some(vec![Tool::Function(weather_tool())]), - tool_choice: ToolChoice::Auto, - response_format: Some(json_schema_format()), - body_overrides: Some(json!({ "user": "override-me" })), - ..CallOptions::default() - }; - - let result = openai_build( - "o3-mini", - &options, - false, - "openai", - &aimux_providers::openai::OpenAICompatProfile::full(), - ) - .expect("openai chat build"); - - assert_eq!( - result.body, - json!({ - "model": "o3-mini", - "messages": [ { "role": "user", "content": "Hello" } ], - "max_completion_tokens": 2048, - "response_format": { - "type": "json_schema", - "json_schema": { - "schema": { - "type": "object", - "properties": { "answer": { "type": "string" } }, - "required": ["answer"] - }, - "name": "qa", - "description": "answer extraction", - "strict": true - } - }, - "reasoning_effort": "high", - "tools": [ - { - "type": "function", - "function": { - "name": "weather", - "description": "current weather", - "parameters": { - "type": "object", - "properties": { "location": { "type": "string" } }, - "required": ["location"], - "additionalProperties": false - } - } - } - ], - "tool_choice": "auto", - "user": "override-me" - }), - "openai chat body diverged: {}", - result.body - ); - // `temperature` is stripped for reasoning models with a warning; the - // body override `user` lands after built-in fields. - assert_eq!(result.body.get("temperature"), None); - assert_eq!( - serde_json::to_value(&result.warnings).unwrap(), - json!([{ "Unsupported": { "feature": "temperature", - "details": "temperature is not supported for reasoning models" } }]) - ); -} - // ── OpenAI Responses API ──────────────────────────────────────────────────── #[test] diff --git a/aimux-providers/tests/data_loss_regression_test.rs b/aimux-providers/tests/data_loss_regression_test.rs index 3810d3c05..458c2a4d4 100644 --- a/aimux-providers/tests/data_loss_regression_test.rs +++ b/aimux-providers/tests/data_loss_regression_test.rs @@ -22,6 +22,7 @@ //! against re-reads of the cassette (a re-read would pass even if both the //! producer and the assertion regressed together). +use aimux_core::tool::{RawToolCall, ToolResult}; use std::collections::BTreeMap; use std::fs; use std::path::PathBuf; @@ -133,6 +134,7 @@ fn opts() -> CallOptions { fn opts_with_tools(tools: Vec) -> CallOptions { let mut o = opts(); o.tools = Some(tools); + o.tool_choice = Some(aimux_core::tool::ToolChoice::Auto); o } @@ -140,7 +142,7 @@ fn provider_tool(id: &str, name: &str) -> Tool { Tool::Provider(ProviderTool { id: id.to_string(), name: name.to_string(), - args: json!({}), + args: serde_json::Map::new(), }) } @@ -196,14 +198,13 @@ fn reasonings(content: &[GenerateContent]) -> Vec<(&str, Option<&Value>)> { .collect() } -/// `(tool_call_id, tool_name, input, provider_executed, dynamic, thought_signature, metadata)` +/// `(tool_call_id, tool_name, input, provider_executed, dynamic, metadata)` type ToolCallView<'a> = ( &'a str, &'a str, Value, Option, Option, - Option<&'a str>, Option<&'a Value>, ); @@ -211,15 +212,14 @@ fn tool_calls(content: &[GenerateContent]) -> Vec> { content .iter() .filter_map(|c| match c { - GenerateContent::ToolCall { + GenerateContent::ToolCall(RawToolCall { tool_call_id, tool_name, input, provider_executed, dynamic, - thought_signature, provider_metadata, - } => { + }) => { // Provider results intentionally carry the exact wire string; // parse only in this assertion helper so nested data-loss // checks remain readable without weakening that boundary. @@ -231,7 +231,6 @@ fn tool_calls(content: &[GenerateContent]) -> Vec> { parsed_input, *provider_executed, *dynamic, - thought_signature.as_deref(), provider_metadata.as_ref(), )) } @@ -254,7 +253,7 @@ fn tool_results(content: &[GenerateContent]) -> Vec> { content .iter() .filter_map(|c| match c { - GenerateContent::ToolResult { + GenerateContent::ToolResult(ToolResult { tool_call_id, tool_name, result, @@ -262,7 +261,7 @@ fn tool_results(content: &[GenerateContent]) -> Vec> { dynamic, provider_metadata, .. - } => Some(( + }) => Some(( tool_call_id.as_str(), tool_name.as_str(), result, @@ -546,7 +545,7 @@ async fn finding_5_gemini_thought_signature_reaches_provider_metadata() { } /// `thoughtSignature` on a `functionCall` part must reach -/// `ToolCall.thought_signature` — that is the field the follow-up turn echoes. +/// `providerMetadata.google.thoughtSignature` for use in follow-up provider options. #[tokio::test] async fn finding_5_gemini_function_call_thought_signature_round_trips() { let c = cassette("gemini/two_arg_rewrites_chain_blocking_0.json"); @@ -564,12 +563,7 @@ async fn finding_5_gemini_function_call_thought_signature_round_trips() { assert_eq!(calls[0].1, "add"); assert_eq!(calls[0].2, json!({ "x": 1, "y": 1 })); assert_eq!( - calls[0].5, - Some("signature_REDACTED_1"), - "the functionCall thoughtSignature must land on ToolCall.thought_signature" - ); - assert_eq!( - calls[0].6.expect("providerMetadata")["google"]["thoughtSignature"], + calls[0].5.expect("providerMetadata")["google"]["thoughtSignature"], json!("signature_REDACTED_1") ); } @@ -1033,7 +1027,7 @@ async fn finding_2_anthropic_mcp_tool_use_and_result_are_dynamic_and_named() { // mcp_tool_use → provider-executed + dynamic + serverName metadata. let calls = tool_calls(&result.content); assert_eq!(calls.len(), 1); - let (id, name, ref input, provider_executed, dynamic, _, meta) = calls[0]; + let (id, name, ref input, provider_executed, dynamic, meta) = calls[0]; assert_eq!(id, "mcptoolu_01SAss3KEwASziHZoMR6HcZU"); assert_eq!(name, "ask_question"); assert_eq!(input["repoName"], json!("pydantic/pydantic-ai")); diff --git a/aimux-providers/tests/deepseek_chat_test.rs b/aimux-providers/tests/deepseek_chat_test.rs index 36729ff0b..79c399d9c 100644 --- a/aimux-providers/tests/deepseek_chat_test.rs +++ b/aimux-providers/tests/deepseek_chat_test.rs @@ -26,6 +26,7 @@ //! overridden with the mock server's root URI (no `/v1` suffix), so the //! resulting request path is `/chat/completions`. +use aimux_core::tool::RawToolCall; use std::collections::HashMap; use futures::StreamExt; @@ -409,18 +410,15 @@ async fn should_extract_tool_call_content() { // The tool-call fixture has empty content and one tool call. assert_eq!(result.content.len(), 1); match &result.content[0] { - GenerateContent::ToolCall { + GenerateContent::ToolCall(RawToolCall { tool_call_id, tool_name, input, .. - } => { + }) => { assert_eq!(tool_call_id, "call_00_9V0vrf86Pc9aelHCJMZqnJBo"); assert_eq!(tool_name, "weather"); - assert_eq!( - input, - &Value::String(r#"{"location": "San Francisco"}"#.into()) - ); + assert_eq!(input, r#"{"location": "San Francisco"}"#); } other => panic!("expected ToolCall, got {other:?}"), } @@ -745,22 +743,19 @@ async fn should_stream_tool_call() { let tool_calls: Vec<_> = parts .iter() .filter_map(|p| match p { - StreamPart::ToolCall { + StreamPart::ToolCall(RawToolCall { tool_call_id, tool_name, input, .. - } => Some((tool_call_id, tool_name, input)), + }) => Some((tool_call_id, tool_name, input)), _ => None, }) .collect(); assert_eq!(tool_calls.len(), 1); assert_eq!(tool_calls[0].0, "call_1"); assert_eq!(tool_calls[0].1, "weather"); - assert_eq!( - tool_calls[0].2, - &Value::String(r#"{"location": "San Francisco"}"#.into()) - ); + assert_eq!(tool_calls[0].2, r#"{"location": "San Francisco"}"#); // The last part should be Finish with ToolCalls. match parts.last() { diff --git a/aimux-providers/tests/e2e_test.rs b/aimux-providers/tests/e2e_test.rs index 3f9e6e566..4cd5a9746 100644 --- a/aimux-providers/tests/e2e_test.rs +++ b/aimux-providers/tests/e2e_test.rs @@ -840,7 +840,7 @@ async fn e2e_openai_stream_tool_calls() { let has_tool_part = parts.iter().any(|p| { matches!( p, - StreamPart::ToolCall { .. } + StreamPart::ToolCall(_) | StreamPart::ToolInputStart { .. } | StreamPart::ToolInputDelta { .. } | StreamPart::ToolInputEnd { .. } @@ -857,9 +857,9 @@ async fn e2e_openai_stream_tool_calls() { // If a complete ToolCall part exists, verify its fields let tool_call = parts.iter().find_map(|p| match p { - StreamPart::ToolCall { + StreamPart::ToolCall(aimux_core::tool::ToolCall { tool_name, input, .. - } => Some((tool_name, input)), + }) => Some((tool_name, input)), _ => None, }); if let Some((tool_name, input)) = tool_call { diff --git a/aimux-providers/tests/google_model_test.rs b/aimux-providers/tests/google_model_test.rs deleted file mode 100644 index e269d81e6..000000000 --- a/aimux-providers/tests/google_model_test.rs +++ /dev/null @@ -1,2700 +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 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::stream_part::StreamPart; -use aimux_core::tool::FunctionTool; -use aimux_core::types::FinishReasonUnified; - -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 { - tool_call_id, - tool_name, - input, - .. - } => { - assert_eq!(tool_call_id, "call-1"); - assert_eq!(tool_name, "weather"); - assert_eq!(input, &json!(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 { - tool_call_id, - tool_name, - input, - thought_signature, - .. - } => { - assert_eq!(tool_call_id, "call-1"); - assert_eq!(tool_name, "weather"); - assert_eq!(input, &json!(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 { - tool_call_id, - tool_name, - input, - .. - } => { - assert_eq!(tool_call_id, ""); - assert_eq!(tool_name, "weather"); - assert_eq!(input, &json!(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 { - 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 { - tool_call_id, - tool_name, - input, - .. - } => { - assert_eq!(tool_call_id, "call-2"); - assert_eq!(tool_name, "calendar"); - assert_eq!(input, &json!(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 { 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 { 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 { tool_call_id, tool_name, input, .. } - if tool_call_id == "call-1" - && tool_name == "weather" - && input == &json!(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 { 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 { 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 { 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(json!({ - "google": { - "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(json!({ - "google": { - "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 c05de148d..000000000 --- a/aimux-providers/tests/google_provider_tools_test.rs +++ /dev/null @@ -1,2595 +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 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, StreamResult}; -use aimux_core::stream_part::StreamPart; -use aimux_core::types::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, Value)> { - parts - .iter() - .filter_map(|p| match p { - StreamPart::ToolCall { - 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 { - 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 { - 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 { - 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 { - 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 { - tool_name, - provider_metadata, - .. - } - | GenerateContent::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 { - 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 == json!(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!( - call.provider_metadata.as_ref().expect("call metadata")["google"], - json!({ - "serverToolCallId": call.tool_call_id, - "serverToolType": "code_execution", - }) - ); - let raw_results: Vec<_> = result - .raw - .content - .iter() - .filter_map(|content| match content { - GenerateContent::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, - json!(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, json!(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!( - meta[0]["google"], - json!({ - "serverToolCallId": "server-call-1", - "serverToolType": "GOOGLE_SEARCH_WEB", - "thoughtSignature": "sig-abc", - }) - ); - assert_eq!( - meta[1]["google"], - 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 == json!(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 { - tool_call_id, - tool_name, - provider_executed: Some(true), - provider_metadata: Some(metadata), - .. - } if tool_name == "runCode" - && metadata["google"] == json!({ - "serverToolCallId": tool_call_id, - "serverToolType": "code_execution", - }) - ))); - assert!(parts.iter().any(|part| matches!( - part, - StreamPart::ToolResult { - tool_call_id, - tool_name, - provider_metadata: Some(metadata), - .. - } if tool_name == "runCode" - && metadata["google"] == 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, json!(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 { - tool_name, - provider_metadata, - .. - } - | StreamPart::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!( - meta[0]["google"], - json!({ - "serverToolCallId": "server-call-1", - "serverToolType": "GOOGLE_SEARCH_WEB", - "thoughtSignature": "sig-abc", - }) - ); - assert_eq!( - meta[1]["google"], - 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/groq_test.rs b/aimux-providers/tests/groq_test.rs index 276f3294b..3cc114448 100644 --- a/aimux-providers/tests/groq_test.rs +++ b/aimux-providers/tests/groq_test.rs @@ -18,6 +18,7 @@ //! browser_search tool, stream_options, etc.). Reasoning effort is a direct //! passthrough — no vendor normalization. +use aimux_core::tool::RawToolCall; use futures::StreamExt; use serde_json::{Value, json}; use wiremock::matchers::{method, path}; @@ -702,34 +703,6 @@ mod prepare_tools { assert_eq!(body["tools"][0]["function"]["parameters"]["type"], "object"); } - /// TS: "should add warnings for unsupported provider-defined tools" - #[tokio::test] - async fn unsupported_provider_tool_warning() { - let server = MockServer::start().await; - mock_json(&server, text_completion_body()).await; - - let model = make_provider(&server); - - let tool = Tool::Provider(aimux_core::tool::ProviderTool { - id: "some.unsupported_tool".to_string(), - name: "unsupported_tool".to_string(), - args: json!({}), - }); - let options = CallOptions { - tools: Some(vec![tool]), - ..default_options(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - // The unsupported tool should produce a warning. - assert!(result.warnings.iter().any(|w| match w { - aimux_core::types::Warning::Unsupported { feature, .. } => { - feature.contains("some.unsupported_tool") - } - _ => false, - })); - } - /// TS: "should pass through strict mode when strict is true" #[tokio::test] async fn strict_mode_true() { @@ -813,15 +786,13 @@ mod prepare_tools { let tool = FunctionTool::new("testFunction", json!({})).with_description("Test"); let options = CallOptions { tools: Some(vec![Tool::from(tool)]), - tool_choice: ToolChoice::Auto, + tool_choice: Some(ToolChoice::Auto), ..default_options(test_prompt()) }; model.do_generate(&options).await.unwrap(); let body = first_request_body(&server).await; - // tool_choice is only sent when not Auto (default) - // Actually, the OpenAI converter sends tool_choice for Auto too - assert!(body.get("tool_choice").is_some() || body.get("tool_choice").is_none()); + assert_eq!(body["tool_choice"], "auto"); } /// TS: "should handle tool choice 'required'" @@ -835,7 +806,7 @@ mod prepare_tools { let tool = FunctionTool::new("testFunction", json!({})).with_description("Test"); let options = CallOptions { tools: Some(vec![Tool::from(tool)]), - tool_choice: ToolChoice::Required, + tool_choice: Some(ToolChoice::Required), ..default_options(test_prompt()) }; model.do_generate(&options).await.unwrap(); @@ -855,7 +826,7 @@ mod prepare_tools { let tool = FunctionTool::new("testFunction", json!({})).with_description("Test"); let options = CallOptions { tools: Some(vec![Tool::from(tool)]), - tool_choice: ToolChoice::None, + tool_choice: Some(ToolChoice::None), ..default_options(test_prompt()) }; model.do_generate(&options).await.unwrap(); @@ -875,9 +846,9 @@ mod prepare_tools { let tool = FunctionTool::new("testFunction", json!({})).with_description("Test"); let options = CallOptions { tools: Some(vec![Tool::from(tool)]), - tool_choice: ToolChoice::Tool { + tool_choice: Some(ToolChoice::Tool { tool_name: "testFunction".to_string(), - }, + }), ..default_options(test_prompt()) }; model.do_generate(&options).await.unwrap(); @@ -886,138 +857,6 @@ mod prepare_tools { assert_eq!(body["tool_choice"]["type"], "function"); assert_eq!(body["tool_choice"]["function"]["name"], "testFunction"); } - - /// TS: "should handle browser search tool with supported model" - #[tokio::test] - async fn browser_search_supported_model() { - let server = MockServer::start().await; - mock_json(&server, text_completion_body()).await; - - let model = provider( - "groq", - Some("test-api-key".to_string()), - "openai/gpt-oss-120b", - Some(ProviderOptions { - base_url: Some(server.uri()), - ..Default::default() - }), - ) - .expect("groq provider should build"); - - let tool = Tool::Provider(aimux_core::tool::ProviderTool { - id: "groq.browser_search".to_string(), - name: "browser_search".to_string(), - args: json!({}), - }); - let options = CallOptions { - tools: Some(vec![tool]), - ..default_options(test_prompt()) - }; - model.do_generate(&options).await.unwrap(); - - let body = first_request_body(&server).await; - assert_eq!(body["tools"][0]["type"], "browser_search"); - } - - /// TS: "should warn when browser search is used with unsupported model" - #[tokio::test] - async fn browser_search_unsupported_model() { - let server = MockServer::start().await; - mock_json(&server, text_completion_body()).await; - - let model = make_provider(&server); - - let tool = Tool::Provider(aimux_core::tool::ProviderTool { - id: "groq.browser_search".to_string(), - name: "browser_search".to_string(), - args: json!({}), - }); - let options = CallOptions { - tools: Some(vec![tool]), - ..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, details, .. - } => { - feature.contains("groq.browser_search") - && details.as_ref().is_some_and(|d| d.contains("gemma2-9b-it")) - } - _ => false, - })); - } - - /// TS: "should handle mixed tools with model validation" - #[tokio::test] - async fn mixed_tools_with_browser_search() { - let server = MockServer::start().await; - mock_json(&server, text_completion_body()).await; - - let model = provider( - "groq", - Some("test-api-key".to_string()), - "openai/gpt-oss-20b", - Some(ProviderOptions { - base_url: Some(server.uri()), - ..Default::default() - }), - ) - .expect("groq provider should build"); - - let func_tool = FunctionTool::new("test-tool", json!({"type":"object","properties":{}})) - .with_description("A test tool"); - let browser_tool = Tool::Provider(aimux_core::tool::ProviderTool { - id: "groq.browser_search".to_string(), - name: "browser_search".to_string(), - args: json!({}), - }); - let options = CallOptions { - tools: Some(vec![Tool::from(func_tool), browser_tool]), - ..default_options(test_prompt()) - }; - model.do_generate(&options).await.unwrap(); - - let body = first_request_body(&server).await; - assert_eq!(body["tools"][0]["type"], "function"); - assert_eq!(body["tools"][0]["function"]["name"], "test-tool"); - assert_eq!(body["tools"][1]["type"], "browser_search"); - } - - /// TS: "should validate all browser search supported models" - #[tokio::test] - async fn browser_search_all_supported_models() { - for model_id in &["openai/gpt-oss-20b", "openai/gpt-oss-120b"] { - let server = MockServer::start().await; - mock_json(&server, text_completion_body()).await; - - let model = provider( - "groq", - Some("test-api-key".to_string()), - model_id, - Some(ProviderOptions { - base_url: Some(server.uri()), - ..Default::default() - }), - ) - .expect("groq provider should build"); - - let tool = Tool::Provider(aimux_core::tool::ProviderTool { - id: "groq.browser_search".to_string(), - name: "browser_search".to_string(), - args: json!({}), - }); - let options = CallOptions { - tools: Some(vec![tool]), - ..default_options(test_prompt()) - }; - model.do_generate(&options).await.unwrap(); - - let body = first_request_body(&server).await; - assert_eq!(body["tools"][0]["type"], "browser_search"); - } - } } // ════════════════════════════════════════════════════════════════════════════ @@ -1081,11 +920,11 @@ mod do_generate { .unwrap(); let tool_call = result.content.iter().find_map(|c| match c { - GenerateContent::ToolCall { + GenerateContent::ToolCall(RawToolCall { tool_call_id, tool_name, .. - } => Some((tool_call_id.clone(), tool_name.clone())), + }) => Some((tool_call_id.clone(), tool_name.clone())), _ => None, }); let (id, name) = tool_call.expect("should have tool call"); @@ -1435,9 +1274,9 @@ mod do_generate { ); let options = CallOptions { tools: Some(vec![Tool::from(tool)]), - tool_choice: ToolChoice::Tool { + tool_choice: Some(ToolChoice::Tool { tool_name: "test-tool".to_string(), - }, + }), ..default_options(test_prompt()) }; model.do_generate(&options).await.unwrap(); @@ -1725,18 +1564,18 @@ mod do_stream { // Should have a ToolCall part let tool_call = parts.iter().find_map(|p| match p { - StreamPart::ToolCall { + StreamPart::ToolCall(RawToolCall { tool_call_id, tool_name, input, .. - } => Some((tool_call_id.clone(), tool_name.clone(), input.clone())), + }) => Some((tool_call_id.clone(), tool_name.clone(), input.clone())), _ => None, }); let (id, name, input) = tool_call.expect("should have tool call"); assert_eq!(id, "call_abc"); assert_eq!(name, "test-tool"); - assert_eq!(input, Value::String(r#"{"value":"Sparkle Day"}"#.into())); + assert_eq!(input, r#"{"value":"Sparkle Day"}"#); // Should have tool-calls finish reason let finish = parts.iter().find_map(|p| match p { @@ -1790,13 +1629,10 @@ mod do_stream { let parts = collect_stream(result).await; let tool_call = parts.iter().find_map(|p| match p { - StreamPart::ToolCall { input, .. } => Some(input.clone()), + StreamPart::ToolCall(RawToolCall { input, .. }) => Some(input.clone()), _ => None, }); - assert_eq!( - tool_call.unwrap(), - Value::String(r#"{"value":"Sparkle Day"}"#.into()) - ); + assert_eq!(tool_call.unwrap(), r#"{"value":"Sparkle Day"}"#); } /// TS: "should stream usage from x_groq.usage" diff --git a/aimux-providers/tests/huggingface_responses_test.rs b/aimux-providers/tests/huggingface_responses_test.rs index 52fe83bda..404e4ae21 100644 --- a/aimux-providers/tests/huggingface_responses_test.rs +++ b/aimux-providers/tests/huggingface_responses_test.rs @@ -16,6 +16,7 @@ //! overridden with the mock server's root URI (no `/v1` suffix), so the //! resulting request path is `/responses`. +use aimux_core::tool::{RawToolCall, ToolResult}; use std::collections::HashMap; use futures::StreamExt; @@ -29,10 +30,9 @@ 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, ResponseFormat, Tool, ToolChoice}; +use aimux_core::options::{CallOptions, ResponseFormat}; use aimux_core::result::GenerateContent; use aimux_core::stream_part::StreamPart; -use aimux_core::tool::FunctionTool; use aimux_core::types::FinishReasonUnified; use aimux_providers::huggingface::responses::convert_to_huggingface_responses_messages; @@ -536,27 +536,24 @@ async fn should_handle_mcp_tools_with_annotations() { // emits it as a separate content item too). assert_eq!(result.content.len(), 5); match &result.content[0] { - GenerateContent::ToolCall { + GenerateContent::ToolCall(RawToolCall { tool_call_id, tool_name, input, .. - } => { + }) => { assert_eq!(tool_call_id, "mcp_search_test"); assert_eq!(tool_name, "search"); - assert_eq!( - input, - &Value::String(r#"{"query": "San Francisco tech events"}"#.into()) - ); + assert_eq!(input, r#"{"query": "San Francisco tech events"}"#); } other => panic!("expected ToolCall at [0], got {other:?}"), } match &result.content[1] { - GenerateContent::ToolResult { + GenerateContent::ToolResult(ToolResult { tool_call_id, tool_name, .. - } => { + }) => { assert_eq!(tool_call_id, "mcp_search_test"); assert_eq!(tool_name, "search"); } @@ -1110,15 +1107,15 @@ async fn should_handle_function_call_tool_responses() { // output, so there is no paired ToolResult. assert_eq!(result.content.len(), 2); match &result.content[0] { - GenerateContent::ToolCall { + GenerateContent::ToolCall(RawToolCall { tool_call_id, tool_name, input, .. - } => { + }) => { assert_eq!(tool_call_id, "call_123"); assert_eq!(tool_name, "getWeather"); - assert_eq!(input, &Value::String(r#"{"location": "New York"}"#.into())); + assert_eq!(input, r#"{"location": "New York"}"#); } other => panic!("expected ToolCall at [0], got {other:?}"), } @@ -1192,27 +1189,24 @@ async fn should_stream_tool_calls() { other => panic!("expected ToolInputEnd, got {other:?}"), } match &parts[4] { - StreamPart::ToolCall { + StreamPart::ToolCall(RawToolCall { tool_call_id, tool_name, input, .. - } => { + }) => { assert_eq!(tool_call_id, "call_456"); assert_eq!(tool_name, "calculator"); - assert_eq!( - input, - &Value::String(r#"{"operation": "add", "a": 5, "b": 3}"#.into()) - ); + assert_eq!(input, r#"{"operation": "add", "a": 5, "b": 3}"#); } other => panic!("expected ToolCall, got {other:?}"), } match &parts[5] { - StreamPart::ToolResult { + StreamPart::ToolResult(ToolResult { tool_call_id, result, .. - } => { + }) => { assert_eq!(tool_call_id, "call_456"); assert_eq!(result, &json!("8")); } @@ -1640,131 +1634,6 @@ async fn should_send_provider_specific_options() { // Tool preparation // ════════════════════════════════════════════════════════════════════════════ -/// TS: tool preparation › "should prepare tools correctly" -#[tokio::test] -async fn should_prepare_tools_correctly() { - let server = MockServer::start().await; - mock_json( - &server, - json!({ - "id": "resp_tools", - "model": "deepseek-ai/DeepSeek-V3-0324", - "object": "response", - "created_at": 1741257730, - "status": "completed", - "error": null, - "instructions": null, - "max_output_tokens": null, - "metadata": null, - "tool_choice": "auto", - "tools": [], - "temperature": 1.0, - "top_p": 1.0, - "incomplete_details": null, - "usage": null, - "output": [], - "output_text": "Test" - }), - ) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("deepseek-ai/DeepSeek-V3-0324"); - - let mut options = default_options(test_prompt()); - options.tools = Some(vec![Tool::from( - FunctionTool::new( - "getWeather", - json!({ - "type": "object", - "properties": { "location": { "type": "string" } }, - "required": ["location"] - }), - ) - .with_description("Get weather information"), - )]); - options.tool_choice = ToolChoice::Tool { - tool_name: "getWeather".to_string(), - }; - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - - assert_eq!( - body["tools"], - json!([{ - "type": "function", - "name": "getWeather", - "description": "Get weather information", - "parameters": { - "type": "object", - "properties": { "location": { "type": "string" } }, - "required": ["location"] - } - }]) - ); - assert_eq!( - body["tool_choice"], - json!({ "type": "function", "function": { "name": "getWeather" } }) - ); -} - -/// TS: tool preparation › "should handle auto and required tool choices" -#[tokio::test] -async fn should_handle_auto_and_required_tool_choices() { - let server = MockServer::start().await; - mock_json( - &server, - json!({ - "id": "resp_tools", - "model": "deepseek-ai/DeepSeek-V3-0324", - "object": "response", - "created_at": 1741257730, - "status": "completed", - "error": null, - "instructions": null, - "max_output_tokens": null, - "metadata": null, - "tool_choice": "auto", - "tools": [], - "temperature": 1.0, - "top_p": 1.0, - "incomplete_details": null, - "usage": null, - "output": [], - "output_text": "Test" - }), - ) - .await; - - let provider = make_provider(&server); - let model = provider.responses_model("deepseek-ai/DeepSeek-V3-0324"); - - // Test auto - let mut options = default_options(test_prompt()); - options.tools = Some(vec![Tool::from(FunctionTool::new( - "test", - json!({ "type": "object" }), - ))]); - options.tool_choice = ToolChoice::Auto; - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - assert_eq!(body["tool_choice"], json!("auto")); - - // Test required - let mut options = default_options(test_prompt()); - options.tools = Some(vec![Tool::from(FunctionTool::new( - "test", - json!({ "type": "object" }), - ))]); - options.tool_choice = ToolChoice::Required; - - let result = model.do_generate(&options).await.expect("should succeed"); - let body = result.request_body.expect("body"); - assert_eq!(body["tool_choice"], json!("required")); -} - // ════════════════════════════════════════════════════════════════════════════ // Top-level-only media type resolution // ════════════════════════════════════════════════════════════════════════════ diff --git a/aimux-providers/tests/mistral_model_test.rs b/aimux-providers/tests/mistral_model_test.rs index e5583b71d..213aff92e 100644 --- a/aimux-providers/tests/mistral_model_test.rs +++ b/aimux-providers/tests/mistral_model_test.rs @@ -1,10 +1,11 @@ -//! Wiremock tests for the Mistral provider. +//! 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}; @@ -302,18 +303,15 @@ async fn should_extract_tool_call() { assert_eq!(result.content.len(), 1); match &result.content[0] { - GenerateContent::ToolCall { + GenerateContent::ToolCall(RawToolCall { tool_call_id, tool_name, input, .. - } => { + }) => { assert_eq!(tool_call_id, "gSIMJiOkT"); assert_eq!(tool_name, "weather"); - assert_eq!( - input, - &Value::String(r#"{"location": "San Francisco"}"#.into()) - ); + assert_eq!(input, r#"{"location": "San Francisco"}"#); } other => panic!("expected ToolCall, got {other:?}"), } @@ -389,7 +387,7 @@ async fn should_pass_tools_with_tool_choice_required() { let options = CallOptions { prompt: test_prompt(), tools: Some(vec![test_tool()]), - tool_choice: ToolChoice::Required, + tool_choice: Some(ToolChoice::Required), ..default_options(Vec::new()) }; @@ -431,9 +429,9 @@ async fn should_pass_tools_with_tool_choice_tool() { let options = CallOptions { prompt: test_prompt(), tools: Some(vec![test_tool()]), - tool_choice: ToolChoice::Tool { + tool_choice: Some(ToolChoice::Tool { tool_name: "test-tool".to_string(), - }, + }), ..default_options(Vec::new()) }; @@ -601,21 +599,18 @@ async fn should_stream_tool_call() { // Find the ToolCall part. let tool_call = parts.iter().find_map(|p| match p { - StreamPart::ToolCall { + StreamPart::ToolCall(RawToolCall { tool_call_id, tool_name, input, .. - } => Some((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, - &Value::String(r#"{"location": "San Francisco"}"#.into()) - ); + assert_eq!(input, r#"{"location": "San Francisco"}"#); // Should also have ToolInputStart, ToolInputDelta, ToolInputEnd. assert!( @@ -1221,28 +1216,28 @@ async fn should_extract_multiple_tool_calls() { assert_eq!(result.content.len(), 2); match &result.content[0] { - GenerateContent::ToolCall { + GenerateContent::ToolCall(RawToolCall { tool_call_id, tool_name, input, .. - } => { + }) => { assert_eq!(tool_call_id, "call-1"); assert_eq!(tool_name, "weather"); - assert_eq!(input, &Value::String(r#"{"city": "SF"}"#.into())); + assert_eq!(input, r#"{"city": "SF"}"#); } other => panic!("expected ToolCall, got {other:?}"), } match &result.content[1] { - GenerateContent::ToolCall { + GenerateContent::ToolCall(RawToolCall { tool_call_id, tool_name, input, .. - } => { + }) => { assert_eq!(tool_call_id, "call-2"); assert_eq!(tool_name, "time"); - assert_eq!(input, &Value::String(r#"{"zone": "PST"}"#.into())); + assert_eq!(input, r#"{"zone": "PST"}"#); } other => panic!("expected ToolCall, got {other:?}"), } diff --git a/aimux-providers/tests/mistral_remaining_test.rs b/aimux-providers/tests/mistral_remaining_test.rs index b06ba2332..b6558199c 100644 --- a/aimux-providers/tests/mistral_remaining_test.rs +++ b/aimux-providers/tests/mistral_remaining_test.rs @@ -22,7 +22,7 @@ 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::tool::FunctionTool; use aimux_core::types::FinishReasonUnified; use aimux_providers::mistral::convert::{ @@ -385,7 +385,6 @@ fn convert_assistant_tool_call_message() { tool_name: "tool-1".to_string(), input: json!({ "test": "This is a tool message" }), provider_executed: None, - thought_signature: None, provider_options: None, }, ], @@ -728,27 +727,6 @@ fn build_request_body_no_stream_flag() { 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() { diff --git a/aimux-providers/tests/open_responses_test.rs b/aimux-providers/tests/open_responses_test.rs index f2821d69a..e72ffdd47 100644 --- a/aimux-providers/tests/open_responses_test.rs +++ b/aimux-providers/tests/open_responses_test.rs @@ -9,6 +9,7 @@ //! `createTestServer`. Each test starts its own `MockServer` so parallel //! `#[tokio::test]` runs do not collide. +use aimux_core::tool::RawToolCall; use std::collections::HashMap; use futures::StreamExt; @@ -20,7 +21,7 @@ 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::options::{CallOptions, ResponseFormat, Tool}; use aimux_core::result::GenerateContent; use aimux_core::stream_part::StreamPart; use aimux_core::tool::FunctionTool; @@ -165,40 +166,6 @@ fn lmstudio_basic_json() -> Value { }) } -/// The `lmstudio-tool-call.1.json` fixture. -fn lmstudio_tool_call_json() -> Value { - json!({ - "id": "resp_930de53bd4b5933673481fa630f3dc5f58027a2c67598a2a", - "object": "response", - "created_at": 1769005553, - "status": "completed", - "incomplete_details": null, - "model": "mistralai/ministral-3-14b-reasoning", - "output": [ - { - "id": "fc_ru0kcno9erlzp8573yub", - "call_id": "call_2866856768160095", - "type": "function_call", - "name": "weather", - "arguments": "{\"location\":\"San Francisco\"}", - "status": "completed" - } - ], - "error": null, - "usage": { - "input_tokens": 1189, - "output_tokens": 11, - "total_tokens": 1200, - "input_tokens_details": { - "cached_tokens": 891 - }, - "output_tokens_details": { - "reasoning_tokens": 0 - } - } - }) -} - /// The `openai-pdf-input-file.1.json` fixture. fn openai_pdf_json() -> Value { json!({ @@ -1412,190 +1379,8 @@ mod do_generate_tests { // -- Tool call parsing tests -- - #[tokio::test] - async fn parse_tool_call_from_response() { - let server = MockServer::start().await; - mock_json(&server, lmstudio_tool_call_json()).await; - - let model = make_model(&server, "gemma-7b-it"); - let options = CallOptions { - tools: Some(vec![Tool::from( - FunctionTool::new( - "weather", - json!({ - "type": "object", - "properties": { - "location": {"type": "string", "description": "The location to get the weather for"} - }, - "required": ["location"] - }), - ) - .with_description("Get the weather in a location"), - )]), - tool_choice: ToolChoice::Required, - ..CallOptions::new(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - assert_eq!(result.content.len(), 1); - match &result.content[0] { - GenerateContent::ToolCall { - tool_call_id, - tool_name, - input, - .. - } => { - assert_eq!(tool_call_id, "call_2866856768160095"); - assert_eq!(tool_name, "weather"); - assert_eq!( - input, - &Value::String(r#"{"location":"San Francisco"}"#.into()) - ); - } - other => panic!("expected ToolCall, got {other:?}"), - } - } - - #[tokio::test] - async fn tool_call_finish_reason() { - let server = MockServer::start().await; - mock_json(&server, lmstudio_tool_call_json()).await; - - let model = make_model(&server, "gemma-7b-it"); - let options = CallOptions { - tools: Some(vec![Tool::from( - FunctionTool::new("weather", json!({"type": "object"})) - .with_description("Get the weather in a location"), - )]), - tool_choice: ToolChoice::Required, - ..CallOptions::new(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - assert_eq!(result.finish_reason.unified, FinishReasonUnified::ToolCalls); - assert_eq!(result.finish_reason.raw, None); - } - - #[tokio::test] - async fn tool_call_usage() { - let server = MockServer::start().await; - mock_json(&server, lmstudio_tool_call_json()).await; - - let model = make_model(&server, "gemma-7b-it"); - let options = CallOptions { - tools: Some(vec![Tool::from( - FunctionTool::new("weather", json!({"type": "object"})) - .with_description("Get the weather in a location"), - )]), - tool_choice: ToolChoice::Required, - ..CallOptions::new(test_prompt()) - }; - let result = model.do_generate(&options).await.unwrap(); - - assert_eq!(result.usage.input_tokens.total, Some(1189)); - assert_eq!(result.usage.input_tokens.cache_read, Some(891)); - assert_eq!(result.usage.input_tokens.no_cache, Some(298)); - assert_eq!(result.usage.output_tokens.total, Some(11)); - assert_eq!(result.usage.output_tokens.reasoning, Some(0)); - assert_eq!(result.usage.output_tokens.text, Some(11)); - } - // -- Tool choice tests -- - fn test_tool() -> Tool { - Tool::from( - FunctionTool::new( - "get_weather", - json!({ - "type": "object", - "properties": {"location": {"type": "string"}}, - "required": ["location"] - }), - ) - .with_description("Get the current weather"), - ) - } - - #[tokio::test] - async fn tool_choice_auto() { - let server = MockServer::start().await; - mock_json(&server, lmstudio_basic_json()).await; - - let model = make_model(&server, "gemma-7b-it"); - let options = CallOptions { - tools: Some(vec![test_tool()]), - tool_choice: ToolChoice::Auto, - ..CallOptions::new(test_prompt()) - }; - model.do_generate(&options).await.unwrap(); - - let request = &server.received_requests().await.unwrap()[0]; - let body: Value = serde_json::from_slice(&request.body).unwrap(); - // In Rust, ToolChoice::Auto is the default and is not emitted (matches - // TS behavior where undefined toolChoice is omitted). Tools are still present. - assert!(body.get("tool_choice").is_none()); - assert!(body.get("tools").is_some()); - } - - #[tokio::test] - async fn tool_choice_none() { - let server = MockServer::start().await; - mock_json(&server, lmstudio_basic_json()).await; - - let model = make_model(&server, "gemma-7b-it"); - let options = CallOptions { - tools: Some(vec![test_tool()]), - tool_choice: ToolChoice::None, - ..CallOptions::new(test_prompt()) - }; - model.do_generate(&options).await.unwrap(); - - let request = &server.received_requests().await.unwrap()[0]; - let body: Value = serde_json::from_slice(&request.body).unwrap(); - assert_eq!(body["tool_choice"], json!("none")); - } - - #[tokio::test] - async fn tool_choice_required() { - let server = MockServer::start().await; - mock_json(&server, lmstudio_basic_json()).await; - - let model = make_model(&server, "gemma-7b-it"); - let options = CallOptions { - tools: Some(vec![test_tool()]), - tool_choice: ToolChoice::Required, - ..CallOptions::new(test_prompt()) - }; - model.do_generate(&options).await.unwrap(); - - let request = &server.received_requests().await.unwrap()[0]; - let body: Value = serde_json::from_slice(&request.body).unwrap(); - assert_eq!(body["tool_choice"], json!("required")); - } - - #[tokio::test] - async fn tool_choice_specific_tool() { - let server = MockServer::start().await; - mock_json(&server, lmstudio_basic_json()).await; - - let model = make_model(&server, "gemma-7b-it"); - let options = CallOptions { - tools: Some(vec![test_tool()]), - tool_choice: ToolChoice::Tool { - tool_name: "get_weather".to_string(), - }, - ..CallOptions::new(test_prompt()) - }; - model.do_generate(&options).await.unwrap(); - - let request = &server.received_requests().await.unwrap()[0]; - let body: Value = serde_json::from_slice(&request.body).unwrap(); - assert_eq!( - body["tool_choice"], - json!({"type": "function", "name": "get_weather"}) - ); - } - // -- System messages tests -- #[tokio::test] @@ -2009,19 +1794,19 @@ mod do_stream_tests { // ToolCall let tool_call = parts.iter().find_map(|p| match p { - StreamPart::ToolCall { + StreamPart::ToolCall(RawToolCall { tool_call_id, tool_name, input, .. - } => Some((tool_call_id, tool_name, input)), + }) => Some((tool_call_id, tool_name, input)), _ => None, }); assert!(tool_call.is_some(), "should have ToolCall"); let (tc_id, tc_name, tc_input) = tool_call.unwrap(); assert_eq!(tc_id, "call_1"); assert_eq!(tc_name, "weather"); - assert_eq!(tc_input, &Value::String(r#"{"location":"SF"}"#.into())); + assert_eq!(tc_input, r#"{"location":"SF"}"#); // Finish with tool-calls reason let finish = parts.iter().find_map(|p| match p { diff --git a/aimux-providers/tests/openai_compatible_test.rs b/aimux-providers/tests/openai_compatible_test.rs index 8e49e65c1..e554f4287 100644 --- a/aimux-providers/tests/openai_compatible_test.rs +++ b/aimux-providers/tests/openai_compatible_test.rs @@ -15,6 +15,7 @@ //! Because the wrappers are structurally identical, a single macro generates //! the four tests per provider — DRY without hiding what is asserted. +use aimux_core::tool::RawToolCall; use futures::StreamExt; use serde_json::{Value, json}; use wiremock::matchers::{method, path}; @@ -592,10 +593,10 @@ macro_rules! openai_compatible_tool_tests { assert_eq!(result.content.len(), 1); match &result.content[0] { - GenerateContent::ToolCall { tool_call_id, tool_name, input, .. } => { + 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, &Value::String(r#"{"city":"SF"}"#.into())); + assert_eq!(input, r#"{"city":"SF"}"#); } other => panic!("expected ToolCall, got {:?}", other), } @@ -630,7 +631,7 @@ macro_rules! openai_compatible_tool_tests { let parts = collect_stream(result).await; let tool_call = parts.iter().find_map(|p| match p { - StreamPart::ToolCall { tool_call_id, tool_name, input, .. } => { + StreamPart::ToolCall(RawToolCall { tool_call_id, tool_name, input, .. }) => { Some((tool_call_id.clone(), tool_name.clone(), input.clone())) } _ => None, @@ -638,7 +639,7 @@ macro_rules! openai_compatible_tool_tests { let (id, name, input) = tool_call.expect("should have ToolCall"); assert_eq!(id, "call_abc"); assert_eq!(name, "get-weather"); - assert_eq!(input, Value::String(r#"{"city":"SF"}"#.into())); + assert_eq!(input, r#"{"city":"SF"}"#); } } } @@ -688,10 +689,10 @@ macro_rules! openai_compatible_tool_tests { assert_eq!(result.content.len(), 1); match &result.content[0] { - GenerateContent::ToolCall { tool_call_id, tool_name, input, .. } => { + 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, &Value::String(r#"{"city":"SF"}"#.into())); + assert_eq!(input, r#"{"city":"SF"}"#); } other => panic!("expected ToolCall, got {:?}", other), } @@ -726,7 +727,7 @@ macro_rules! openai_compatible_tool_tests { let parts = collect_stream(result).await; let tool_call = parts.iter().find_map(|p| match p { - StreamPart::ToolCall { tool_call_id, tool_name, input, .. } => { + StreamPart::ToolCall(RawToolCall { tool_call_id, tool_name, input, .. }) => { Some((tool_call_id.clone(), tool_name.clone(), input.clone())) } _ => None, @@ -734,7 +735,7 @@ macro_rules! openai_compatible_tool_tests { let (id, name, input) = tool_call.expect("should have ToolCall"); assert_eq!(id, "call_abc"); assert_eq!(name, "get-weather"); - assert_eq!(input, Value::String(r#"{"city":"SF"}"#.into())); + assert_eq!(input, r#"{"city":"SF"}"#); } } } 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 6e6f71d17..000000000 --- a/aimux-providers/tests/openai_convert_extended_test.rs +++ /dev/null @@ -1,958 +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::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> { - let mut h = std::collections::HashMap::new(); - h.insert("openai".to_string(), map); - Some(h) -} - -// �T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�T�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: Some( - json!({ "openai": { "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 = Some(json!({ "openai": { "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: Some(json!({ "openai": { "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: Some( - json!({ "openai": { "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 index 5f1222740..96235c409 100644 --- a/aimux-providers/tests/openai_convert_test.rs +++ b/aimux-providers/tests/openai_convert_test.rs @@ -558,9 +558,9 @@ mod build_request_body_tests { provider_options: None, input_examples: None, })]), - tool_choice: ToolChoice::Tool { + tool_choice: Some(ToolChoice::Tool { tool_name: "test-tool".to_string(), - }, + }), ..default_options(test_prompt()) }; let body = build_request_body("gpt-3.5-turbo", &options, false).unwrap(); @@ -751,7 +751,7 @@ mod build_request_body_tests { provider_options: None, input_examples: None, })]), - tool_choice: ToolChoice::Required, + tool_choice: Some(ToolChoice::Required), ..default_options(test_prompt()) }; let body = build_request_body("gpt-4o-2024-08-06", &options, false).unwrap(); @@ -787,9 +787,9 @@ mod build_request_body_tests { provider_options: None, input_examples: None, })]), - tool_choice: ToolChoice::Tool { + tool_choice: Some(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(); 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 560f93ccc..000000000 --- a/aimux-providers/tests/openai_model_extended_test.rs +++ /dev/null @@ -1,936 +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; -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> { - let mut h = std::collections::HashMap::new(); - h.insert("openai".to_string(), map); - Some(h) -} -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_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 abe140674..000000000 --- a/aimux-providers/tests/openai_model_test.rs +++ /dev/null @@ -1,2155 +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 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; - -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 { - tool_call_id, - tool_name, - input, - .. - } => { - assert_eq!(tool_call_id, "call_O17Uplv4lJvD6DVdIvFFeRMw"); - assert_eq!(tool_name, "test-tool"); - assert_eq!(input, &Value::String(r#"{"value":"Spark"}"#.into())); - } - 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 { 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 { tool_call_id, tool_name, input, .. } - if tool_call_id == "call_O17Uplv4lJvD6DVdIvFFeRMw" - && tool_name == "test-tool" - && input == &Value::String(r#"{"value":"Sparkle Day"}"#.into())) - }); - 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 { tool_call_id, input, .. } - if tool_call_id == "call_O17Uplv4lJvD6DVdIvFFeRMw" - && input == &Value::String(r#"{"value":"Sparkle Day"}"#.into())) - }); - 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 { - tool_call_id, - tool_name, - input, - .. - } => { - assert_eq!( - tool_call_id, - "chatcmpl-tool-b3b307239370432d9910d4b79b4dbbaa" - ); - assert_eq!(tool_name, "searchGoogle"); - assert_eq!( - input, - &Value::String(r#"{"query": "latest news on ai"}"#.into()) - ); - } - 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 { input, .. } => { - // The full accumulated string is: {"query": "test"}, "limit": 10} - // which is NOT valid JSON by itself. The implementation should - // fall back to storing the raw string as Value::String. - match input { - Value::String(s) => { - assert!( - s.contains("query") && s.contains("limit"), - "input string should contain both 'query' and 'limit': {s}" - ); - } - Value::Object(_) => { - // If it somehow parsed, still check for both keys - assert!(input.get("query").is_some(), "should have 'query'"); - } - other => panic!("expected String or Object, got {other:?}"), - } - } - 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 { tool_call_id, input, .. } - if tool_call_id == "call_abc123" - && input == &Value::String(r#"{"value":"hello"}"#.into())) - }); - 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 { tool_call_id, input, .. } - if tool_call_id == "call_O17Uplv4lJvD6DVdIvFFeRMw" - && input == &Value::String(r#"{"value":"Sparkle Day"}"#.into())) - })); - } - - // ── 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 = collect_stream(StreamResult { - stream: result.stream, - request_body: result.request_body, - response_headers: result.response_headers, - }) - .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 = collect_stream(StreamResult { - stream: result.stream, - request_body: result.request_body, - response_headers: result.response_headers, - }) - .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 { 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_responses_test.rs b/aimux-providers/tests/openai_responses_test.rs index 07c01a1c5..e1b323429 100644 --- a/aimux-providers/tests/openai_responses_test.rs +++ b/aimux-providers/tests/openai_responses_test.rs @@ -27,10 +27,9 @@ 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::options::CallOptions; 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::{OpenAIConfig, OpenAIProvider}; @@ -51,23 +50,6 @@ 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")) @@ -634,30 +616,6 @@ mod do_generate_request { // -- 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. @@ -817,69 +775,6 @@ mod do_generate_response { // -- 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 { - tool_call_id, - tool_name, - input, - .. - } => { - assert_eq!(tool_call_id, "call_abc"); - assert_eq!(tool_name, "weather"); - assert_eq!( - input, - &Value::String(r#"{"location":"San Francisco"}"#.into()) - ); - } - 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. @@ -1057,110 +952,6 @@ mod do_stream { // -- 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 { - tool_call_id, - tool_name, - input, - .. - } => { - assert_eq!(tool_call_id, "call_done"); - assert_eq!(tool_name, "weather"); - assert_eq!(input, &Value::String(r#"{"location":"Rome"}"#.into())); - } - 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. diff --git a/aimux-providers/tests/openrouter_test.rs b/aimux-providers/tests/openrouter_test.rs index 320979af6..76d4f3bbd 100644 --- a/aimux-providers/tests/openrouter_test.rs +++ b/aimux-providers/tests/openrouter_test.rs @@ -1,4 +1,4 @@ -//! Provider-specific tests for the OpenRouter provider. +//! Provider-specific tests for the OpenRouter provider. //! //! OpenRouter is a thin OpenAI-compatible wrapper over [`OpenAIProvider`]. The //! behaviours verified here are the ones the wrapper is responsible for: @@ -17,6 +17,7 @@ //! `/api/v1/chat/completions` — matching the path the real OpenRouter API //! (base `https://openrouter.ai/api/v1`) records in the cassettes. +use aimux_core::tool::RawToolCall; use futures::StreamExt; use serde_json::{Value, json}; use serial_test::serial; @@ -28,7 +29,7 @@ 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, Tool, ToolChoice}; +use aimux_core::options::{CallOptions, Tool}; use aimux_core::provider::Provider; use aimux_core::result::GenerateContent; use aimux_core::stream_part::StreamPart; @@ -399,47 +400,21 @@ async fn do_generate_extracts_tool_call() { assert_eq!(result.content.len(), 1); match &result.content[0] { - GenerateContent::ToolCall { + 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, &Value::String(r#"{"city":"SF"}"#.into())); + assert_eq!(input, r#"{"city":"SF"}"#); } other => panic!("expected ToolCall, got {other:?}"), } assert_eq!(result.finish_reason.unified, FinishReasonUnified::ToolCalls); } -/// Tool choice `required` is forwarded as `"required"`. -#[tokio::test] -async fn tool_choice_required_forwarded() { - 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("openai/gpt-4o-mini"); - - let tool = FunctionTool::new("get-weather", json!({})).with_description("Test"); - let options = CallOptions { - tools: Some(vec![Tool::from(tool)]), - tool_choice: ToolChoice::Required, - ..default_options(test_prompt()) - }; - 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["tool_choice"], "required"); -} - /// do_stream returns text deltas. #[tokio::test] async fn do_stream_returns_text() { @@ -527,18 +502,18 @@ async fn do_stream_emits_tool_call() { let parts = collect_stream(result).await; let tool_call = parts.iter().find_map(|p| match p { - StreamPart::ToolCall { + StreamPart::ToolCall(RawToolCall { tool_call_id, tool_name, input, .. - } => Some((tool_call_id.clone(), tool_name.clone(), input.clone())), + }) => 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, Value::String(r#"{"city":"SF"}"#.into())); + assert_eq!(input, r#"{"city":"SF"}"#); } /// A 401 response maps to `AiMuxError::ApiCall` (401 in `status_code`). @@ -645,7 +620,7 @@ mod conformance { fn has_tool_call(content: &[GenerateContent]) -> bool { content .iter() - .any(|c| matches!(c, GenerateContent::ToolCall { .. })) + .any(|c| matches!(c, GenerateContent::ToolCall(_))) } fn has_reasoning(content: &[GenerateContent]) -> bool { diff --git a/aimux-providers/tests/vertex_anthropic_test.rs b/aimux-providers/tests/vertex_anthropic_test.rs index 65e4798ef..7e0c9767a 100644 --- a/aimux-providers/tests/vertex_anthropic_test.rs +++ b/aimux-providers/tests/vertex_anthropic_test.rs @@ -8,6 +8,7 @@ //! tests mock the Vertex AI endpoints and never touch the public network or //! real credentials. +use aimux_core::tool::RawToolCall; use futures::StreamExt; use serde_json::{Value, json}; use wiremock::matchers::{header, method, path}; @@ -18,7 +19,7 @@ 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::options::CallOptions; use aimux_core::result::{GenerateContent, StreamResult}; use aimux_core::stream_part::StreamPart; use aimux_core::types::FinishReasonUnified; @@ -185,9 +186,9 @@ async fn vertex_anthropic_generate_keeps_direct_caller_metadata() { .await .unwrap(); let metadata = result.content.iter().find_map(|part| match part { - GenerateContent::ToolCall { + GenerateContent::ToolCall(RawToolCall { provider_metadata, .. - } => provider_metadata.as_ref(), + }) => provider_metadata.as_ref(), _ => None, }); assert_eq!( @@ -344,112 +345,6 @@ async fn vertex_anthropic_stream_text() { } // ═════════════════════════════════════════════════════════════════════════════ -#[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 { - 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() { @@ -496,9 +391,9 @@ async fn vertex_anthropic_stream_keeps_programmatic_caller_metadata() { ) .await; let metadata = parts.iter().find_map(|part| match part { - StreamPart::ToolCall { + StreamPart::ToolCall(RawToolCall { provider_metadata, .. - } => provider_metadata.as_ref(), + }) => provider_metadata.as_ref(), _ => None, }); assert_eq!( diff --git a/aimux-providers/tests/vertex_model_test.rs b/aimux-providers/tests/vertex_model_test.rs index 295562172..8e076b7e7 100644 --- a/aimux-providers/tests/vertex_model_test.rs +++ b/aimux-providers/tests/vertex_model_test.rs @@ -9,6 +9,7 @@ //! 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}; @@ -17,11 +18,9 @@ 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::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; @@ -100,12 +99,12 @@ fn as_text(item: &GenerateContent) -> &str { fn as_tool_call(item: &GenerateContent) -> (&str, &str, &str) { match item { - GenerateContent::ToolCall { + GenerateContent::ToolCall(RawToolCall { tool_call_id, tool_name, input, .. - } => (tool_call_id, tool_name, input), + }) => (tool_call_id, tool_name, input), _ => panic!("expected ToolCall content, got {item:?}"), } } @@ -205,109 +204,11 @@ async fn vertex_generate_tool_call() { let (id, name, input) = as_tool_call(&result.content[0]); assert_eq!(id, "call_1"); assert_eq!(name, "getWeather"); - assert_eq!(input, &json!(r#"{"location":"Tokyo"}"#)); + 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!( - call_metadata["googleVertex"], - 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 { tool_name, .. } if tool_name == "runCode" - ))); - let result_ids: Vec<&str> = result - .raw - .content - .iter() - .filter_map(|content| match content { - GenerateContent::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; @@ -369,14 +270,14 @@ async fn vertex_server_tool_call_and_response_pass_core_generate_boundary() { ); assert!(result.raw.content.iter().any(|content| matches!( content, - GenerateContent::ToolResult { + GenerateContent::ToolResult(ToolResult { tool_call_id, tool_name, result, dynamic: None, provider_metadata: Some(metadata), .. - } if tool_call_id == "server-call-1" + }) if tool_call_id == "server-call-1" && tool_name == "server:GOOGLE_SEARCH_WEB" && *result == json!({ "results": [{ "title": "Sunny" }] }) && metadata["googleVertex"] == json!({ @@ -428,21 +329,16 @@ async fn vertex_generate_tool_call_with_thought_signature() { assert_eq!(result.content.len(), 1); match &result.content[0] { - GenerateContent::ToolCall { + 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, &json!(r#"{"location":"Tokyo"}"#)); - assert_eq!( - thought_signature.as_deref(), - Some("EuIDCt8DARFNMg/aRDRK3THWhBjzltCEy5/VM6ImWLJU8oHmnC75abdcZBMH") - ); + assert_eq!(input, r#"{"location":"Tokyo"}"#); let metadata = provider_metadata.as_ref().expect("thought metadata"); assert_eq!( metadata["googleVertex"]["thoughtSignature"], @@ -752,18 +648,18 @@ async fn vertex_stream_tool_call() { let parts = collect_stream(result).await; let tool_call = parts.iter().find_map(|p| match p { - StreamPart::ToolCall { + StreamPart::ToolCall(RawToolCall { tool_call_id, tool_name, input, .. - } => Some((tool_call_id.clone(), tool_name.clone(), input.clone())), + }) => 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, json!(r#"{"location":"Tokyo"}"#)); + assert_eq!(input, r#"{"location":"Tokyo"}"#); } /// TS: response headers are exposed on the stream result. @@ -859,16 +755,16 @@ fn finish_provider_metadata(parts: &[StreamPart]) -> Option { } /// Collect `(tool_call_id, tool_name, input)` from streamed `ToolCall` parts. -fn stream_tool_calls(parts: &[StreamPart]) -> Vec<(String, String, Value)> { +fn stream_tool_calls(parts: &[StreamPart]) -> Vec<(String, String, String)> { parts .iter() .filter_map(|p| match p { - StreamPart::ToolCall { + StreamPart::ToolCall(RawToolCall { tool_call_id, tool_name, input, .. - } => Some((tool_call_id.clone(), tool_name.clone(), input.clone())), + }) => Some((tool_call_id.clone(), tool_name.clone(), input.clone())), _ => None, }) .collect() @@ -879,11 +775,11 @@ fn stream_tool_results(parts: &[StreamPart]) -> Vec<(String, Value)> { parts .iter() .filter_map(|p| match p { - StreamPart::ToolResult { + StreamPart::ToolResult(ToolResult { tool_call_id, result, .. - } => Some((tool_call_id.clone(), result.clone())), + }) => Some((tool_call_id.clone(), result.clone())), _ => None, }) .collect() @@ -906,173 +802,6 @@ fn stream_sources(parts: &[StreamPart]) -> Vec<(String, String, Option, .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 == json!(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 { - tool_call_id, - tool_name, - provider_metadata: Some(metadata), - .. - } if tool_name == "runCode" - && metadata["googleVertex"] == 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 { - tool_call_id, - tool_name, - provider_metadata: Some(metadata), - .. - } if tool_name == "runCode" - && metadata["googleVertex"] == 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() { @@ -1207,7 +936,7 @@ async fn vertex_stream_server_tool_call_and_response() { assert!(core_parts.iter().any(|part| matches!( part, - StreamPart::ToolCall { + StreamPart::ToolCall(aimux_core::tool::ToolCall { tool_call_id, tool_name, input, @@ -1216,7 +945,7 @@ async fn vertex_stream_server_tool_call_and_response() { invalid: None, provider_metadata: Some(metadata), .. - } if tool_call_id == "server-call-1" + }) 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" @@ -1225,14 +954,14 @@ async fn vertex_stream_server_tool_call_and_response() { ))); assert!(core_parts.iter().any(|part| matches!( part, - StreamPart::ToolResult { + StreamPart::ToolResult(ToolResult { tool_call_id, tool_name, result, dynamic: None, provider_metadata: Some(metadata), .. - } if tool_call_id == "server-call-1" + }) 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" diff --git a/aimux-providers/tests/xai_responses_test.rs b/aimux-providers/tests/xai_responses_test.rs index 24f936a74..573d396d1 100644 --- a/aimux-providers/tests/xai_responses_test.rs +++ b/aimux-providers/tests/xai_responses_test.rs @@ -10,6 +10,7 @@ //! or SSE response, creates an `XaiResponsesModel` via `XAIProvider`, calls //! `do_generate` / `do_stream`, and asserts on the result. +use aimux_core::tool::RawToolCall; use std::collections::HashMap; use futures::StreamExt; @@ -18,14 +19,14 @@ 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; use aimux_core::stream_part::StreamPart; -use aimux_core::tool::{FunctionTool, ProviderTool, Tool}; +use aimux_core::tool::FunctionTool; use aimux_core::types::ReasoningEffort; use aimux_providers::{XAIConfig, XAIProvider}; @@ -1035,515 +1036,6 @@ mod settings { 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 { - 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 { 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 { 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 { 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 { 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 { 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 { - 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 { - 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 { 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() { @@ -1583,15 +1075,15 @@ mod tools { let result = model.do_generate(&options).await.unwrap(); match &result.content[0] { - GenerateContent::ToolCall { + GenerateContent::ToolCall(RawToolCall { tool_call_id, tool_name, input, .. - } => { + }) => { assert_eq!(tool_call_id, "call_123"); assert_eq!(tool_name, "weather"); - assert_eq!(input, &Value::String(r#"{"location":"sf"}"#.into())); + assert_eq!(input, r#"{"location":"sf"}"#); } other => panic!("expected ToolCall, got {other:?}"), } @@ -1964,81 +1456,6 @@ mod do_stream { } } - /// 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 { - 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() { @@ -2099,20 +1516,18 @@ mod do_stream { .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 { .. })); + let tool_call = parts.iter().find(|p| matches!(p, StreamPart::ToolCall(_))); assert!(tool_call.is_some()); - if let Some(StreamPart::ToolCall { + if let Some(StreamPart::ToolCall(RawToolCall { tool_call_id, tool_name, input, .. - }) = tool_call + })) = tool_call { assert_eq!(tool_call_id, "call_123"); assert_eq!(tool_name, "weather"); - assert_eq!(input, &Value::String(r#"{"location":"sf"}"#.into())); + assert_eq!(input, r#"{"location":"sf"}"#); } // Finish reason should be tool-calls. @@ -2481,176 +1896,6 @@ mod prepare_tools { 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() { @@ -2720,48 +1965,6 @@ mod prepare_tools { 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() { @@ -2784,75 +1987,6 @@ mod prepare_tools { 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"); - } } // ════════════════════════════════════════════════════════════════════════════ @@ -2962,7 +2096,6 @@ mod convert_input { tool_name: "web_search".to_string(), input: json!({ "query": "weather" }), provider_executed: Some(true), - thought_signature: None, provider_options: None, }], ..Default::default() @@ -2983,7 +2116,6 @@ mod convert_input { tool_name: "weather".to_string(), input: json!({ "city": "Singapore" }), provider_executed: Some(false), - thought_signature: None, provider_options: Some(json!({ "xai": { "providerExecuted": true } })), diff --git a/aimux-providers/tests/xai_test.rs b/aimux-providers/tests/xai_test.rs index 71a4dfb4e..c77492c6e 100644 --- a/aimux-providers/tests/xai_test.rs +++ b/aimux-providers/tests/xai_test.rs @@ -1,4 +1,4 @@ -//! Rust translations of the AI SDK xAI provider chat-level tests. +//! 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 @@ -13,6 +13,7 @@ //! or SSE response, creates an `XaiModel` via `XAIProvider`, calls //! `do_generate` / `do_stream`, and asserts on the result. +use aimux_core::tool::RawToolCall; use std::collections::HashMap; use futures::StreamExt; @@ -25,10 +26,10 @@ 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::options::{CallOptions, ResponseFormat}; use aimux_core::result::GenerateContent; use aimux_core::stream_part::StreamPart; -use aimux_core::tool::{FunctionTool, ProviderTool, Tool}; +use aimux_core::tool::{FunctionTool, Tool}; use aimux_core::types::{FinishReasonUnified, ReasoningEffort}; use aimux_providers::xai::convert::supports_reasoning_effort; @@ -587,211 +588,6 @@ mod prepare_tools { 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() { @@ -878,47 +674,6 @@ mod prepare_tools { 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"); - } } // ════════════════════════════════════════════════════════════════════════════ @@ -1401,21 +1156,18 @@ mod do_generate { .unwrap(); let tool_call = result.content.iter().find_map(|c| match c { - GenerateContent::ToolCall { + GenerateContent::ToolCall(RawToolCall { tool_call_id, tool_name, input, .. - } => Some((tool_call_id.clone(), tool_name.clone(), input.clone())), + }) => 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, - Value::String(r#"{"location":"San Francisco"}"#.into()) - ); + assert_eq!(input, r#"{"location":"San Francisco"}"#); assert_eq!(result.finish_reason.unified, FinishReasonUnified::ToolCalls); } @@ -1518,52 +1270,6 @@ mod do_generate { 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() { @@ -2000,21 +1706,18 @@ mod do_stream { let parts = collect_stream(result).await; let tool_call = parts.iter().find_map(|p| match p { - StreamPart::ToolCall { + StreamPart::ToolCall(RawToolCall { tool_call_id, tool_name, input, .. - } => Some((tool_call_id.clone(), tool_name.clone(), input.clone())), + }) => 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, - Value::String(r#"{"location":"San Francisco"}"#.into()) - ); + assert_eq!(input, r#"{"location":"San Francisco"}"#); } /// TS: should pass the messages (stream request body) diff --git a/bindings/README.md b/bindings/README.md index 2a3f686f2..2d57b5169 100644 --- a/bindings/README.md +++ b/bindings/README.md @@ -111,17 +111,3 @@ gradle test The Java binding uses JNA (no native toolchain needed at build time); the native library ships as per-platform classifier JARs. See [RFC-0013](../rfc/0013-java-bindings.md) for details. - -## Contract Tests - -Shared JSON fixtures drive all languages, ensuring wire format consistency: - -```bash -# Rust side -cargo test -p aimux-core --test contract_test - -# Node side -node --experimental-strip-types contract-tests/run-node.ts -``` - -The fixtures are located at [contract-tests/fixtures/wire-format.json](../contract-tests/fixtures/wire-format.json). diff --git a/bindings/flutter/lib/types.dart b/bindings/flutter/lib/types.dart index 2eb4beb2f..52c564fc9 100644 --- a/bindings/flutter/lib/types.dart +++ b/bindings/flutter/lib/types.dart @@ -161,8 +161,6 @@ class ToolCall { // and mapped to the `dynamic` JSON key. @JsonKey(name: 'dynamic') final bool? isDynamic; - @JsonKey(name: 'thought_signature') - final String? thoughtSignature; /// Additional provider-specific metadata associated with this call. @JsonKey(name: 'provider_metadata', includeIfNull: false) final dynamic providerMetadata; @@ -177,7 +175,6 @@ class ToolCall { required this.input, this.providerExecuted, this.isDynamic, - this.thoughtSignature, this.providerMetadata, this.invalid, this.error, @@ -217,10 +214,6 @@ class RawToolCall { /// identifier, so the field is `isDynamic` and maps to the `dynamic` key. final bool? isDynamic; - /// Provider-assigned thought signature (e.g. Gemini `thoughtSignature`), - /// echoed back verbatim on the next turn. - final String? thoughtSignature; - /// Additional provider-specific metadata associated with this call. final dynamic providerMetadata; @@ -230,7 +223,6 @@ class RawToolCall { required this.input, this.providerExecuted, this.isDynamic, - this.thoughtSignature, this.providerMetadata, }); @@ -240,7 +232,6 @@ class RawToolCall { input: json['input'] as String, providerExecuted: json['provider_executed'] as bool?, isDynamic: json['dynamic'] as bool?, - thoughtSignature: json['thought_signature'] as String?, providerMetadata: json['provider_metadata'], ); @@ -253,7 +244,6 @@ class RawToolCall { input: input ?? this.input, providerExecuted: providerExecuted, isDynamic: isDynamic, - thoughtSignature: thoughtSignature, providerMetadata: providerMetadata, ); @@ -263,7 +253,6 @@ class RawToolCall { 'input': input, if (providerExecuted != null) 'provider_executed': providerExecuted, if (isDynamic != null) 'dynamic': isDynamic, - if (thoughtSignature != null) 'thought_signature': thoughtSignature, if (providerMetadata != null) 'provider_metadata': providerMetadata, }; } @@ -339,6 +328,14 @@ typedef RepairToolCall = FutureOr Function( // Tools // ───────────────────────────────────────────────────────────────────────────── +class FunctionToolInputExample { + final Map input; + FunctionToolInputExample({required this.input}); + factory FunctionToolInputExample.fromJson(Map json) => + FunctionToolInputExample(input: json['input'] as Map); + Map toJson() => {'input': input}; +} + /// A function tool definition. Mirrors `FunctionTool.ts`. @JsonSerializable() class FunctionTool { @@ -350,7 +347,7 @@ class FunctionTool { @JsonKey(name: 'provider_options') final Map? providerOptions; @JsonKey(name: 'input_examples') - final List>? inputExamples; + final List? inputExamples; FunctionTool({ required this.name, @@ -498,7 +495,6 @@ final class GenerateContentToolCall extends GenerateContent { final dynamic input; final bool? providerExecuted; final bool? isDynamic; - final String? thoughtSignature; final Map? providerMetadata; GenerateContentToolCall({ @@ -507,7 +503,6 @@ final class GenerateContentToolCall extends GenerateContent { required this.input, this.providerExecuted, this.isDynamic, - this.thoughtSignature, this.providerMetadata, }); @@ -521,7 +516,6 @@ final class GenerateContentToolCall extends GenerateContent { input: json['input'], providerExecuted: json['provider_executed'] as bool?, isDynamic: json['dynamic'] as bool?, - thoughtSignature: json['thought_signature'] as String?, providerMetadata: json['provider_metadata'] as Map?, ); @@ -534,7 +528,6 @@ final class GenerateContentToolCall extends GenerateContent { 'input': input, if (providerExecuted != null) 'provider_executed': providerExecuted, if (isDynamic != null) 'dynamic': isDynamic, - if (thoughtSignature != null) 'thought_signature': thoughtSignature, if (providerMetadata != null) 'provider_metadata': providerMetadata, }, }; @@ -1427,7 +1420,6 @@ final class StreamPartToolCall extends StreamPart { final dynamic input; final bool? providerExecuted; final bool? isDynamic; - final String? thoughtSignature; final Map? providerMetadata; /// Set by Core when the tool call stays invalid after optional repair. final bool? invalid; @@ -1440,7 +1432,6 @@ final class StreamPartToolCall extends StreamPart { required this.input, this.providerExecuted, this.isDynamic, - this.thoughtSignature, this.providerMetadata, this.invalid, this.error, @@ -1453,7 +1444,6 @@ final class StreamPartToolCall extends StreamPart { input: json['input'], providerExecuted: json['provider_executed'] as bool?, isDynamic: json['dynamic'] as bool?, - thoughtSignature: json['thought_signature'] as String?, providerMetadata: json['provider_metadata'] as Map?, invalid: json['invalid'] as bool?, @@ -1468,7 +1458,6 @@ final class StreamPartToolCall extends StreamPart { 'input': input, if (providerExecuted != null) 'provider_executed': providerExecuted, if (isDynamic != null) 'dynamic': isDynamic, - if (thoughtSignature != null) 'thought_signature': thoughtSignature, if (providerMetadata != null) 'provider_metadata': providerMetadata, if (invalid != null) 'invalid': invalid, if (error != null) 'error': error, @@ -1987,17 +1976,15 @@ final class ContentPartToolCall extends ContentPart { final String toolName; final dynamic input; final bool? providerExecuted; - final String? thoughtSignature; final Map? providerOptions; const ContentPartToolCall( - {required this.toolCallId, required this.toolName, required this.input, this.providerExecuted, this.thoughtSignature, this.providerOptions}); + {required this.toolCallId, required this.toolName, required this.input, this.providerExecuted, this.providerOptions}); static ContentPartToolCall fromJson(Map json) => ContentPartToolCall( toolCallId: json['tool_call_id'] as String, toolName: json['tool_name'] as String, input: json['input'], providerExecuted: json['provider_executed'] as bool?, - thoughtSignature: json['thought_signature'] as String?, providerOptions: json['provider_options'] as Map?, ); @override @@ -2007,7 +1994,6 @@ final class ContentPartToolCall extends ContentPart { 'tool_name': toolName, 'input': input, if (providerExecuted != null) 'provider_executed': providerExecuted, - if (thoughtSignature != null) 'thought_signature': thoughtSignature, if (providerOptions != null) 'provider_options': providerOptions, }; } diff --git a/bindings/flutter/lib/types.g.dart b/bindings/flutter/lib/types.g.dart index a0a8f56fa..1004fd158 100644 --- a/bindings/flutter/lib/types.g.dart +++ b/bindings/flutter/lib/types.g.dart @@ -57,7 +57,6 @@ ToolCall _$ToolCallFromJson(Map json) => ToolCall( input: json['input'], providerExecuted: json['provider_executed'] as bool?, isDynamic: json['dynamic'] as bool?, - thoughtSignature: json['thought_signature'] as String?, providerMetadata: json['provider_metadata'], invalid: json['invalid'] as bool?, error: json['error'], @@ -69,7 +68,6 @@ Map _$ToolCallToJson(ToolCall instance) => { 'input': instance.input, 'provider_executed': instance.providerExecuted, 'dynamic': instance.isDynamic, - 'thought_signature': instance.thoughtSignature, if (instance.providerMetadata != null) 'provider_metadata': instance.providerMetadata, 'invalid': instance.invalid, @@ -83,7 +81,7 @@ FunctionTool _$FunctionToolFromJson(Map json) => FunctionTool( strict: json['strict'] as bool?, providerOptions: json['provider_options'] as Map?, inputExamples: (json['input_examples'] as List?) - ?.map((e) => e as Map) + ?.map((e) => FunctionToolInputExample.fromJson(e as Map)) .toList(), ); @@ -94,7 +92,7 @@ Map _$FunctionToolToJson(FunctionTool instance) => 'input_schema': instance.inputSchema, 'strict': instance.strict, 'provider_options': instance.providerOptions, - 'input_examples': instance.inputExamples, + 'input_examples': instance.inputExamples?.map((e) => e.toJson()).toList(), }; ResponseMetadata _$ResponseMetadataFromJson(Map json) => diff --git a/bindings/flutter/test/contract_test.dart b/bindings/flutter/test/contract_test.dart deleted file mode 100644 index 561130b97..000000000 --- a/bindings/flutter/test/contract_test.dart +++ /dev/null @@ -1,156 +0,0 @@ -// Contract tests — validate the Dart binding against the shared wire-format -// fixtures in `contract-tests/fixtures/wire-format.json`, the same file the -// Rust, Go, Java, Kotlin, Python, Node and Swift contract tests read. -// -// Rust is the wire authority: it produces the JSON and the bindings consume it, -// so the direction that matters is DECODING — every fixture must decode into -// the Dart type it claims to describe. Encoding is checked as a Dart round-trip -// (decode → encode → decode) rather than by byte-comparing against the fixture, -// because Dart omits null fields that Rust emits explicitly; a byte comparison -// would fail on that alone and say nothing about type correctness. -// -// Pure serialization — no native library is loaded. - -import 'dart:convert'; -import 'dart:io'; - -import 'package:test/test.dart'; -import 'package:aimux/types.dart'; - -/// The fixture lives at the repo root; the test's working directory depends on -/// how the suite is invoked, so try the usual spots (mirrors the Java runner). -File _fixtureFile() { - const candidates = [ - '../../contract-tests/fixtures/wire-format.json', // cwd = bindings/flutter - 'contract-tests/fixtures/wire-format.json', // cwd = repo root - '../contract-tests/fixtures/wire-format.json', - ]; - for (final path in candidates) { - final file = File(path); - if (file.existsSync()) return file; - } - throw StateError('cannot find wire-format.json; tried $candidates'); -} - -List> _loadFixtures() => - (jsonDecode(_fixtureFile().readAsStringSync()) as List) - .cast>(); - -void main() { - group('shared wire-format fixtures', () { - test('every fixture decodes into its Dart type', () { - final fixtures = _loadFixtures(); - expect(fixtures, isNotEmpty, reason: 'no fixtures loaded'); - - for (final fixture in fixtures) { - final name = fixture['name'] as String; - final type = fixture['type'] as String; - final wire = jsonDecode(fixture['json'] as String); - - // Encoding a decoded fixture and decoding that again must produce the - // same encoding. Comparing encodings rather than objects is deliberate: - // these generated classes do not implement value equality, so `==` - // would compare identities and pass no matter what. The generated - // toJson() is shallow — nested models stay live objects — so each - // encoding is normalized through jsonEncode/jsonDecode, which invokes - // nested toJson() recursively. - Object? norm(Object? o) => jsonDecode(jsonEncode(o)); - final Object? first; - final Object? second; - - // A fixture type with no case here fails rather than being skipped: - // silent skipping is how a net grows holes. - switch (type) { - case 'ToolChoice': - first = norm(ToolChoice.fromJson(wire).toJson()); - second = norm(ToolChoice.fromJson(first).toJson()); - case 'StreamPart': - first = norm(StreamPart.fromJson(wire as Map).toJson()); - second = norm(StreamPart.fromJson(first as Map).toJson()); - case 'GenerateContent': - first = norm(GenerateContent.fromJson(wire as Map).toJson()); - second = norm(GenerateContent.fromJson(first as Map) - .toJson()); - case 'GenerateTextOptions': - first = norm(GenerateTextOptions.fromJson(wire as Map) - .toJson()); - second = norm(GenerateTextOptions.fromJson(first as Map) - .toJson()); - case 'TimeoutConfiguration': - first = norm(TimeoutConfiguration.fromJson(wire as Map) - .toJson()); - second = norm(TimeoutConfiguration.fromJson(first as Map) - .toJson()); - case 'ModelMessage': - first = norm(ModelMessage.fromJson(wire as Map).toJson()); - second = norm(ModelMessage.fromJson(first as Map).toJson()); - case 'Role': - first = norm(Role.fromJson(wire as String).toJson()); - second = norm(Role.fromJson(first as String).toJson()); - case 'FinishReasonUnified': - first = norm(FinishReasonUnified.fromJson(wire as String).toJson()); - second = norm(FinishReasonUnified.fromJson(first as String).toJson()); - case 'ReasoningEffort': - first = norm(ReasoningEffort.fromJson(wire as String).toJson()); - second = norm(ReasoningEffort.fromJson(first as String).toJson()); - default: - fail( - "fixture '$name' declares type '$type', which has no case in " - 'contract_test.dart — wire it up so the fixture is actually ' - 'checked against Dart', - ); - } - - expect( - second, - equals(first), - reason: - "fixture '$name' does not survive a Dart encode/decode round-trip", - ); - } - }); - - test('numeric fields keep their declared precision', () { - // `top_k` is fractional on purpose: Rust is Option (matching the - // upstream AI SDK's `topK?: number`), so a binding that declares it an - // integer cannot hold this value. Kotlin and Java both declared it Long - // until this fixture was added. - final fixture = _loadFixtures().firstWhere( - (f) => f['name'] == 'generate_text_options_numeric_types', - orElse: () => throw StateError( - "fixture 'generate_text_options_numeric_types' is missing", - ), - ); - final opts = GenerateTextOptions.fromJson( - jsonDecode(fixture['json'] as String) as Map, - ); - - expect(opts.topK, 40.5, - reason: 'top_k must keep its fraction — an int would truncate to 40'); - expect(opts.frequencyPenalty, -0.5, reason: 'penalties must stay signed'); - expect(opts.temperature, 0.7); - expect(opts.topP, 0.95); - expect(opts.maxOutputTokens, 256); - expect(opts.seed, 42); - expect(opts.maxRetries, 3); - }); - - test('provider-executed transcript uses typed content parts', () { - final fixture = _loadFixtures().firstWhere( - (f) => f['name'] == 'model_message_provider_executed_tool_transcript', - ); - final message = jsonDecode(fixture['json'] as String) as Map; - final content = (message['content'] as List) - .cast>(); - final call = ContentPart.fromJson(content[0]) as ContentPartToolCall; - final result = ContentPart.fromJson(content[1]) as ContentPartToolResult; - - expect(call.providerExecuted, true); - expect(call.toolName, 'search'); - expect(result.toolName, 'search'); - expect(result.isError, false); - expect(result.preliminary, true); - expect(result.isDynamic, true); - }); - }); -} diff --git a/bindings/flutter/test/tool_call_repair_test.dart b/bindings/flutter/test/tool_call_repair_test.dart deleted file mode 100644 index a78485d67..000000000 --- a/bindings/flutter/test/tool_call_repair_test.dart +++ /dev/null @@ -1,222 +0,0 @@ -import 'dart:convert'; -import 'dart:ffi'; -import 'dart:io'; - -import 'package:aimux/errors.dart'; -import 'package:aimux/tool_call_repair.dart'; -import 'package:aimux/types.dart'; -import 'package:ffi/ffi.dart'; -import 'package:test/test.dart'; - -typedef _Repair3 = Pointer Function(Pointer, Pointer?, - Pointer?, Pointer>); -typedef _Repair4 = Pointer Function(Pointer, Pointer?, - Pointer, Pointer, Pointer>); - -final _lib = openAimuxLibrary(); -final _context = _lib.lookupFunction<_Repair3, _Repair3>( - 'aimux_tool_call_repair_context'); -final _apply = _lib.lookupFunction<_Repair3, _Repair3>( - 'aimux_apply_tool_call_repair'); -final _patch = _lib.lookupFunction<_Repair4, _Repair4>( - 'aimux_apply_tool_call_repair_to_result'); -final List> _cases = - (jsonDecode(_fixture().readAsStringSync())['cases'] as List) - .cast>(); - -void main() { - test('replays every shared contract fixture case', () { - expect(_cases, isNotEmpty); - for (final fixture in _cases) { - final input = fixture['input'] as Map; - Object? run() => jsonDecode(_invoke(fixture['function'] as String, input)); - if (fixture.containsKey('expected_error')) { - expect(run, throwsA(isA().having( - (e) => e.code, 'code', AimuxErrorCode.invalidArgument)), - reason: fixture['name'] as String); - } else { - expect(run(), equals(fixture['expected']), - reason: fixture['name'] as String); - } - } - }); - - test('public result repair receives raw input and patches transcript', () { - final fixture = _named('patch_result'); - final input = fixture['input'] as Map; - String? rawInput; - final result = repairResultToolCalls( - jsonDecode(jsonEncode(input['result'])) as Map, - 'weather in Singapore?', - _options(input['opts'] as Map, (context) { - rawInput = context.toolCall.input; - return RawToolCall.fromJson( - input['reply']['tool_call'] as Map); - }), - ); - expect(rawInput, '{"town":"Singapore"}'); - expect(result['tool_calls'], fixture['expected']['tool_calls']); - expect(result['response_messages'], fixture['expected']['response_messages']); - }); - - test('public stream awaits repair and preserves input deltas', () async { - final fixture = _named('reply_repaired_validates'); - final input = fixture['input'] as Map; - final deltas = [ - {'ToolInputDelta': {'id': 'call-1', 'delta': '{"town":'}}, - {'ToolInputDelta': {'id': 'call-1', 'delta': '"Singapore"}'}}, - ]; - final parts = [...deltas, {'ToolCall': input['tool_call']}]; - final out = await repairStreamToolCalls( - Stream.fromIterable(parts), - 'weather in Singapore?', - _options(input['opts'] as Map, (_) async => - RawToolCall.fromJson(input['reply']['tool_call'] as Map)), - ).toList(); - expect(out.take(2), equals(deltas)); - expect(out.last['ToolCall'], equals(fixture['expected'])); - }); - - test('synchronous repair rejects an async hook', () { - final fixture = _named('reply_unchanged'); - final input = fixture['input'] as Map; - expect( - () => repairResultToolCalls(_result(input['tool_call'] as Map), - 'weather?', _options(input['opts'] as Map, (_) async => null)), - throwsStateError, - ); - }); - - // The four host-loop branches the shared fixture cannot reach: whether the - // hook runs at all, and what a throw or a null return leaves behind. - test('host loop reports a failed repair and skips calls it must not offer', - () { - int hookCalls = 0; - RawToolCall? counting(ToolCallRepairContext context) { - hookCalls++; - return null; - } - - // 1. the hook throws → invalid, wrapped in ToolCallRepair{cause: Other}. - final Map patch = - _named('patch_result')['input'] as Map; - final Map invalidCall = - _firstCall(patch['result'] as Map); - final Map failed = repairResultToolCalls( - _result(_copy(invalidCall)), - 'weather in Singapore?', - // A bare String, not an Exception: the core records the thrown object's - // toString(), and an Exception would prefix it with "Exception: ". - _options(patch['opts'] as Map, (_) { - hookCalls++; - throw 'repair model unavailable'; - }), - ); - final Map failedCall = _firstCall(failed); - final Map wrapped = - (failedCall['error'] as Map)['ToolCallRepair'] - as Map; - expect(failedCall['invalid'], isTrue); - expect(wrapped['cause'], {'Other': 'repair model unavailable'}); - expect(wrapped['original_error'], invalidCall['error']); - - // 2. the hook returns null → the ORIGINAL error survives, unwrapped. - final Map kept = repairResultToolCalls( - _result(_copy(invalidCall)), - 'weather in Singapore?', - _options(patch['opts'] as Map, counting), - ); - expect(_firstCall(kept)['invalid'], isTrue); - expect(_firstCall(kept)['error'], invalidCall['error']); - expect(hookCalls, 2); - - // 3. a valid call is never offered to the hook. - final Map valid = - _named('reject_valid_tool_call')['input'] as Map; - final Map untouched = repairResultToolCalls( - _result(_copy(valid['tool_call'] as Map)), - 'weather in Singapore?', - _options(valid['opts'] as Map, counting), - ); - expect(_firstCall(untouched)['invalid'], isNull); - - // 4. no tool set → the native context answers null and the host skips. - final Map noTools = - _named('context_without_tools')['input'] as Map; - final Map skipped = repairResultToolCalls( - _result(_copy(noTools['tool_call'] as Map)), - 'weather in Singapore?', - _options(noTools['opts'] as Map, counting), - ); - expect(_firstCall(skipped)['invalid'], isTrue); - expect(hookCalls, 2); - }); -} - -String _invoke(String function, Map input) { - final opts = input['opts'] == null ? null : jsonEncode(input['opts']); - switch (function) { - case 'tool_call_repair_context': - return _call3(_context, jsonEncode(input['tool_call']), - jsonEncode(input['prompt']), opts); - case 'apply_tool_call_repair': - return _call3(_apply, jsonEncode(input['tool_call']), opts, - jsonEncode(input['reply'])); - case 'apply_tool_call_repair_to_result': - return _call4(jsonEncode(input['result']), opts, - input['tool_call_id'] as String, jsonEncode(input['reply'])); - default: - throw StateError('unknown fixture function: $function'); - } -} - -String _call3(_Repair3 function, String a, String? b, String? c) => - withUtf8(a, (pa) { - final pb = toCStringOrNull(b), pc = toCStringOrNull(c); - try { - return takeString((out) => function(pa, pb, pc, out), 'fixture'); - } finally { - if (pb != nullptr) calloc.free(pb); - if (pc != nullptr) calloc.free(pc); - } - }); - -String _call4(String a, String? b, String c, String d) => withUtf8(a, (pa) => - withUtf8(c, (pc) => withUtf8(d, (pd) { - final pb = toCStringOrNull(b); - try { - return takeString((out) => _patch(pa, pb, pc, pd, out), 'fixture'); - } finally { - if (pb != nullptr) calloc.free(pb); - } - }))); - -GenerateTextOptions _options( - Map json, RepairToolCall repair) { - final base = GenerateTextOptions.fromJson(json); - return GenerateTextOptions(tools: base.tools, instructions: base.instructions, - repairToolCall: repair); -} - -Map _result(Map call) => { - 'text': '', 'tool_calls': [call], - 'response_messages': [{'role': 'assistant', 'content': [ - {'type': 'tool_call', 'tool_call_id': call['tool_call_id'], - 'tool_name': call['tool_name'], 'input': call['input']} - ]}] -}; - -Map _firstCall(Map result) => - (result['tool_calls'] as List).first as Map; - -Map _copy(Map json) => - jsonDecode(jsonEncode(json)) as Map; - -Map _named(String name) => - _cases.singleWhere((fixture) => fixture['name'] == name); - -File _fixture() => [ - '../../contract-tests/fixtures/tool-call-repair.json', - 'contract-tests/fixtures/tool-call-repair.json', - '../contract-tests/fixtures/tool-call-repair.json', -].map(File.new).firstWhere((file) => file.existsSync()); diff --git a/bindings/flutter/test/tool_call_repair_typed_test.dart b/bindings/flutter/test/tool_call_repair_typed_test.dart index 0a0cfd35e..181cb5c5b 100644 --- a/bindings/flutter/test/tool_call_repair_typed_test.dart +++ b/bindings/flutter/test/tool_call_repair_typed_test.dart @@ -116,7 +116,6 @@ void main() { 'input': '{"town":"Tokyo"}', 'provider_executed': true, 'dynamic': true, - 'thought_signature': 'sig', 'provider_metadata': { 'google': {'x': 1}, }, @@ -127,7 +126,6 @@ void main() { 'input': '{"city":"Tokyo"}', 'provider_executed': true, 'dynamic': true, - 'thought_signature': 'sig', 'provider_metadata': { 'google': {'x': 1}, }, diff --git a/bindings/go/repair.go b/bindings/go/repair.go index 5c12383fb..017eb7925 100644 --- a/bindings/go/repair.go +++ b/bindings/go/repair.go @@ -29,7 +29,6 @@ type RawToolCall struct { Input string `json:"input"` ProviderExecuted *bool `json:"provider_executed,omitempty"` Dynamic *bool `json:"dynamic,omitempty"` - ThoughtSignature *string `json:"thought_signature,omitempty"` ProviderMetadata json.RawMessage `json:"provider_metadata,omitempty"` } diff --git a/bindings/go/repair_test.go b/bindings/go/repair_test.go deleted file mode 100644 index 770b893a3..000000000 --- a/bindings/go/repair_test.go +++ /dev/null @@ -1,279 +0,0 @@ -package aimux - -import ( - "encoding/json" - "errors" - "os" - "path/filepath" - "reflect" - "strings" - "testing" -) - -type repairFixture struct { - Cases []struct { - Name string `json:"name"` - Function string `json:"function"` - Input json.RawMessage `json:"input"` - Expected json.RawMessage `json:"expected"` - ExpectedError string `json:"expected_error"` - } `json:"cases"` -} - -func jsonString(v json.RawMessage) string { - if len(v) == 0 || string(v) == "null" { - return "" - } - return string(v) -} - -func TestRepairContractFixture(t *testing.T) { - raw, err := os.ReadFile(filepath.Join("..", "..", "contract-tests", "fixtures", "tool-call-repair.json")) - if err != nil { - t.Fatalf("read fixture: %v", err) - } - var fixture repairFixture - if err := json.Unmarshal(raw, &fixture); err != nil { - t.Fatalf("parse fixture: %v", err) - } - if len(fixture.Cases) == 0 { - t.Fatal("fixture has no cases") - } - - for _, c := range fixture.Cases { - t.Run(c.Name, func(t *testing.T) { - var in struct { - ToolCall json.RawMessage `json:"tool_call"` - Prompt json.RawMessage `json:"prompt"` - Opts json.RawMessage `json:"opts"` - Reply json.RawMessage `json:"reply"` - Result json.RawMessage `json:"result"` - ToolCallID string `json:"tool_call_id"` - } - if err := json.Unmarshal(c.Input, &in); err != nil { - t.Fatalf("parse case input: %v", err) - } - var got string - var callErr error - switch c.Function { - case "tool_call_repair_context": - got, callErr = toolCallRepairContextJSON(string(in.ToolCall), string(in.Prompt), jsonString(in.Opts)) - case "apply_tool_call_repair": - got, callErr = applyToolCallRepairJSON(string(in.ToolCall), jsonString(in.Opts), string(in.Reply)) - case "apply_tool_call_repair_to_result": - got, callErr = applyToolCallRepairToResultJSON(string(in.Result), jsonString(in.Opts), in.ToolCallID, string(in.Reply)) - default: - t.Fatalf("unknown fixture function %q", c.Function) - } - if c.ExpectedError != "" { - var e *Error - if !errors.As(callErr, &e) || e.Code.String() != c.ExpectedError { - t.Fatalf("expected %s, got err=%v out=%s", c.ExpectedError, callErr, got) - } - return - } - if callErr != nil { - t.Fatalf("unexpected error: %v", callErr) - } - var actual, expected any - if err := json.Unmarshal([]byte(got), &actual); err != nil { - t.Fatalf("output is not JSON: %v", err) - } - if err := json.Unmarshal(c.Expected, &expected); err != nil { - t.Fatalf("fixture expectation is not JSON: %v", err) - } - if !reflect.DeepEqual(actual, expected) { - t.Errorf("got %s\nwant %s", got, c.Expected) - } - }) - } -} - -func strictWeatherOpts(repair RepairToolCallFunc) *GenerateTextOptions { - return &GenerateTextOptions{ - Tools: []Tool{{Type: "function", Name: "weather", InputSchema: json.RawMessage( - `{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false}`)}}, - RepairToolCall: repair, - } -} - -const townToolCallResponse = `{"id":"chatcmpl-repair","model":"gpt-4o","choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"call-1","type":"function","function":{"name":"weather","arguments":"{\"town\":\"Singapore\"}"}}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":5,"completion_tokens":3,"total_tokens":8}}` - -func toCity(ctx *ToolCallRepairContext) (*RawToolCall, error) { - return &RawToolCall{ToolCallID: ctx.ToolCall.ToolCallID, ToolName: ctx.ToolCall.ToolName, Input: `{"city":"Singapore"}`}, nil -} - -func TestRepairGenerateRepairs(t *testing.T) { - srv := newMockServer() - defer srv.Close() - srv.SetResponse(townToolCallResponse) - m := OpenAIWithBase("sk-test-fake-key", "gpt-4o", srv.URL) - defer m.Close() - - var seen *ToolCallRepairContext - result, err := m.Generate("weather in Singapore?", strictWeatherOpts(func(ctx *ToolCallRepairContext) (*RawToolCall, error) { - seen = ctx - return toCity(ctx) - })) - if err != nil { - t.Fatalf("Generate: %v", err) - } - if seen == nil || seen.ToolCall.Input != `{"town":"Singapore"}` { - t.Fatalf("repair did not receive provider raw input: %+v", seen) - } - if len(result.ToolCalls) != 1 || string(result.ToolCalls[0].Input) != `{"city":"Singapore"}` { - t.Fatalf("unexpected repaired calls: %+v", result.ToolCalls) - } - transcript, _ := json.Marshal(result.ResponseMessages) - if !strings.Contains(string(transcript), `"city":"Singapore"`) || strings.Contains(string(transcript), `"town"`) { - t.Errorf("response_messages disagree with tool_calls: %s", transcript) - } -} - -func TestRepairGenerateAsOpenAICarriesRepairedArguments(t *testing.T) { - srv := newMockServer() - defer srv.Close() - srv.SetResponse(townToolCallResponse) - m := OpenAIWithBase("sk-test-fake-key", "gpt-4o", srv.URL) - defer m.Close() - - completion, err := m.GenerateAsOpenAI("weather in Singapore?", strictWeatherOpts(toCity)) - if err != nil { - t.Fatalf("GenerateAsOpenAI: %v", err) - } - calls := completion.Choices[0].Message.ToolCalls - if len(calls) != 1 || calls[0].ID != "call-1" || calls[0].Function.Arguments != `{"city":"Singapore"}` { - t.Fatalf("completion does not carry the repaired call: %+v", calls) - } -} - -const townToolCallSSE = "data: " + `{"id":"1","model":"gpt-4o","choices":[{"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"call-1","type":"function","function":{"name":"weather","arguments":""}}]}}]}` + "\n\n" + - "data: " + `{"id":"1","model":"gpt-4o","choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"town\":\"Singapore\"}"}}]}}]}` + "\n\n" + - "data: " + `{"id":"1","model":"gpt-4o","choices":[{"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":5,"completion_tokens":2,"total_tokens":7}}` + "\n\n" + - "data: [DONE]\n\n" - -func TestRepairStreamReplacesToolCallPart(t *testing.T) { - srv := newMockServer() - defer srv.Close() - srv.SetContentType("text/event-stream") - srv.SetResponse(townToolCallSSE) - m := OpenAIWithBase("sk-test-fake-key", "gpt-4o", srv.URL) - defer m.Close() - - stream, err := m.Stream("weather in Singapore?", strictWeatherOpts(toCity)) - if err != nil { - t.Fatalf("Stream: %v", err) - } - var toolCall string - var deltas []string - for part := range stream.Parts() { - if part.Tag == "ToolCall" { - toolCall = string(part.Payload) - } else if part.Tag == "ToolInputDelta" { - var delta TextDeltaPayload - if err := json.Unmarshal(part.Payload, &delta); err != nil { - t.Fatalf("decode ToolInputDelta: %v", err) - } - deltas = append(deltas, delta.Delta) - } - } - if err := stream.Err(); err != nil { - t.Fatalf("stream error: %v", err) - } - if !strings.Contains(toolCall, `"city":"Singapore"`) || strings.Contains(toolCall, `"invalid"`) { - t.Errorf("expected repaired ToolCall, got %s", toolCall) - } - if got := strings.Join(deltas, ""); got != `{"town":"Singapore"}` { - t.Errorf("provider deltas were changed: %s", got) - } -} - -// TestRepairHostLoopBranches covers the non-happy branches of the Go repair -// loop: a repair function that fails, one that declines, and the two cases -// where the loop must never reach it at all. -func TestRepairHostLoopBranches(t *testing.T) { - cityToolCallResponse := strings.Replace(townToolCallResponse, `\"town\"`, `\"city\"`, 1) - - cases := []struct { - name string - response string - noTools bool - hook RepairToolCallFunc - wantHits int - wantInvalid bool - wantErrKey string // top-level key of tool_calls[0].error; "" skips the check - wantCause string // ToolCallRepair.cause.Other - }{ - {name: "failed repair wraps the original error", response: townToolCallResponse, - hook: func(*ToolCallRepairContext) (*RawToolCall, error) { return nil, errors.New("repair model unavailable") }, - wantHits: 1, wantInvalid: true, wantErrKey: "ToolCallRepair", wantCause: "repair model unavailable"}, - {name: "nil repair keeps the original error", response: townToolCallResponse, - hook: func(*ToolCallRepairContext) (*RawToolCall, error) { return nil, nil }, - wantHits: 1, wantInvalid: true, wantErrKey: "InvalidToolInput"}, - {name: "valid call never reaches repair", response: cityToolCallResponse, - hook: toCity, wantHits: 0, wantInvalid: false}, - {name: "call without a tool set never reaches repair", response: townToolCallResponse, - noTools: true, hook: toCity, wantHits: 0, wantInvalid: true}, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - srv := newMockServer() - defer srv.Close() - srv.SetResponse(tc.response) - m := OpenAIWithBase("sk-test-fake-key", "gpt-4o", srv.URL) - defer m.Close() - - hits := 0 - opts := strictWeatherOpts(func(ctx *ToolCallRepairContext) (*RawToolCall, error) { - hits++ - return tc.hook(ctx) - }) - if tc.noTools { - opts.Tools = nil - } - result, err := m.Generate("weather in Singapore?", opts) - if err != nil { - t.Fatalf("Generate: %v", err) - } - if hits != tc.wantHits { - t.Fatalf("repair invoked %d times, want %d", hits, tc.wantHits) - } - if len(result.ToolCalls) != 1 { - t.Fatalf("expected 1 tool call, got %+v", result.ToolCalls) - } - call := result.ToolCalls[0] - if invalid := call.Invalid != nil && *call.Invalid; invalid != tc.wantInvalid { - t.Fatalf("invalid = %v, want %v (error %s)", invalid, tc.wantInvalid, call.Error) - } - if tc.wantErrKey == "" { - return - } - var errObj map[string]json.RawMessage - if err := json.Unmarshal(call.Error, &errObj); err != nil { - t.Fatalf("decode call error %s: %v", call.Error, err) - } - if _, ok := errObj[tc.wantErrKey]; !ok || len(errObj) != 1 { - t.Fatalf("expected a lone %s error, got %s", tc.wantErrKey, call.Error) - } - if tc.wantCause == "" { - return - } - var wrap struct { - OriginalError map[string]json.RawMessage `json:"original_error"` - Cause struct { - Other string `json:"Other"` - } `json:"cause"` - } - if err := json.Unmarshal(errObj["ToolCallRepair"], &wrap); err != nil { - t.Fatalf("decode ToolCallRepair: %v", err) - } - if wrap.Cause.Other != tc.wantCause { - t.Fatalf("cause = %q, want Other = %q", wrap.Cause.Other, tc.wantCause) - } - if _, ok := wrap.OriginalError["InvalidToolInput"]; !ok { - t.Fatalf("original_error is not InvalidToolInput: %s", errObj["ToolCallRepair"]) - } - }) - } -} diff --git a/bindings/go/types.go b/bindings/go/types.go index b63089e6c..d504c528e 100644 --- a/bindings/go/types.go +++ b/bindings/go/types.go @@ -108,7 +108,6 @@ type ToolCall struct { Input json.RawMessage `json:"input,omitempty"` ProviderExecuted *bool `json:"provider_executed,omitempty"` Dynamic *bool `json:"dynamic,omitempty"` - ThoughtSignature *string `json:"thought_signature,omitempty"` // ProviderMetadata carries provider-specific data associated with this call. ProviderMetadata json.RawMessage `json:"provider_metadata,omitempty"` // Invalid is set by Core when the tool call stays invalid after optional repair. @@ -309,6 +308,10 @@ type GenerateTextOptions struct { RepairToolCall RepairToolCallFunc `json:"-"` } +type FunctionToolInputExample struct { + Input map[string]json.RawMessage `json:"input"` +} + // Tool is a function tool definition (the "function" variant). // Mirrors Kotlin Tool.Function (Types.kt:209-230). type Tool struct { @@ -318,7 +321,7 @@ type Tool struct { InputSchema json.RawMessage `json:"input_schema,omitempty"` Strict *bool `json:"strict,omitempty"` ProviderOptions map[string]json.RawMessage `json:"provider_options,omitempty"` - InputExamples []json.RawMessage `json:"input_examples,omitempty"` + InputExamples []FunctionToolInputExample `json:"input_examples,omitempty"` } // MarshalOptions serializes GenerateTextOptions to JSON. Returns "" for nil opts. diff --git a/bindings/go/wire_format_test.go b/bindings/go/wire_format_test.go deleted file mode 100644 index 4a2834116..000000000 --- a/bindings/go/wire_format_test.go +++ /dev/null @@ -1,256 +0,0 @@ -// Contract tests for the Go binding — validates wire format consistency -// against the shared JSON fixture used by all language bindings. -// -// The fixture (contract-tests/fixtures/wire-format.json) contains canonical -// JSON wire-format examples for ToolChoice, StreamPart, GenerateTextOptions, -// Role, ModelMessage, FinishReasonUnified, and ReasoningEffort. This test -// ensures Go parses them identically to Rust, Node, Kotlin, Swift, and Python. -// -// Run from the repo root: -// cd bindings/go && go test -run TestWireFormat -v - -package aimux - -import ( - "encoding/json" - "os" - "path/filepath" - "reflect" - "testing" -) - -func jsonStructurallyEqual(a, b string) bool { - var av, bv any - if json.Unmarshal([]byte(a), &av) != nil || json.Unmarshal([]byte(b), &bv) != nil { - return false - } - return reflect.DeepEqual(av, bv) -} - -// fixtureCase is a single entry in the wire-format.json fixture. -type fixtureCase struct { - Name string `json:"name"` - Type string `json:"type"` - RustValue string `json:"rust_value,omitempty"` - JSON json.RawMessage `json:"json"` - Description string `json:"description"` -} - -func loadFixture(t *testing.T) []fixtureCase { - t.Helper() - path := filepath.Join("..", "..", "contract-tests", "fixtures", "wire-format.json") - data, err := os.ReadFile(path) - if err != nil { - t.Fatalf("failed to read fixture: %v", err) - } - var cases []fixtureCase - if err := json.Unmarshal(data, &cases); err != nil { - t.Fatalf("failed to parse fixture: %v", err) - } - return cases -} - -// TestWireFormatConsistency loads the shared fixture and verifies Go can -// decode each wire-format example, ensuring cross-language consistency. -func TestWireFormatConsistency(t *testing.T) { - cases := loadFixture(t) - - for _, tc := range cases { - t.Run(tc.Name, func(t *testing.T) { - // The fixture stores the JSON wire format as a JSON string value - // (doubly encoded). Decode the outer string to get the actual - // wire format bytes, then parse those. - var wireJSON string - if err := json.Unmarshal(tc.JSON, &wireJSON); err != nil { - t.Fatalf("failed to extract wire format from fixture: %v", err) - } - - switch tc.Type { - case "ToolChoice": - // ToolChoice is polymorphic on the wire: bare string ("auto") - // or tagged object ({"type":"tool",...}). Verify raw round-trip. - var tc2 json.RawMessage - if err := json.Unmarshal([]byte(wireJSON), &tc2); err != nil { - t.Fatalf("failed to unmarshal ToolChoice %s: %v", wireJSON, err) - } - if !jsonStructurallyEqual(string(tc2), wireJSON) { - t.Errorf("round-trip mismatch: got %s, want %s", tc2, wireJSON) - } - - case "StreamPart": - // StreamPart is externally-tagged JSON; verify we can parse - // the tag and payload. - sp, err := ParseStreamPart(wireJSON) - if err != nil { - t.Fatalf("failed to parse StreamPart: %v", err) - } - if sp.Tag == "" { - t.Error("expected non-empty stream part tag") - } - // Verify the tag matches the fixture name's prefix. - expectedTags := map[string]string{ - "stream_part_text_delta": "TextDelta", - "stream_part_finish": "Finish", - "stream_part_stream_start": "StreamStart", - "stream_part_raw": "Raw", - } - if expected, ok := expectedTags[tc.Name]; ok { - if sp.Tag != expected { - t.Errorf("expected tag %q, got %q", expected, sp.Tag) - } - } - - case "GenerateTextOptions": - // Default options: all fields null. Verify we can decode. - var opts GenerateTextOptions - if err := json.Unmarshal([]byte(wireJSON), &opts); err != nil { - t.Fatalf("failed to unmarshal GenerateTextOptions: %v", err) - } - // Per-fixture expectations. The emptiness checks belong to the - // default fixture alone — asserting them for every case is what - // kept the numeric fields untested while they were all null. - switch tc.Name { - case "generate_text_options_default": - if opts.MaxOutputTokens != nil { - t.Error("expected nil MaxOutputTokens") - } - if opts.Temperature != nil { - t.Error("expected nil Temperature") - } - - case "generate_text_options_with_session_id": - if opts.SessionID == nil || *opts.SessionID != "sess-1" { - t.Errorf("expected session_id \"sess-1\", got %v", opts.SessionID) - } - // This fixture sets only session_id; the numeric fields stay - // null. There is no round-trip comparison in this branch to - // catch that incidentally, so assert it. - if opts.MaxOutputTokens != nil { - t.Error("expected nil MaxOutputTokens") - } - if opts.Temperature != nil { - t.Error("expected nil Temperature") - } - - case "generate_text_options_include_raw_chunks_true": - // RFC-0016 M2 true-case: include_raw_chunks round-trips. - if opts.IncludeRawChunks == nil || *opts.IncludeRawChunks != true { - t.Errorf("expected IncludeRawChunks=true, got %v", opts.IncludeRawChunks) - } - // Likewise: only include_raw_chunks is set here. - if opts.MaxOutputTokens != nil { - t.Error("expected nil MaxOutputTokens") - } - if opts.Temperature != nil { - t.Error("expected nil Temperature") - } - - case "generate_text_options_numeric_types": - // top_k is fractional on purpose: Go declares *float64, so - // the fraction must survive. An integer type would truncate - // 40.5 to 40 and still pass a field-name-only check. - if opts.TopK == nil || *opts.TopK != 40.5 { - t.Errorf("expected TopK=40.5, got %v", opts.TopK) - } - if opts.FrequencyPenalty == nil || *opts.FrequencyPenalty != -0.5 { - t.Errorf("expected FrequencyPenalty=-0.5, got %v", opts.FrequencyPenalty) - } - if opts.MaxOutputTokens == nil || *opts.MaxOutputTokens != 256 { - t.Errorf("expected MaxOutputTokens=256, got %v", opts.MaxOutputTokens) - } - if opts.Seed == nil || *opts.Seed != 42 { - t.Errorf("expected Seed=42, got %v", opts.Seed) - } - if opts.MaxRetries == nil || *opts.MaxRetries != 3 { - t.Errorf("expected MaxRetries=3, got %v", opts.MaxRetries) - } - } - - case "Role": - var r Role - if err := json.Unmarshal([]byte(wireJSON), &r); err != nil { - t.Fatalf("failed to unmarshal Role: %v", err) - } - reencoded, _ := json.Marshal(r) - if !jsonStructurallyEqual(string(reencoded), wireJSON) { - t.Errorf("round-trip mismatch: got %s, want %s", reencoded, wireJSON) - } - - case "ModelMessage": - var msg ModelMessage - if err := json.Unmarshal([]byte(wireJSON), &msg); err != nil { - t.Fatalf("failed to unmarshal ModelMessage: %v", err) - } - if msg.Role == "" { - t.Error("expected non-empty role") - } - if msg.Content == "" { - t.Error("expected non-empty content") - } - // Round-trip: re-encode and verify structure. - reencoded, _ := json.Marshal(msg) - if !jsonStructurallyEqual(string(reencoded), wireJSON) { - t.Errorf("round-trip mismatch: got %s, want %s", reencoded, wireJSON) - } - - case "FinishReasonUnified": - var fr FinishReasonUnified - if err := json.Unmarshal([]byte(wireJSON), &fr); err != nil { - t.Fatalf("failed to unmarshal FinishReasonUnified: %v", err) - } - reencoded, _ := json.Marshal(fr) - if !jsonStructurallyEqual(string(reencoded), wireJSON) { - t.Errorf("round-trip mismatch: got %s, want %s", reencoded, wireJSON) - } - - case "ReasoningEffort": - var re ReasoningEffort - if err := json.Unmarshal([]byte(wireJSON), &re); err != nil { - t.Fatalf("failed to unmarshal ReasoningEffort: %v", err) - } - reencoded, _ := json.Marshal(re) - if !jsonStructurallyEqual(string(reencoded), wireJSON) { - t.Errorf("round-trip mismatch: got %s, want %s", reencoded, wireJSON) - } - - case "TimeoutConfiguration": - var tc3 TimeoutConfiguration - if err := json.Unmarshal([]byte(wireJSON), &tc3); err != nil { - t.Fatalf("failed to unmarshal TimeoutConfiguration: %v", err) - } - if tc3.TotalMs == nil || *tc3.TotalMs != 5000 { - t.Error("expected TotalMs=5000") - } - if tc3.FirstChunkMs == nil || *tc3.FirstChunkMs != 1000 { - t.Error("expected FirstChunkMs=1000") - } - if tc3.ChunkMs == nil || *tc3.ChunkMs != 500 { - t.Error("expected ChunkMs=500") - } - if tc3.StepMs != nil { - t.Error("expected nil StepMs") - } - // No byte round-trip here: the fixture spells Rust's explicit - // nulls, while the Go struct is omitempty by convention (same - // reasoning as the GenerateTextOptions branch above). - - case "GenerateContent": - // Go keeps result content parts as raw JSON by design - // (ContentPart = json.RawMessage), so there is no typed - // GenerateContent to decode into. Verify the wire shape - // survives Go's JSON layer byte-for-byte — the same check - // ToolChoice gets, and for the same reason. - var gc json.RawMessage - if err := json.Unmarshal([]byte(wireJSON), &gc); err != nil { - t.Fatalf("failed to unmarshal GenerateContent %s: %v", wireJSON, err) - } - if !jsonStructurallyEqual(string(gc), wireJSON) { - t.Errorf("round-trip mismatch: got %s, want %s", gc, wireJSON) - } - - default: - t.Fatalf("unsupported fixture type: %s (add Go support for this wire type)", tc.Type) - } - }) - } -} diff --git a/bindings/java/src/main/java/ai/arcships/aimux/Types.java b/bindings/java/src/main/java/ai/arcships/aimux/Types.java index 62d272e89..f39084310 100644 --- a/bindings/java/src/main/java/ai/arcships/aimux/Types.java +++ b/bindings/java/src/main/java/ai/arcships/aimux/Types.java @@ -410,7 +410,6 @@ public static class ToolCall { @JsonProperty("input") private JsonNode input = emptyObject(); @JsonProperty("provider_executed") private Boolean providerExecuted; @JsonProperty("dynamic") private Boolean dynamic; - @JsonProperty("thought_signature") private String thoughtSignature; @JsonProperty("provider_metadata") private JsonNode providerMetadata; @JsonProperty("invalid") private Boolean invalid; @JsonProperty("error") private JsonNode error; @@ -419,13 +418,12 @@ public static class ToolCall { ToolCall() {} private ToolCall(String toolCallId, String toolName, JsonNode input, Boolean providerExecuted, Boolean dynamic, - String thoughtSignature, JsonNode providerMetadata, Boolean invalid, JsonNode error) { + JsonNode providerMetadata, Boolean invalid, JsonNode error) { this.toolCallId = toolCallId; this.toolName = toolName; this.input = input; this.providerExecuted = providerExecuted; this.dynamic = dynamic; - this.thoughtSignature = thoughtSignature; this.providerMetadata = providerMetadata; this.invalid = invalid; this.error = error; @@ -436,7 +434,6 @@ private ToolCall(String toolCallId, String toolName, JsonNode input, Boolean pro public JsonNode getInput() { return input; } public Boolean getProviderExecuted() { return providerExecuted; } public Boolean getDynamic() { return dynamic; } - public String getThoughtSignature() { return thoughtSignature; } public JsonNode getProviderMetadata() { return providerMetadata; } /** Set by Core when the tool call stays invalid after optional repair. */ public Boolean getInvalid() { return invalid; } @@ -451,7 +448,6 @@ public static class Builder { private JsonNode input = emptyObject(); private Boolean providerExecuted; private Boolean dynamic; - private String thoughtSignature; private JsonNode providerMetadata; private Boolean invalid; private JsonNode error; @@ -461,13 +457,12 @@ public static class Builder { public Builder input(JsonNode v) { this.input = v; return this; } public Builder providerExecuted(Boolean v) { this.providerExecuted = v; return this; } public Builder dynamic(Boolean v) { this.dynamic = v; return this; } - public Builder thoughtSignature(String v) { this.thoughtSignature = v; return this; } public Builder providerMetadata(JsonNode v) { this.providerMetadata = v; return this; } public Builder invalid(Boolean v) { this.invalid = v; return this; } public Builder error(JsonNode v) { this.error = v; return this; } public ToolCall build() { - return new ToolCall(toolCallId, toolName, input, providerExecuted, dynamic, thoughtSignature, providerMetadata, + return new ToolCall(toolCallId, toolName, input, providerExecuted, dynamic, providerMetadata, invalid, error); } } @@ -482,7 +477,6 @@ public boolean equals(Object o) { && Objects.equals(input, that.input) && Objects.equals(providerExecuted, that.providerExecuted) && Objects.equals(dynamic, that.dynamic) - && Objects.equals(thoughtSignature, that.thoughtSignature) && Objects.equals(providerMetadata, that.providerMetadata) && Objects.equals(invalid, that.invalid) && Objects.equals(error, that.error); @@ -490,7 +484,7 @@ public boolean equals(Object o) { @Override public int hashCode() { - return Objects.hash(toolCallId, toolName, input, providerExecuted, dynamic, thoughtSignature, providerMetadata, + return Objects.hash(toolCallId, toolName, input, providerExecuted, dynamic, providerMetadata, invalid, error); } } @@ -501,6 +495,25 @@ public int hashCode() { // `Tool` is an internally-tagged union on `type` (`"function" | "provider"`). // ───────────────────────────────────────────────────────────────────────────── + public static class FunctionToolInputExample { + @JsonProperty("input") private ObjectNode input = emptyObject(); + + @JsonCreator + FunctionToolInputExample() {} + + public FunctionToolInputExample(ObjectNode input) { this.input = input; } + public ObjectNode getInput() { return input; } + + @Override + public boolean equals(Object o) { + return o instanceof FunctionToolInputExample + && Objects.equals(input, ((FunctionToolInputExample) o).input); + } + + @Override + public int hashCode() { return Objects.hash(input); } + } + /** * A user-defined function tool. * @@ -512,13 +525,13 @@ public static class FunctionTool { @JsonProperty("input_schema") private JsonNode inputSchema = emptyObject(); @JsonProperty("strict") private Boolean strict; @JsonProperty("provider_options") private Map providerOptions; - @JsonProperty("input_examples") private List inputExamples; + @JsonProperty("input_examples") private List inputExamples; @JsonCreator FunctionTool() {} private FunctionTool(String name, String description, JsonNode inputSchema, Boolean strict, - Map providerOptions, List inputExamples) { + Map providerOptions, List inputExamples) { this.name = name; this.description = description; this.inputSchema = inputSchema; @@ -532,7 +545,7 @@ private FunctionTool(String name, String description, JsonNode inputSchema, Bool public JsonNode getInputSchema() { return inputSchema; } public Boolean getStrict() { return strict; } public Map getProviderOptions() { return providerOptions; } - public List getInputExamples() { return inputExamples; } + public List getInputExamples() { return inputExamples; } public static Builder builder() { return new Builder(); } @@ -542,14 +555,14 @@ public static class Builder { private JsonNode inputSchema = emptyObject(); private Boolean strict; private Map providerOptions; - private List inputExamples; + private List inputExamples; public Builder name(String v) { this.name = v; return this; } public Builder description(String v) { this.description = v; return this; } public Builder inputSchema(JsonNode v) { this.inputSchema = v; return this; } public Builder strict(Boolean v) { this.strict = v; return this; } public Builder providerOptions(Map v) { this.providerOptions = v; return this; } - public Builder inputExamples(List v) { this.inputExamples = v; return this; } + public Builder inputExamples(List v) { this.inputExamples = v; return this; } public FunctionTool build() { return new FunctionTool(name, description, inputSchema, strict, providerOptions, inputExamples); @@ -583,12 +596,12 @@ public int hashCode() { public static class ProviderTool { @JsonProperty("id") private String id = ""; @JsonProperty("name") private String name = ""; - @JsonProperty("args") private JsonNode args = emptyObject(); + @JsonProperty("args") private ObjectNode args = emptyObject(); @JsonCreator ProviderTool() {} - private ProviderTool(String id, String name, JsonNode args) { + private ProviderTool(String id, String name, ObjectNode args) { this.id = id; this.name = name; this.args = args; @@ -596,18 +609,18 @@ private ProviderTool(String id, String name, JsonNode args) { public String getId() { return id; } public String getName() { return name; } - public JsonNode getArgs() { return args; } + public ObjectNode getArgs() { return args; } public static Builder builder() { return new Builder(); } public static class Builder { private String id = ""; private String name = ""; - private JsonNode args = emptyObject(); + private ObjectNode args = emptyObject(); public Builder id(String v) { this.id = v; return this; } public Builder name(String v) { this.name = v; return this; } - public Builder args(JsonNode v) { this.args = v; return this; } + public Builder args(ObjectNode v) { this.args = v; return this; } public ProviderTool build() { return new ProviderTool(id, name, args); } } @@ -649,13 +662,13 @@ public static class Function extends Tool { @JsonProperty("input_schema") private JsonNode inputSchema = emptyObject(); @JsonProperty("strict") private Boolean strict; @JsonProperty("provider_options") private Map providerOptions; - @JsonProperty("input_examples") private List inputExamples; + @JsonProperty("input_examples") private List inputExamples; @JsonCreator Function() {} private Function(String name, String description, JsonNode inputSchema, Boolean strict, - Map providerOptions, List inputExamples) { + Map providerOptions, List inputExamples) { this.name = name; this.description = description; this.inputSchema = inputSchema; @@ -675,7 +688,7 @@ public static Function from(FunctionTool tool) { public JsonNode getInputSchema() { return inputSchema; } public Boolean getStrict() { return strict; } public Map getProviderOptions() { return providerOptions; } - public List getInputExamples() { return inputExamples; } + public List getInputExamples() { return inputExamples; } public static Builder builder() { return new Builder(); } @@ -685,14 +698,14 @@ public static class Builder { private JsonNode inputSchema = emptyObject(); private Boolean strict; private Map providerOptions; - private List inputExamples; + private List inputExamples; public Builder name(String v) { this.name = v; return this; } public Builder description(String v) { this.description = v; return this; } public Builder inputSchema(JsonNode v) { this.inputSchema = v; return this; } public Builder strict(Boolean v) { this.strict = v; return this; } public Builder providerOptions(Map v) { this.providerOptions = v; return this; } - public Builder inputExamples(List v) { this.inputExamples = v; return this; } + public Builder inputExamples(List v) { this.inputExamples = v; return this; } public Function build() { return new Function(name, description, inputSchema, strict, providerOptions, inputExamples); @@ -722,12 +735,12 @@ public int hashCode() { public static class Provider extends Tool { @JsonProperty("id") private String id = ""; @JsonProperty("name") private String name = ""; - @JsonProperty("args") private JsonNode args = emptyObject(); + @JsonProperty("args") private ObjectNode args = emptyObject(); @JsonCreator Provider() {} - private Provider(String id, String name, JsonNode args) { + private Provider(String id, String name, ObjectNode args) { this.id = id; this.name = name; this.args = args; @@ -740,18 +753,18 @@ public static Provider from(ProviderTool tool) { public String getId() { return id; } public String getName() { return name; } - public JsonNode getArgs() { return args; } + public ObjectNode getArgs() { return args; } public static Builder builder() { return new Builder(); } public static class Builder { private String id = ""; private String name = ""; - private JsonNode args = emptyObject(); + private ObjectNode args = emptyObject(); public Builder id(String v) { this.id = v; return this; } public Builder name(String v) { this.name = v; return this; } - public Builder args(JsonNode v) { this.args = v; return this; } + public Builder args(ObjectNode v) { this.args = v; return this; } public Provider build() { return new Provider(id, name, args); } } @@ -1273,19 +1286,17 @@ public static class ToolCall extends ContentPart { @JsonProperty("tool_name") private String toolName = ""; @JsonProperty("input") private JsonNode input = emptyObject(); @JsonProperty("provider_executed") private Boolean providerExecuted; - @JsonProperty("thought_signature") private String thoughtSignature; @JsonProperty("provider_options") private JsonNode providerOptions; @JsonCreator ToolCall() {} private ToolCall(String toolCallId, String toolName, JsonNode input, - Boolean providerExecuted, String thoughtSignature, JsonNode providerOptions) { + Boolean providerExecuted, JsonNode providerOptions) { this.toolCallId = toolCallId; this.toolName = toolName; this.input = input; this.providerExecuted = providerExecuted; - this.thoughtSignature = thoughtSignature; this.providerOptions = providerOptions; } @@ -1293,7 +1304,6 @@ private ToolCall(String toolCallId, String toolName, JsonNode input, public String getToolName() { return toolName; } public JsonNode getInput() { return input; } public Boolean getProviderExecuted() { return providerExecuted; } - public String getThoughtSignature() { return thoughtSignature; } public JsonNode getProviderOptions() { return providerOptions; } public static Builder builder() { return new Builder(); } @@ -1303,18 +1313,16 @@ public static class Builder { private String toolName = ""; private JsonNode input = emptyObject(); private Boolean providerExecuted; - private String thoughtSignature; private JsonNode providerOptions; public Builder toolCallId(String v) { this.toolCallId = v; return this; } public Builder toolName(String v) { this.toolName = v; return this; } public Builder input(JsonNode v) { this.input = v; return this; } public Builder providerExecuted(Boolean v) { this.providerExecuted = v; return this; } - public Builder thoughtSignature(String v) { this.thoughtSignature = v; return this; } public Builder providerOptions(JsonNode v) { this.providerOptions = v; return this; } public ToolCall build() { - return new ToolCall(toolCallId, toolName, input, providerExecuted, thoughtSignature, providerOptions); + return new ToolCall(toolCallId, toolName, input, providerExecuted, providerOptions); } } @@ -1327,13 +1335,12 @@ public boolean equals(Object o) { && Objects.equals(toolName, that.toolName) && Objects.equals(input, that.input) && Objects.equals(providerExecuted, that.providerExecuted) - && Objects.equals(thoughtSignature, that.thoughtSignature) && Objects.equals(providerOptions, that.providerOptions); } @Override public int hashCode() { - return Objects.hash(toolCallId, toolName, input, providerExecuted, thoughtSignature, providerOptions); + return Objects.hash(toolCallId, toolName, input, providerExecuted, providerOptions); } } @@ -1729,20 +1736,18 @@ public static class RawToolCall { @JsonProperty("input") private String input = ""; @JsonProperty("provider_executed") private Boolean providerExecuted; @JsonProperty("dynamic") private Boolean dynamic; - @JsonProperty("thought_signature") private String thoughtSignature; @JsonProperty("provider_metadata") private JsonNode providerMetadata; @JsonCreator RawToolCall() {} private RawToolCall(String toolCallId, String toolName, String input, Boolean providerExecuted, - Boolean dynamic, String thoughtSignature, JsonNode providerMetadata) { + Boolean dynamic, JsonNode providerMetadata) { this.toolCallId = toolCallId; this.toolName = toolName; this.input = input; this.providerExecuted = providerExecuted; this.dynamic = dynamic; - this.thoughtSignature = thoughtSignature; this.providerMetadata = providerMetadata; } @@ -1752,7 +1757,6 @@ private RawToolCall(String toolCallId, String toolName, String input, Boolean pr public String getInput() { return input; } public Boolean getProviderExecuted() { return providerExecuted; } public Boolean getDynamic() { return dynamic; } - public String getThoughtSignature() { return thoughtSignature; } public JsonNode getProviderMetadata() { return providerMetadata; } public static Builder builder() { return new Builder(); } @@ -1763,7 +1767,6 @@ public static class Builder { private String input = ""; private Boolean providerExecuted; private Boolean dynamic; - private String thoughtSignature; private JsonNode providerMetadata; public Builder toolCallId(String v) { this.toolCallId = v; return this; } @@ -1771,12 +1774,11 @@ public static class Builder { public Builder input(String v) { this.input = v; return this; } public Builder providerExecuted(Boolean v) { this.providerExecuted = v; return this; } public Builder dynamic(Boolean v) { this.dynamic = v; return this; } - public Builder thoughtSignature(String v) { this.thoughtSignature = v; return this; } public Builder providerMetadata(JsonNode v) { this.providerMetadata = v; return this; } public RawToolCall build() { return new RawToolCall(toolCallId, toolName, input, providerExecuted, dynamic, - thoughtSignature, providerMetadata); + providerMetadata); } } } @@ -2320,20 +2322,18 @@ public static class ToolCall extends GenerateContent { @JsonProperty("input") private JsonNode input = emptyObject(); @JsonProperty("provider_executed") private Boolean providerExecuted; @JsonProperty("dynamic") private Boolean dynamic; - @JsonProperty("thought_signature") private String thoughtSignature; @JsonProperty("provider_metadata") private JsonNode providerMetadata; @JsonCreator ToolCall() {} private ToolCall(String toolCallId, String toolName, JsonNode input, Boolean providerExecuted, - Boolean dynamic, String thoughtSignature, JsonNode providerMetadata) { + Boolean dynamic, JsonNode providerMetadata) { this.toolCallId = toolCallId; this.toolName = toolName; this.input = input; this.providerExecuted = providerExecuted; this.dynamic = dynamic; - this.thoughtSignature = thoughtSignature; this.providerMetadata = providerMetadata; } @@ -2342,7 +2342,6 @@ private ToolCall(String toolCallId, String toolName, JsonNode input, Boolean pro public JsonNode getInput() { return input; } public Boolean getProviderExecuted() { return providerExecuted; } public Boolean getDynamic() { return dynamic; } - public String getThoughtSignature() { return thoughtSignature; } public JsonNode getProviderMetadata() { return providerMetadata; } public static Builder builder() { return new Builder(); } @@ -2353,7 +2352,6 @@ public static class Builder { private JsonNode input = emptyObject(); private Boolean providerExecuted; private Boolean dynamic; - private String thoughtSignature; private JsonNode providerMetadata; public Builder toolCallId(String v) { this.toolCallId = v; return this; } @@ -2361,11 +2359,10 @@ public static class Builder { public Builder input(JsonNode v) { this.input = v; return this; } public Builder providerExecuted(Boolean v) { this.providerExecuted = v; return this; } public Builder dynamic(Boolean v) { this.dynamic = v; return this; } - public Builder thoughtSignature(String v) { this.thoughtSignature = v; return this; } public Builder providerMetadata(JsonNode v) { this.providerMetadata = v; return this; } public ToolCall build() { - return new ToolCall(toolCallId, toolName, input, providerExecuted, dynamic, thoughtSignature, + return new ToolCall(toolCallId, toolName, input, providerExecuted, dynamic, providerMetadata); } } @@ -2380,13 +2377,12 @@ public boolean equals(Object o) { && Objects.equals(input, that.input) && Objects.equals(providerExecuted, that.providerExecuted) && Objects.equals(dynamic, that.dynamic) - && Objects.equals(thoughtSignature, that.thoughtSignature) && Objects.equals(providerMetadata, that.providerMetadata); } @Override public int hashCode() { - return Objects.hash(toolCallId, toolName, input, providerExecuted, dynamic, thoughtSignature, + return Objects.hash(toolCallId, toolName, input, providerExecuted, dynamic, providerMetadata); } } @@ -3543,7 +3539,6 @@ public static class ToolCall extends StreamPart { @JsonProperty("input") private JsonNode input = emptyObject(); @JsonProperty("provider_executed") private Boolean providerExecuted; @JsonProperty("dynamic") private Boolean dynamic; - @JsonProperty("thought_signature") private String thoughtSignature; @JsonProperty("provider_metadata") private JsonNode providerMetadata; @JsonProperty("invalid") private Boolean invalid; @JsonProperty("error") private JsonNode error; @@ -3552,14 +3547,13 @@ public static class ToolCall extends StreamPart { ToolCall() {} private ToolCall(String toolCallId, String toolName, JsonNode input, Boolean providerExecuted, - Boolean dynamic, String thoughtSignature, JsonNode providerMetadata, Boolean invalid, + Boolean dynamic, JsonNode providerMetadata, Boolean invalid, JsonNode error) { this.toolCallId = toolCallId; this.toolName = toolName; this.input = input; this.providerExecuted = providerExecuted; this.dynamic = dynamic; - this.thoughtSignature = thoughtSignature; this.providerMetadata = providerMetadata; this.invalid = invalid; this.error = error; @@ -3570,7 +3564,6 @@ private ToolCall(String toolCallId, String toolName, JsonNode input, Boolean pro public JsonNode getInput() { return input; } public Boolean getProviderExecuted() { return providerExecuted; } public Boolean getDynamic() { return dynamic; } - public String getThoughtSignature() { return thoughtSignature; } public JsonNode getProviderMetadata() { return providerMetadata; } /** Set by Core when the tool call stays invalid after optional repair. */ public Boolean getInvalid() { return invalid; } @@ -3585,7 +3578,6 @@ public static class Builder { private JsonNode input = emptyObject(); private Boolean providerExecuted; private Boolean dynamic; - private String thoughtSignature; private JsonNode providerMetadata; private Boolean invalid; private JsonNode error; @@ -3595,13 +3587,12 @@ public static class Builder { public Builder input(JsonNode v) { this.input = v; return this; } public Builder providerExecuted(Boolean v) { this.providerExecuted = v; return this; } public Builder dynamic(Boolean v) { this.dynamic = v; return this; } - public Builder thoughtSignature(String v) { this.thoughtSignature = v; return this; } public Builder providerMetadata(JsonNode v) { this.providerMetadata = v; return this; } public Builder invalid(Boolean v) { this.invalid = v; return this; } public Builder error(JsonNode v) { this.error = v; return this; } public ToolCall build() { - return new ToolCall(toolCallId, toolName, input, providerExecuted, dynamic, thoughtSignature, + return new ToolCall(toolCallId, toolName, input, providerExecuted, dynamic, providerMetadata, invalid, error); } } @@ -3616,7 +3607,6 @@ public boolean equals(Object o) { && Objects.equals(input, that.input) && Objects.equals(providerExecuted, that.providerExecuted) && Objects.equals(dynamic, that.dynamic) - && Objects.equals(thoughtSignature, that.thoughtSignature) && Objects.equals(providerMetadata, that.providerMetadata) && Objects.equals(invalid, that.invalid) && Objects.equals(error, that.error); @@ -3624,7 +3614,7 @@ public boolean equals(Object o) { @Override public int hashCode() { - return Objects.hash(toolCallId, toolName, input, providerExecuted, dynamic, thoughtSignature, + return Objects.hash(toolCallId, toolName, input, providerExecuted, dynamic, providerMetadata, invalid, error); } } diff --git a/bindings/java/src/test/java/ai/arcships/aimux/ContractTest.java b/bindings/java/src/test/java/ai/arcships/aimux/ContractTest.java deleted file mode 100644 index 30f7b1d19..000000000 --- a/bindings/java/src/test/java/ai/arcships/aimux/ContractTest.java +++ /dev/null @@ -1,398 +0,0 @@ -package ai.arcships.aimux; - -import com.fasterxml.jackson.databind.JsonNode; -import org.junit.jupiter.api.Test; - -import java.io.File; -import java.io.IOException; -import java.util.Arrays; -import java.util.LinkedHashMap; -import java.util.Map; - -import static org.assertj.core.api.Assertions.assertThat; - -/** - * Contract tests — validate the Java binding's wire-format consistency against the - * shared fixtures in {@code contract-tests/fixtures/wire-format.json} (the same - * fixtures used by the Rust contract tests and the Node runner). - * - *

One deliberate deviation from a strict "deserialize → re-serialize == fixture" - * round-trip, documented here because it reflects the binding's real behavior: - * - *

    - *
  1. Null-field omission. {@link Types.AimuxJson} uses - * {@code NON_NULL} inclusion (mirroring the Kotlin binding's intent of - * not emitting unset fields): only {@code null} fields are omitted on - * encode. {@code stream_part_stream_start} carries {@code "warnings": []}, - * an empty (non-null) list, so re-serialization keeps it — the round-trip - * is exact.
  2. - *
- * - *

A second "deviation" used to be documented here: the {@code stream_part_finish} - * fixture encoded the finish reason as an externally-tagged object, - * {@code {"unified":{"stop":null}}}, which no binding could deserialize. That was - * not a deviation — the fixture described a shape Rust never emits (its wire form - * is the bare string {@code "stop"}), and nothing pinned the fixture to Rust's real - * output, so the drift survived as a documented "expected failure". The fixture is - * now correct and this test deserializes it like any other. - * - *

These tests are pure serialization tests: no native library is loaded. - */ -class ContractTest { - - /** Candidate locations for the shared fixtures, relative to the test working dir. */ - private static final String[] FIXTURE_CANDIDATES = { - "../../contract-tests/fixtures/wire-format.json", // gradle test cwd = bindings/java - "contract-tests/fixtures/wire-format.json", // run from the repo root - "../contract-tests/fixtures/wire-format.json", - }; - - // ── fixture loading ────────────────────────────────────────────────────── - - private static JsonNode loadFixtures() throws IOException { - for (String candidate : FIXTURE_CANDIDATES) { - File f = new File(candidate); - if (f.isFile()) { - return Types.AimuxJson.MAPPER.readTree(f); - } - } - throw new IllegalStateException( - "cannot find wire-format.json; tried " + Arrays.toString(FIXTURE_CANDIDATES) - + " from " + new File(".").getAbsolutePath()); - } - - private static JsonNode fixture(JsonNode fixtures, String name) { - for (JsonNode f : fixtures) { - if (name.equals(f.path("name").asText())) { - return f; - } - } - throw new IllegalStateException("no fixture named '" + name + "'"); - } - - private static String fixtureJson(JsonNode fixtures, String name) { - return fixture(fixtures, name).get("json").asText(); - } - - /** Semantic JSON equality (ignores key order / whitespace). */ - private static boolean jsonEquals(String a, String b) throws IOException { - return Types.AimuxJson.MAPPER.readTree(a).equals(Types.AimuxJson.MAPPER.readTree(b)); - } - - @Test - void topLevelToolCallProviderMetadataRoundTrips() throws Exception { - String json = "{\"tool_call_id\":\"call_1\",\"tool_name\":\"get_weather\"," - + "\"input\":{\"city\":\"Paris\"}," - + "\"thought_signature\":\"sig_top\"," - + "\"provider_metadata\":{\"openai\":{\"itemId\":\"item_1\"}}}"; - - Types.ToolCall call = Types.AimuxJson.MAPPER.readValue(json, Types.ToolCall.class); - assertThat(call.getProviderMetadata().path("openai").path("itemId").asText()) - .isEqualTo("item_1"); - assertThat(call.getThoughtSignature()).isEqualTo("sig_top"); - assertThat(jsonEquals(Types.AimuxJson.MAPPER.writeValueAsString(call), json)).isTrue(); - - JsonNode metadata = Types.AimuxJson.MAPPER.readTree("{\"openai\":{\"itemId\":\"item_2\"}}"); - assertThat(Types.ToolCall.builder().providerMetadata(metadata).build().getProviderMetadata()) - .isEqualTo(metadata); - } - - @Test - void providerExecutedToolTranscriptMessageRoundTrips() throws Exception { - String json = fixtureJson( - loadFixtures(), "model_message_provider_executed_tool_transcript"); - Types.ModelMessage message = - Types.AimuxJson.MAPPER.readValue(json, Types.ModelMessage.class); - - assertThat(message.getRole()).isEqualTo(Types.Role.ASSISTANT); - assertThat(message.getContentParts()).hasSize(2); - Types.ContentPart.ToolCall call = - (Types.ContentPart.ToolCall) message.getContentParts().get(0); - assertThat(call.getProviderExecuted()).isTrue(); - assertThat(call.getThoughtSignature()).isEqualTo("sig_provider"); - Types.ContentPart.ToolResult result = - (Types.ContentPart.ToolResult) message.getContentParts().get(1); - assertThat(result.getToolName()).isEqualTo("search"); - assertThat(result.getIsError()).isFalse(); - assertThat(result.getPreliminary()).isTrue(); - assertThat(result.getDynamic()).isTrue(); - - String reencoded = Types.AimuxJson.MAPPER.writeValueAsString(message); - assertThat(jsonEquals(reencoded, json)).isTrue(); - } - - // ── ToolChoice: round-trip + semantic type assertions ─────────────────── - - @Test - void toolChoiceFixturesRoundTripAndTypes() throws Exception { - JsonNode fixtures = loadFixtures(); - - // tool_choice_auto → "auto" - String autoJson = fixtureJson(fixtures, "tool_choice_auto"); - Types.ToolChoice auto = Types.AimuxJson.MAPPER.readValue(autoJson, Types.ToolChoice.class); - assertThat(auto).isInstanceOf(Types.ToolChoice.Auto.class); - String autoOut = Types.AimuxJson.MAPPER.writeValueAsString(auto); - assertThat(autoOut).isEqualTo(autoJson); - - // tool_choice_none → "none" - String noneJson = fixtureJson(fixtures, "tool_choice_none"); - Types.ToolChoice none = Types.AimuxJson.MAPPER.readValue(noneJson, Types.ToolChoice.class); - assertThat(none).isInstanceOf(Types.ToolChoice.None.class); - assertThat(Types.AimuxJson.MAPPER.writeValueAsString(none)).isEqualTo(noneJson); - - // tool_choice_required → "required" - String requiredJson = fixtureJson(fixtures, "tool_choice_required"); - Types.ToolChoice required = Types.AimuxJson.MAPPER.readValue(requiredJson, Types.ToolChoice.class); - assertThat(required).isInstanceOf(Types.ToolChoice.Required.class); - assertThat(Types.AimuxJson.MAPPER.writeValueAsString(required)).isEqualTo(requiredJson); - - // tool_choice_tool → {"type":"tool","toolName":"get_weather"} - String toolJson = fixtureJson(fixtures, "tool_choice_tool"); - Types.ToolChoice tool = Types.AimuxJson.MAPPER.readValue(toolJson, Types.ToolChoice.class); - assertThat(tool).isInstanceOf(Types.ToolChoice.Tool.class); - assertThat(((Types.ToolChoice.Tool) tool).getToolName()).isEqualTo("get_weather"); - String toolOut = Types.AimuxJson.MAPPER.writeValueAsString(tool); - assertThat(toolOut).isEqualTo(toolJson); - } - - // ── StreamPart: round-trip + semantic type assertions ─────────────────── - - @Test - void streamPartTextDeltaRoundTripAndType() throws Exception { - JsonNode fixtures = loadFixtures(); - String json = fixtureJson(fixtures, "stream_part_text_delta"); - - Types.StreamPart part = Types.AimuxJson.MAPPER.readValue(json, Types.StreamPart.class); - assertThat(part).isInstanceOf(Types.StreamPart.TextDelta.class); - Types.StreamPart.TextDelta delta = (Types.StreamPart.TextDelta) part; - assertThat(delta.getId()).isEqualTo("tx1"); - assertThat(delta.getDelta()).isEqualTo("Hello"); - - // Exact round-trip: NON_NULL only omits the (null) provider_metadata. - String out = Types.AimuxJson.MAPPER.writeValueAsString(part); - assertThat(out).isEqualTo(json); - } - - @Test - void streamPartStreamStartRoundTripAndType() throws Exception { - JsonNode fixtures = loadFixtures(); - String json = fixtureJson(fixtures, "stream_part_stream_start"); - - Types.StreamPart part = Types.AimuxJson.MAPPER.readValue(json, Types.StreamPart.class); - assertThat(part).isInstanceOf(Types.StreamPart.StreamStart.class); - Types.StreamPart.StreamStart start = (Types.StreamPart.StreamStart) part; - assertThat(start.getWarnings()).isEmpty(); - // warnings is an empty (non-null) list; NON_NULL inclusion keeps it, - // so the re-encoded form retains the `warnings` key — exact round-trip. - assertThat(Types.AimuxJson.MAPPER.writeValueAsString(part)) - .isEqualTo("{\"StreamStart\":{\"warnings\":[]}}"); - } - - /// RFC-0016 M2: the shared `stream_part_raw` fixture decodes to the Java - /// `Raw` variant and round-trips exactly. - @Test - void streamPartRawRoundTripAndType() throws Exception { - JsonNode fixtures = loadFixtures(); - String json = fixtureJson(fixtures, "stream_part_raw"); - - Types.StreamPart part = Types.AimuxJson.MAPPER.readValue(json, Types.StreamPart.class); - assertThat(part).isInstanceOf(Types.StreamPart.Raw.class); - Types.StreamPart.Raw raw = (Types.StreamPart.Raw) part; - assertThat(raw.getRawValue()).isNotNull(); - assertThat(raw.getRawValue().path("id").asText()).isEqualTo("c1"); - assertThat(raw.getRawValue().path("choices").isArray()).isTrue(); - - // Exact round-trip (NON_NULL: raw_value is the only inner field). - assertThat(Types.AimuxJson.MAPPER.writeValueAsString(part)).isEqualTo(json); - } - - @Test - void streamPartToolCallThoughtSignatureRoundTrips() throws Exception { - String json = "{\"ToolCall\":{\"tool_call_id\":\"stream_1\",\"tool_name\":\"search\"," - + "\"input\":{\"query\":\"Rust\"},\"thought_signature\":\"sig_stream\"}}"; - - Types.StreamPart part = Types.AimuxJson.MAPPER.readValue(json, Types.StreamPart.class); - assertThat(part).isInstanceOf(Types.StreamPart.ToolCall.class); - Types.StreamPart.ToolCall call = (Types.StreamPart.ToolCall) part; - assertThat(call.getThoughtSignature()).isEqualTo("sig_stream"); - assertThat(jsonEquals(Types.AimuxJson.MAPPER.writeValueAsString(call), json)).isTrue(); - } - - /// RFC-0016 M10: `Usage.raw` with a vendor-specific field survives a - /// Java round-trip (NON_NULL omits a null raw, keeps a non-null one). - @Test - void usageRawPreservesVendorFieldsRoundTrip() throws Exception { - Types.Usage usage = Types.Usage.builder() - .inputTokens(Types.TokenUsage.builder().total(20L).build()) - .outputTokens(Types.TokenUsage.builder().total(5L).build()) - .raw(Types.AimuxJson.MAPPER.readTree( - "{\"prompt_cache_hit_tokens\":42,\"prompt_tokens\":20}")) - .build(); - - String json = Types.AimuxJson.MAPPER.writeValueAsString(usage); - assertThat(json).contains("\"raw\":{\"prompt_cache_hit_tokens\":42"); - Types.Usage decoded = Types.AimuxJson.MAPPER.readValue(json, Types.Usage.class); - assertThat(decoded.getRaw()).isNotNull(); - assertThat(decoded.getRaw().path("prompt_cache_hit_tokens").asInt()).isEqualTo(42); - assertThat(decoded.getRaw().path("prompt_tokens").asInt()).isEqualTo(20); - } - - /// RFC-0024: `session_id` crosses the wire as snake_case and round-trips. - @Test - void generateTextOptionsSessionIdWireAndRoundTrip() throws Exception { - // Wire shape: the shared fixture. - JsonNode fixtures = loadFixtures(); - String json = fixtureJson(fixtures, "generate_text_options_with_session_id"); - Types.GenerateTextOptions opts = - Types.AimuxJson.MAPPER.readValue(json, Types.GenerateTextOptions.class); - assertThat(opts.getSessionId()).isEqualTo("sess-1"); - - // Java-native round-trip with the Builder. - Types.GenerateTextOptions nativeOpts = Types.GenerateTextOptions.builder() - .sessionId("sess-1") - .build(); - String nativeJson = Types.AimuxJson.MAPPER.writeValueAsString(nativeOpts); - assertThat(nativeJson).contains("\"session_id\":\"sess-1\""); - Types.GenerateTextOptions decoded = - Types.AimuxJson.MAPPER.readValue(nativeJson, Types.GenerateTextOptions.class); - assertThat(decoded.getSessionId()).isEqualTo("sess-1"); - } - - /** - * Every {@code GenerateContent} fixture decodes into its concrete variant. - * - *

The variant type is asserted, not merely the absence of an exception. - * {@code GenerateContent} falls back to {@link Types.GenerateContent.Unknown} - * for forward compatibility, so a variant whose shape drifted would still - * decode — silently, as {@code Unknown}. Only naming the expected class - * catches that. The fixture count is asserted so a newly added fixture - * cannot slip past this test unnoticed. - */ - @Test - void generateContentFixturesDecodeIntoTheirVariants() throws Exception { - JsonNode fixtures = loadFixtures(); - Map byName = new LinkedHashMap<>(); - for (JsonNode f : fixtures) { - if (!"GenerateContent".equals(f.get("type").asText())) { - continue; - } - String json = f.get("json").asText(); - byName.put( - f.get("name").asText(), - Types.AimuxJson.MAPPER.readValue(json, Types.GenerateContent.class)); - } - assertThat(byName).hasSize(8); - - assertThat(byName.get("generate_content_text")) - .isInstanceOf(Types.GenerateContent.Text.class); - assertThat(byName.get("generate_content_reasoning_no_metadata")) - .isInstanceOf(Types.GenerateContent.Reasoning.class); - - // The shapes a parse-only check leaves invisible: the tool-call input, - // the nested file union, and Source's optionals. - Types.GenerateContent.ToolCall toolCall = - (Types.GenerateContent.ToolCall) byName.get("generate_content_tool_call"); - assertThat(toolCall.getToolCallId()).isEqualTo("call_1"); - assertThat(toolCall.getInput().asText()).isEqualTo("{\"city\":\"Paris\"}"); - assertThat(toolCall.getProviderExecuted()).isTrue(); - assertThat(toolCall.getThoughtSignature()).isEqualTo("sig_abc"); - String toolCallJson = fixtureJson(fixtures, "generate_content_tool_call"); - assertThat(jsonEquals(Types.AimuxJson.MAPPER.writeValueAsString(toolCall), toolCallJson)).isTrue(); - - Types.GenerateContent.File file = - (Types.GenerateContent.File) byName.get("generate_content_file"); - assertThat(file.getMediaType()).isEqualTo("image/png"); - assertThat(file.getData()).isNotNull(); - - Types.GenerateContent.Source source = - (Types.GenerateContent.Source) byName.get("generate_content_source_unset_optionals"); - assertThat(source.getUrl()).isNull(); - assertThat(source.getTitle()).isNull(); - } - - /** - * The regression lock for {@code topK}, which this binding declared as - * {@code Long} against an {@code f64} wire. - * - *

The values are asserted explicitly, and for Java that is not a - * stylistic choice: Jackson coerces a {@code 40.5} token into a - * {@code Long} by truncating it to {@code 40} rather than by failing, so a - * decode-only test would still pass if someone restored the integer type. - * Only comparing the decoded value catches that. {@code topK} is the field - * that was wrong; the rest are asserted so narrowing any single numeric - * type is caught here too. - */ - @Test - void generateTextOptionsNumericTypesDecodeWithFullPrecision() throws Exception { - JsonNode fixtures = loadFixtures(); - String json = fixtureJson(fixtures, "generate_text_options_numeric_types"); - Types.GenerateTextOptions opts = - Types.AimuxJson.MAPPER.readValue(json, Types.GenerateTextOptions.class); - - assertThat(opts.getTopK()).isEqualTo(40.5); - assertThat(opts.getFrequencyPenalty()).isEqualTo(-0.5); - assertThat(opts.getTemperature()).isEqualTo(0.7); - assertThat(opts.getTopP()).isEqualTo(0.95); - assertThat(opts.getPresencePenalty()).isEqualTo(0.5); - assertThat(opts.getMaxOutputTokens()).isEqualTo(256L); - assertThat(opts.getSeed()).isEqualTo(42L); - assertThat(opts.getMaxRetries()).isEqualTo(3L); - } - - @Test - void streamPartFinishWireShapeAndJavaRoundTrip() throws Exception { - JsonNode fixtures = loadFixtures(); - String json = fixtureJson(fixtures, "stream_part_finish"); - - // 1) Wire-level shape (as run-node.ts asserts): single "Finish" tag, usage totals. - JsonNode finishNode = Types.AimuxJson.MAPPER.readTree(json); - assertThat(finishNode.size()).isEqualTo(1); - JsonNode inner = finishNode.get("Finish"); - assertThat(inner).isNotNull(); - assertThat(inner.path("finish_reason").path("unified").asText()).isEqualTo("stop"); - assertThat(inner.path("usage").path("input_tokens").path("total").asInt()).isEqualTo(10); - assertThat(inner.path("usage").path("output_tokens").path("total").asInt()).isEqualTo(20); - assertThat(inner.path("provider_metadata").isNull()).isTrue(); - - // 2) The Java binding deserializes the shared fixture itself. It could not - // before: the fixture carried an externally-tagged enum form, - // {"unified":{"stop":null}}, that Rust never emits, and this test used - // to assert that failure as if it were the contract. - Types.StreamPart fromFixture = - Types.AimuxJson.MAPPER.readValue(json, Types.StreamPart.class); - assertThat(fromFixture).isInstanceOf(Types.StreamPart.Finish.class); - Types.StreamPart.Finish fixtureFinish = (Types.StreamPart.Finish) fromFixture; - assertThat(fixtureFinish.getFinishReason().getUnified()) - .isEqualTo(Types.FinishReasonUnified.STOP); - assertThat(fixtureFinish.getUsage().getInputTokens().getTotal()).isEqualTo(10L); - assertThat(fixtureFinish.getUsage().getOutputTokens().getTotal()).isEqualTo(20L); - - // 3) Java-native Finish round-trips with the binding's own encoding. - Types.StreamPart.Finish nativeFinish = Types.StreamPart.Finish.builder() - .finishReason(Types.FinishReason.builder().unified(Types.FinishReasonUnified.STOP).build()) - .usage(Types.Usage.of(10L, 20L)) - .build(); - String nativeJson = Types.AimuxJson.MAPPER.writeValueAsString(nativeFinish); - // NON_NULL omits the null raw / provider_metadata fields. - assertThat(nativeJson).isEqualTo( - "{\"Finish\":{\"finish_reason\":{\"unified\":\"stop\"}," - + "\"usage\":{\"input_tokens\":{\"total\":10},\"output_tokens\":{\"total\":20}}}}"); - Types.StreamPart decoded = Types.AimuxJson.MAPPER.readValue(nativeJson, Types.StreamPart.class); - assertThat(decoded).isInstanceOf(Types.StreamPart.Finish.class); - Types.StreamPart.Finish f = (Types.StreamPart.Finish) decoded; - assertThat(f.getFinishReason().getUnified()).isEqualTo(Types.FinishReasonUnified.STOP); - assertThat(f.getUsage().getInputTokens().getTotal()).isEqualTo(10L); - assertThat(f.getUsage().getOutputTokens().getTotal()).isEqualTo(20L); - } - - // ── sanity: every fixture parses as valid JSON (mirrors the Rust contract test) ── - - @Test - void allFixturesAreValidJson() throws Exception { - JsonNode fixtures = loadFixtures(); - assertThat(fixtures.isArray() && fixtures.size() > 0).isTrue(); - for (JsonNode f : fixtures) { - Types.AimuxJson.MAPPER.readTree(f.get("json").asText()); - } - } -} diff --git a/bindings/java/src/test/java/ai/arcships/aimux/ToolCallRepairTest.java b/bindings/java/src/test/java/ai/arcships/aimux/ToolCallRepairTest.java deleted file mode 100644 index 3b7fa0da0..000000000 --- a/bindings/java/src/test/java/ai/arcships/aimux/ToolCallRepairTest.java +++ /dev/null @@ -1,432 +0,0 @@ -package ai.arcships.aimux; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.node.ObjectNode; -import org.json.JSONArray; -import org.json.JSONObject; -import org.junit.jupiter.api.Test; - -import java.io.File; -import java.io.IOException; -import java.time.Duration; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicReference; -import java.util.stream.Collectors; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively; - -/** - * Host-side tool-call repair (RFC-0035). - * - *

Two layers: a replay of the shared - * {@code contract-tests/fixtures/tool-call-repair.json} over the three native - * functions, plus public typed non-streaming/streaming integration and the - * same-model nested-generation deadlock regression. Tests use only mock - * providers, but need the native library on {@code java.library.path}. - */ -class ToolCallRepairTest { - - private static final String[] FIXTURE_CANDIDATES = { - "../../contract-tests/fixtures/tool-call-repair.json", // gradle test cwd = bindings/java - "contract-tests/fixtures/tool-call-repair.json", // run from the repo root - "../contract-tests/fixtures/tool-call-repair.json", - }; - - // ── shared fixture replay ──────────────────────────────────────────────── - - @Test - void contractFixtureReplays() throws Exception { - JsonNode cases = loadFixture().get("cases"); - List replayed = new ArrayList<>(); - for (JsonNode c : cases) { - String name = c.get("name").asText(); - String expectedError = c.path("expected_error").asText(null); - if (expectedError != null) { - // Every error case in the fixture is InvalidArgument; if a new - // variant appears, this pins it rather than silently passing. - assertThat(expectedError).describedAs(name).isEqualTo("InvalidArgument"); - assertThatThrownBy(() -> invoke(c)) - .describedAs(name) - .isInstanceOf(AimuxException.InvalidArgumentError.class); - } else { - assertThat(json(invoke(c))).describedAs(name).isEqualTo(c.get("expected")); - } - replayed.add(name); - } - assertThat(replayed).hasSize(cases.size()); - } - - private static String invoke(JsonNode testCase) { - JsonNode in = testCase.get("input"); - String function = testCase.get("function").asText(); - String opts = text(in.get("opts")); - if ("tool_call_repair_context".equals(function)) { - return ToolCallRepairs.context(in.get("tool_call").toString(), text(in.get("prompt")), opts); - } - if ("apply_tool_call_repair".equals(function)) { - return ToolCallRepairs.apply(in.get("tool_call").toString(), opts, in.get("reply").toString()); - } - if ("apply_tool_call_repair_to_result".equals(function)) { - return ToolCallRepairs.applyToResult(in.get("result").toString(), opts, - in.get("tool_call_id").asText(), in.get("reply").toString()); - } - throw new AssertionError("unknown fixture function: " + function); - } - - // ── end to end through TypedModel (mock provider) ──────────────────────── - - /** The strict tool wants {@code location}; the canned calls send {@code city}. */ - private static Types.RawToolCall cityToLocation(Types.ToolCallRepairContext context) { - return Types.RawToolCall.builder() - .toolCallId(context.getToolCall().getToolCallId()) - .toolName(context.getToolCall().getToolName()) - .input(context.getToolCall().getInput().replace("city", "location")) - .build(); - } - - @Test - void generateTextRepairsTheInvalidCallItGotBack() { - try (MockProviderServer server = new MockProviderServer()) { - server.setResponseBody(toolCallResponse("{\"city\":\"Tokyo\"}")); - Types.GenerateTextOptions options = Types.GenerateTextOptions.builder() - .tools(Collections.singletonList(strictWeatherTool())) - .repairToolCall(context -> { - assertThat(context.getToolCall().getInput()).isEqualTo("{\"city\":\"Tokyo\"}"); - return cityToLocation(context); - }) - .build(); - - try (TypedModel model = - TypedModel.openaiWithBase("sk-test-fake-key", "gpt-4o", server.baseUrl())) { - Types.GenerateTextResult result = - model.generateText("What is the weather in Tokyo?", options); - - Types.ToolCall call = result.getToolCalls().get(0); - assertThat(call.getInvalid()).isNull(); - assertThat(call.getInput().get("location").asText()).isEqualTo("Tokyo"); - Types.ContentPart.ToolCall transcriptCall = result.getResponseMessages().stream() - .flatMap(message -> message.getContentParts().stream()) - .filter(part -> part instanceof Types.ContentPart.ToolCall) - .map(part -> (Types.ContentPart.ToolCall) part) - .findFirst().orElseThrow(AssertionError::new); - assertThat(transcriptCall.getInput().get("location").asText()).isEqualTo("Tokyo"); - } - } - } - - @Test - void generateTextAsOpenAICarriesTheRepairedArguments() { - try (MockProviderServer server = new MockProviderServer()) { - server.setResponseBody(toolCallResponse("{\"city\":\"Tokyo\"}")); - Types.GenerateTextOptions options = Types.GenerateTextOptions.builder() - .tools(Collections.singletonList(strictWeatherTool())) - .repairToolCall(ToolCallRepairTest::cityToLocation) - .build(); - - try (TypedModel model = - TypedModel.openaiWithBase("sk-test-fake-key", "gpt-4o", server.baseUrl())) { - // A ChatCompletion has no invalid marker: the native result is - // repaired, then converted. - Types.ChatCompletion completion = - model.generateTextAsOpenAI("What is the weather in Tokyo?", options); - - Types.ChatCompletionToolCall call = - completion.getChoices().get(0).getMessage().getToolCalls().get(0); - assertThat(call.getFunction().getArguments()).isEqualTo("{\"location\":\"Tokyo\"}"); - } - } - } - - @Test - void aStreamRepairFailingAtTheBoundaryStillDeliversTheCall() { - try (MockProviderServer server = new MockProviderServer()) { - server.setContentType("text/event-stream"); - server.setResponseBody(toolCallSse("{\"city\":\"Tokyo\"}")); - // A replacement without a tool_call_id is not a RawToolCall on the - // wire: the library rejects the reply itself (not the hook failing). - Types.GenerateTextOptions options = Types.GenerateTextOptions.builder() - .tools(Collections.singletonList(strictWeatherTool())) - .repairToolCall(context -> Types.RawToolCall.builder() - .toolCallId(null) - .toolName(context.getToolCall().getToolName()) - .input("{\"location\":\"Tokyo\"}") - .build()) - .build(); - - try (TypedModel model = - TypedModel.openaiWithBase("sk-test-fake-key", "gpt-4o", server.baseUrl())) { - List parts = new ArrayList<>(); - List errors = new ArrayList<>(); - model.streamText("What is the weather in Tokyo?", options, - parts::add, () -> { }, errors::add); - - assertThat(errors).hasSize(1); - assertThat(errors.get(0)).startsWith("failed to repair tool call:"); - List calls = parts.stream() - .filter(p -> p instanceof Types.StreamPart.ToolCall) - .map(p -> (Types.StreamPart.ToolCall) p) - .collect(Collectors.toList()); - assertThat(calls).hasSize(1); - assertThat(calls.get(0).getInvalid()).isTrue(); - } - } - } - - @Test - void streamTextStreamDeliversTheRepairedToolCallPart() { - try (MockProviderServer server = new MockProviderServer()) { - server.setContentType("text/event-stream"); - server.setResponseBody(toolCallSse("{\"city\":\"Tokyo\"}")); - Types.GenerateTextOptions options = Types.GenerateTextOptions.builder() - .tools(Collections.singletonList(strictWeatherTool())) - .repairToolCall(ToolCallRepairTest::cityToLocation) - .build(); - - try (TypedModel model = - TypedModel.openaiWithBase("sk-test-fake-key", "gpt-4o", server.baseUrl())) { - List parts = - model.streamTextStream("What is the weather in Tokyo?", options) - .collect(Collectors.toList()); - - List calls = parts.stream() - .filter(p -> p instanceof Types.StreamPart.ToolCall) - .map(p -> (Types.StreamPart.ToolCall) p) - .collect(Collectors.toList()); - assertThat(calls).hasSize(1); - assertThat(calls.get(0).getInvalid()).isNull(); - assertThat(calls.get(0).getInput().get("location").asText()).isEqualTo("Tokyo"); - - // Argument deltas are forwarded verbatim, repair or not. - assertThat(parts.stream() - .filter(p -> p instanceof Types.StreamPart.ToolInputDelta) - .map(p -> ((Types.StreamPart.ToolInputDelta) p).getDelta()) - .collect(Collectors.joining())).contains("city"); - } - } - } - - @Test - void theRepairLoopHandlesThrowNullValidCallAndNoToolSet() { - try (MockProviderServer server = new MockProviderServer(); - TypedModel model = - TypedModel.openaiWithBase("sk-test-fake-key", "gpt-4o", server.baseUrl())) { - final AtomicInteger hookCalls = new AtomicInteger(); - ToolCallRepair countingNull = context -> { - hookCalls.incrementAndGet(); - return null; - }; - - // The hook throws: ToolCallRepair { original_error, cause: Other }. - server.setResponseBody(toolCallResponse("{\"city\":\"Tokyo\"}")); - Types.ToolCall call = model.generateText("What is the weather in Tokyo?", - Types.GenerateTextOptions.builder() - .tools(Collections.singletonList(strictWeatherTool())) - .repairToolCall(context -> { - hookCalls.incrementAndGet(); - throw new IllegalStateException("repair model unavailable"); - }) - .build()).getToolCalls().get(0); - assertThat(call.getInvalid()).isTrue(); - JsonNode failure = call.getError().path("ToolCallRepair"); - assertThat(failure.path("cause").path("Other").asText()).isEqualTo("repair model unavailable"); - assertThat(failure.path("original_error").has("InvalidToolInput")).isTrue(); - assertThat(hookCalls.get()).isEqualTo(1); - - // The hook returns null: the ORIGINAL error, not wrapped in ToolCallRepair. - server.setResponseBody(toolCallResponse("{\"city\":\"Tokyo\"}")); - call = model.generateText("What is the weather in Tokyo?", - Types.GenerateTextOptions.builder() - .tools(Collections.singletonList(strictWeatherTool())) - .repairToolCall(countingNull) - .build()).getToolCalls().get(0); - assertThat(call.getInvalid()).isTrue(); - assertThat(call.getError().has("ToolCallRepair")).isFalse(); - assertThat(call.getError().has("InvalidToolInput")).isTrue(); - assertThat(hookCalls.get()).isEqualTo(2); - - // A valid call never reaches the hook. - server.setResponseBody(toolCallResponse("{\"location\":\"Tokyo\"}")); - call = model.generateText("What is the weather in Tokyo?", - Types.GenerateTextOptions.builder() - .tools(Collections.singletonList(strictWeatherTool())) - .repairToolCall(countingNull) - .build()).getToolCalls().get(0); - assertThat(call.getInvalid()).isNull(); - assertThat(hookCalls.get()).isEqualTo(2); - - // No tool set: the native context answers null, so the host skips the hook. - server.setResponseBody(toolCallResponse("{\"city\":\"Tokyo\"}")); - call = model.generateText("What is the weather in Tokyo?", - Types.GenerateTextOptions.builder().repairToolCall(countingNull).build()) - .getToolCalls().get(0); - assertThat(call.getInvalid()).isTrue(); - assertThat(hookCalls.get()).isEqualTo(2); - } - } - - @Test - void aStreamHookMayGenerateWithTheSameModelWhileCloseIsPending() { - // Regression: the stream holds Model's (fair) read lock for its whole - // duration, a queued close() waits for it, and a hook generating with - // the SAME model used to queue behind that writer — S waits for R waits - // for C waits for S. The stream thread lends its read hold to the repair - // worker so the hook's call takes no lock at all. - assertTimeoutPreemptively(Duration.ofSeconds(30), () -> { - try (MockProviderServer server = new MockProviderServer()) { - server.setContentType("text/event-stream"); - server.setResponses(toolCallSse("{\"city\":\"Tokyo\"}"), - textResponse("{\"location\":\"Tokyo\"}")); - - final Model raw = - Model.openaiWithBase("sk-test-fake-key", "gpt-4o", server.baseUrl()); - final TypedModel model = TypedModel.of(raw); - final CountDownLatch hookStarted = new CountDownLatch(1); - final AtomicReference closerFailure = new AtomicReference<>(); - Thread closer = new Thread(() -> { - try { - hookStarted.await(); - raw.close(); - } catch (Throwable t) { - closerFailure.set(t); - } - }, "closer"); - closer.start(); - - Types.GenerateTextOptions options = Types.GenerateTextOptions.builder() - .tools(Collections.singletonList(strictWeatherTool())) - .repairToolCall(context -> { - hookStarted.countDown(); - // Let the closer actually enqueue for the write lock; - // without it the race the test reproduces may not happen. - Thread.sleep(200); - // The mock server's content type is global, so flip it - // now that the SSE response has already been written. - server.setContentType("application/json"); - return Types.RawToolCall.builder() - .toolCallId(context.getToolCall().getToolCallId()) - .toolName(context.getToolCall().getToolName()) - .input(model.generateText( - "fix these arguments: " + context.getToolCall().getInput()) - .getText()) - .build(); - }) - .build(); - - List parts = new ArrayList<>(); - model.streamText("What is the weather in Tokyo?", options, - parts::add, () -> {}, error -> { throw new AssertionError(error); }); - - closer.join(10_000); - assertThat(closer.isAlive()).isFalse(); - assertThat(closerFailure.get()).isNull(); - - List calls = parts.stream() - .filter(p -> p instanceof Types.StreamPart.ToolCall) - .map(p -> (Types.StreamPart.ToolCall) p) - .collect(Collectors.toList()); - assertThat(calls).hasSize(1); - assertThat(calls.get(0).getInvalid()).isNull(); - assertThat(calls.get(0).getInput().get("location").asText()).isEqualTo("Tokyo"); - raw.close(); // idempotent; the closer already did it - } - }); - } - - /** A plain OpenAI text response whose content is {@code text}. */ - private static String textResponse(String text) { - return new JSONObject() - .put("id", "chatcmpl-repair") - .put("model", "gpt-4o") - .put("choices", new JSONArray().put(new JSONObject() - .put("message", new JSONObject().put("role", "assistant").put("content", text)) - .put("finish_reason", "stop"))) - .put("usage", new JSONObject() - .put("prompt_tokens", 4).put("completion_tokens", 4).put("total_tokens", 8)) - .toString(); - } - - /** {@code get_weather}, schema-strict so a {@code city} argument is rejected. */ - private static Types.Tool strictWeatherTool() { - ObjectNode schema = Types.AimuxJson.MAPPER.createObjectNode(); - schema.put("type", "object"); - schema.set("properties", Types.AimuxJson.MAPPER.createObjectNode() - .set("location", Types.AimuxJson.MAPPER.createObjectNode().put("type", "string"))); - schema.set("required", Types.AimuxJson.MAPPER.createArrayNode().add("location")); - schema.put("additionalProperties", false); - return Types.Tool.Function.builder().name("get_weather").inputSchema(schema).build(); - } - - private static String toolCallResponse(String arguments) { - return new JSONObject() - .put("id", "chatcmpl-tc") - .put("model", "gpt-4o") - .put("choices", new JSONArray().put(new JSONObject() - .put("message", new JSONObject() - .put("role", "assistant") - .put("content", JSONObject.NULL) - .put("tool_calls", new JSONArray().put(new JSONObject() - .put("id", "call_abc") - .put("type", "function") - .put("function", new JSONObject() - .put("name", "get_weather") - .put("arguments", arguments))))) - .put("finish_reason", "tool_calls"))) - .put("usage", new JSONObject() - .put("prompt_tokens", 20).put("completion_tokens", 10).put("total_tokens", 30)) - .toString(); - } - - private static String toolCallSse(String arguments) { - StringBuilder sb = new StringBuilder(); - sb.append("data: ").append(new JSONObject() - .put("id", "1").put("model", "gpt-4o") - .put("choices", new JSONArray().put(new JSONObject().put("delta", new JSONObject() - .put("role", "assistant") - .put("tool_calls", new JSONArray().put(new JSONObject() - .put("index", 0).put("id", "call_xyz").put("type", "function") - .put("function", new JSONObject().put("name", "get_weather") - .put("arguments", "")))))))).append("\n\n"); - sb.append("data: ").append(new JSONObject() - .put("id", "1").put("model", "gpt-4o") - .put("choices", new JSONArray().put(new JSONObject().put("delta", new JSONObject() - .put("tool_calls", new JSONArray().put(new JSONObject() - .put("index", 0) - .put("function", new JSONObject().put("arguments", arguments)))))))).append("\n\n"); - sb.append("data: ").append(new JSONObject() - .put("id", "1").put("model", "gpt-4o") - .put("choices", new JSONArray().put(new JSONObject() - .put("delta", new JSONObject()).put("finish_reason", "tool_calls"))) - .put("usage", new JSONObject() - .put("prompt_tokens", 5).put("completion_tokens", 2).put("total_tokens", 7))) - .append("\n\n"); - sb.append("data: [DONE]\n\n"); - return sb.toString(); - } - - private static JsonNode json(String raw) throws IOException { - return Types.AimuxJson.MAPPER.readTree(raw); - } - - private static String text(JsonNode node) { - return node == null || node.isNull() ? null : node.toString(); - } - - private static JsonNode loadFixture() throws IOException { - for (String candidate : FIXTURE_CANDIDATES) { - File f = new File(candidate); - if (f.isFile()) { - return Types.AimuxJson.MAPPER.readTree(f); - } - } - throw new IllegalStateException( - "cannot find tool-call-repair.json; tried " + Arrays.toString(FIXTURE_CANDIDATES) - + " from " + new File(".").getAbsolutePath()); - } -} diff --git a/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/ToolCallRepair.kt b/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/ToolCallRepair.kt index 5e0f4f685..dd913f937 100644 --- a/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/ToolCallRepair.kt +++ b/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/ToolCallRepair.kt @@ -45,7 +45,6 @@ data class RawToolCall( val input: String, @SerialName("provider_executed") val providerExecuted: Boolean? = null, val dynamic: Boolean? = null, - @SerialName("thought_signature") val thoughtSignature: String? = null, @SerialName("provider_metadata") val providerMetadata: JsonElement? = null, ) diff --git a/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Types.kt b/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Types.kt index 2820ef158..d14e4fcfe 100644 --- a/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Types.kt +++ b/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Types.kt @@ -166,7 +166,6 @@ data class ToolCall( val input: JsonElement = JsonObject(emptyMap()), @SerialName("provider_executed") val providerExecuted: Boolean? = null, @SerialName("dynamic") val dynamic: Boolean? = null, - @SerialName("thought_signature") val thoughtSignature: String? = null, @SerialName("provider_metadata") val providerMetadata: JsonElement? = null, val invalid: Boolean? = null, val error: JsonElement? = null, @@ -180,6 +179,9 @@ data class ToolCall( // the `"type"` discriminator — no custom serializer needed. // ───────────────────────────────────────────────────────────────────────────── +@Serializable +data class FunctionToolInputExample(val input: JsonObject) + /** * A user-defined function tool. * @@ -192,7 +194,7 @@ data class FunctionTool( @SerialName("input_schema") val inputSchema: JsonElement, val strict: Boolean? = null, @SerialName("provider_options") val providerOptions: Map? = null, - @SerialName("input_examples") val inputExamples: List? = null, + @SerialName("input_examples") val inputExamples: List? = null, ) /** @@ -204,7 +206,7 @@ data class FunctionTool( data class ProviderTool( val id: String, val name: String, - val args: JsonElement = JsonObject(emptyMap()), + val args: JsonObject = JsonObject(emptyMap()), ) /** @@ -223,7 +225,7 @@ sealed interface Tool { @SerialName("input_schema") val inputSchema: JsonElement, val strict: Boolean? = null, @SerialName("provider_options") val providerOptions: Map? = null, - @SerialName("input_examples") val inputExamples: List? = null, + @SerialName("input_examples") val inputExamples: List? = null, ) : Tool { companion object { /** Convenience constructor from a [FunctionTool]. */ @@ -243,7 +245,7 @@ sealed interface Tool { data class Provider( val id: String, val name: String, - val args: JsonElement = JsonObject(emptyMap()), + val args: JsonObject = JsonObject(emptyMap()), ) : Tool { companion object { fun from(tool: ProviderTool): Provider = Provider(tool.id, tool.name, tool.args) @@ -391,7 +393,6 @@ sealed interface ContentPart { @SerialName("tool_call_id") val toolCallId: String = "", @SerialName("tool_name") val toolName: String = "", val input: JsonElement = JsonObject(emptyMap()), - @SerialName("thought_signature") val thoughtSignature: String? = null, @SerialName("provider_options") val providerOptions: JsonElement? = null, @SerialName("provider_executed") val providerExecuted: Boolean? = null, ) : ContentPart @@ -807,7 +808,6 @@ sealed interface GenerateContent { val input: JsonElement = JsonObject(emptyMap()), @SerialName("provider_executed") val providerExecuted: Boolean? = null, @SerialName("dynamic") val dynamic: Boolean? = null, - @SerialName("thought_signature") val thoughtSignature: String? = null, @SerialName("provider_metadata") val providerMetadata: JsonElement? = null, ) : GenerateContent @@ -1099,7 +1099,6 @@ sealed interface StreamPart { val input: JsonElement = JsonObject(emptyMap()), @SerialName("provider_executed") val providerExecuted: Boolean? = null, @SerialName("dynamic") val dynamic: Boolean? = null, - @SerialName("thought_signature") val thoughtSignature: String? = null, @SerialName("provider_metadata") val providerMetadata: JsonElement? = null, val invalid: Boolean? = null, val error: JsonElement? = null, diff --git a/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/ContractTest.kt b/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/ContractTest.kt deleted file mode 100644 index bfede7f26..000000000 --- a/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/ContractTest.kt +++ /dev/null @@ -1,207 +0,0 @@ -package ai.arcships.aimux - -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive -import kotlinx.serialization.json.put -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Test -import java.io.File - -/** - * Cross-language contract tests for the Kotlin binding. - * - * Consumes the shared `contract-tests/fixtures/wire-format.json` (same - * fixtures as Rust `contract_test.rs`, Node `run-node.ts`, Java - * `ContractTest` and Go `wire_format_test.go`). - * - * Pure serialization tests: no native library is loaded. - */ -class ContractTest { - - /** Candidate locations for the shared fixtures, relative to the test working dir. */ - private val fixtureCandidates = listOf( - "../../contract-tests/fixtures/wire-format.json", // gradle test cwd = bindings/kotlin - "contract-tests/fixtures/wire-format.json", // run from the repo root - "../contract-tests/fixtures/wire-format.json", - ) - - private fun loadFixtures(): JsonArray { - val file = fixtureCandidates.map(::File).firstOrNull { it.isFile } - ?: throw IllegalStateException( - "cannot find wire-format.json; tried $fixtureCandidates from ${File(".").absolutePath}" - ) - return Json.parseToJsonElement(file.readText()).jsonArray - } - - private fun fixtureJson(fixtures: JsonArray, name: String): String { - for (f in fixtures) { - if (name == f.jsonObject["name"]?.jsonPrimitive?.content) { - return f.jsonObject["json"]!!.jsonPrimitive.content - } - } - throw IllegalStateException("no fixture named '$name'") - } - - /// RFC-0016 M2: the shared `stream_part_raw` fixture decodes to the - /// Kotlin `StreamPart.Raw` variant with the raw payload intact. - @Test - fun `stream part raw fixture decodes to Raw variant`() { - val json = fixtureJson(loadFixtures(), "stream_part_raw") - val part = AimuxJson.decodeFromString(json) - assertThat(part).isInstanceOf(StreamPart.Raw::class.java) - val raw = part as StreamPart.Raw - assertThat(raw.rawValue.jsonObject["id"]?.jsonPrimitive?.content).isEqualTo("c1") - assertThat(raw.rawValue.jsonObject["choices"]).isNotNull - } - - /// RFC-0016 M2 true-case: `include_raw_chunks: true` round-trips through - /// the Kotlin typed options. - @Test - fun `include raw chunks true round-trips through typed options`() { - val json = fixtureJson(loadFixtures(), "generate_text_options_include_raw_chunks_true") - val opts = AimuxJson.decodeFromString(json) - assertThat(opts.includeRawChunks).isTrue() - - // Re-encode: include_raw_chunks must survive (encodeDefaults=false, - // so only non-default fields are emitted). - val reencoded = AimuxJson.encodeToString(GenerateTextOptions.serializer(), opts) - assertThat(reencoded).contains("\"include_raw_chunks\":true") - } - - /// RFC-0024: `session_id` decodes from the shared fixture and round-trips. - @Test - fun `session id wire format and round-trip`() { - val json = fixtureJson(loadFixtures(), "generate_text_options_with_session_id") - val opts = AimuxJson.decodeFromString(json) - assertThat(opts.sessionId).isEqualTo("sess-1") - - val reencoded = AimuxJson.encodeToString(GenerateTextOptions.serializer(), opts) - assertThat(reencoded).contains("\"session_id\":\"sess-1\"") - } - - @Test - fun `provider executed tool transcript message round-trips`() { - val json = fixtureJson( - loadFixtures(), - "model_message_provider_executed_tool_transcript", - ) - val message = AimuxJson.decodeFromString(json) - assertThat(message.role).isEqualTo(Role.ASSISTANT) - val parts = message.contentParts!! - assertThat(parts).hasSize(2) - val call = parts[0] as ContentPart.ToolCall - assertThat(call.providerExecuted).isTrue() - val result = parts[1] as ContentPart.ToolResult - assertThat(result.toolName).isEqualTo("search") - assertThat(result.isError).isFalse() - assertThat(result.preliminary).isTrue() - assertThat(result.dynamic).isTrue() - - val reencoded = AimuxJson.encodeToString(ModelMessage.serializer(), message) - assertThat(AimuxJson.parseToJsonElement(reencoded)) - .isEqualTo(AimuxJson.parseToJsonElement(json)) - } - - /// Every `GenerateContent` fixture decodes into its concrete variant. - /// - /// The variant type is asserted, not merely the absence of an exception: - /// `GenerateContent` falls back to [GenerateContent.Unknown] for forward - /// compatibility, so a variant whose shape drifted would still decode — - /// silently, as `Unknown`. Only naming the expected class catches that. - /// The fixture count is asserted so a newly added fixture cannot slip past - /// this test unnoticed. - @Test - fun `generate content fixtures decode into their variants`() { - val fixtures = loadFixtures() - val byName = LinkedHashMap() - for (element in fixtures) { - val obj = element.jsonObject - if (obj["type"]?.jsonPrimitive?.content != "GenerateContent") continue - val name = obj["name"]!!.jsonPrimitive.content - val json = obj["json"]!!.jsonPrimitive.content - byName[name] = AimuxJson.decodeFromString(json) - } - assertThat(byName).hasSize(8) - - assertThat(byName["generate_content_text"]).isInstanceOf(GenerateContent.Text::class.java) - assertThat(byName["generate_content_reasoning_no_metadata"]) - .isInstanceOf(GenerateContent.Reasoning::class.java) - - // The shapes a parse-only check leaves invisible: the tool-call input, - // the nested file union, and Source's optionals. - val toolCall = byName["generate_content_tool_call"] as GenerateContent.ToolCall - assertThat(toolCall.toolCallId).isEqualTo("call_1") - assertThat(toolCall.input.jsonPrimitive.content).isEqualTo("{\"city\":\"Paris\"}") - assertThat(toolCall.providerExecuted).isTrue() - - val file = byName["generate_content_file"] as GenerateContent.File - assertThat(file.mediaType).isEqualTo("image/png") - - val source = - byName["generate_content_source_unset_optionals"] as GenerateContent.Source - assertThat(source.url).isNull() - assertThat(source.title).isNull() - } - - /// The regression lock for `topK`, which this binding declared as `Long` - /// against an `f64` wire. - /// - /// Every value is asserted explicitly rather than relying on decoding to - /// fail: a decode-only test would pass again the moment someone restored - /// the integer type, since a serializer is free to coerce `40.5` rather - /// than reject it. `topK` is the field that was wrong; the others are - /// asserted so a narrowing of any single numeric type is caught here too. - @Test - fun `numeric options decode with full precision`() { - val json = fixtureJson(loadFixtures(), "generate_text_options_numeric_types") - val opts = AimuxJson.decodeFromString(json) - - assertThat(opts.topK).isEqualTo(40.5) - assertThat(opts.frequencyPenalty).isEqualTo(-0.5) - assertThat(opts.temperature).isEqualTo(0.7) - assertThat(opts.topP).isEqualTo(0.95) - assertThat(opts.presencePenalty).isEqualTo(0.5) - assertThat(opts.maxOutputTokens).isEqualTo(256L) - assertThat(opts.seed).isEqualTo(42L) - assertThat(opts.maxRetries).isEqualTo(3L) - } - - @Test - fun `video poll options use the core wire names`() { - val opts = VideoCallOptions( - prompt = "a cat", - poll = VideoPollOptions(intervalMs = 1_000L, timeoutMs = 120_000L), - ) - - val encoded = AimuxJson.encodeToString(VideoCallOptions.serializer(), opts) - assertThat(encoded).contains( - "\"poll\":{\"interval_ms\":1000,\"timeout_ms\":120000}", - ) - val decoded = AimuxJson.decodeFromString(encoded) - assertThat(decoded.poll?.intervalMs).isEqualTo(1_000L) - assertThat(decoded.poll?.timeoutMs).isEqualTo(120_000L) - } - - /// RFC-0016 M10: `Usage.raw` with a vendor-specific field survives a - /// Kotlin round-trip. - @Test - fun `usage raw preserves vendor fields round-trip`() { - val usage = Usage( - inputTokens = TokenUsage(total = 20L), - outputTokens = TokenUsage(total = 5L), - raw = buildJsonObject { - put("prompt_cache_hit_tokens", 42) - put("prompt_tokens", 20) - }, - ) - val json = AimuxJson.encodeToString(Usage.serializer(), usage) - assertThat(json).contains("\"raw\"") - val decoded = AimuxJson.decodeFromString(json) - assertThat(decoded.raw?.jsonObject?.get("prompt_cache_hit_tokens")?.jsonPrimitive?.content) - .isEqualTo("42") - } -} diff --git a/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/ToolCallRepairTest.kt b/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/ToolCallRepairTest.kt deleted file mode 100644 index 6b089a37d..000000000 --- a/bindings/kotlin/src/test/kotlin/ai/arcships/aimux/ToolCallRepairTest.kt +++ /dev/null @@ -1,310 +0,0 @@ -package ai.arcships.aimux - -import kotlinx.serialization.json.JsonNull -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive -import org.assertj.core.api.Assertions.assertThat -import org.assertj.core.api.Assertions.assertThatThrownBy -import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.Assertions.assertTimeoutPreemptively -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import java.io.File -import java.time.Duration -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit - -// ───────────────────────────────────────────────────────────────────────────── -// Host-side tool-call repair (RFC-0035). -// -// Two halves: -// 1. the shared `tool-call-repair.json` fixture replayed through the three -// native entry points (pure functions — no model, no server), -// 2. end-to-end through TypedModel against MockProviderServer: the provider -// returns arguments that violate the tool schema, Core marks the call -// invalid, and the repair function runs on the JVM. -// ───────────────────────────────────────────────────────────────────────────── - -class ToolCallRepairTest { - - private lateinit var server: MockProviderServer - - @BeforeEach - fun setUp() { - server = MockProviderServer() - } - - @AfterEach - fun tearDown() { - server.stop() - } - - // ── shared fixture ────────────────────────────────────────────────── - - private val fixtureCandidates = listOf( - "../../contract-tests/fixtures/tool-call-repair.json", // gradle test cwd = bindings/kotlin - "contract-tests/fixtures/tool-call-repair.json", - "../contract-tests/fixtures/tool-call-repair.json", - ) - - private fun fixtureCases(): List { - val file = fixtureCandidates.map(::File).firstOrNull { it.isFile } - ?: throw IllegalStateException( - "cannot find tool-call-repair.json; tried $fixtureCandidates from ${File(".").absolutePath}" - ) - val cases = AimuxJson.parseToJsonElement(file.readText()).jsonObject["cases"]!! - return cases.let { it as kotlinx.serialization.json.JsonArray } - .map { it.jsonObject } - } - - /** `opts` may be JSON null in the fixture, which is the binding's "defaults". */ - private fun optsOf(input: JsonObject): String? = - input["opts"]?.takeIf { it !is JsonNull }?.toString() - - @Test - fun `all shared contract fixture cases replay`() { - fixtureCases().forEach { case -> - val name = case["name"]!!.jsonPrimitive.content - val input = case["input"]!!.jsonObject - val invoke = { - when (val function = case["function"]!!.jsonPrimitive.content) { - "tool_call_repair_context" -> toolCallRepairContext( - input["tool_call"]!!.toString(), input["prompt"]!!.toString(), optsOf(input)) - "apply_tool_call_repair" -> applyToolCallRepair( - input["tool_call"]!!.toString(), optsOf(input), input["reply"]!!.toString()) - "apply_tool_call_repair_to_result" -> applyToolCallRepairToResult( - input["result"]!!.toString(), optsOf(input), - input["tool_call_id"]!!.jsonPrimitive.content, input["reply"]!!.toString()) - else -> throw AssertionError("unknown fixture function: $function") - } - } - if (case["expected_error"] != null) { - assertThat(case["expected_error"]!!.jsonPrimitive.content).isEqualTo("InvalidArgument") - assertThatThrownBy { invoke() }.describedAs(name) - .isInstanceOf(InvalidArgumentError::class.java) - } else { - assertThat(AimuxJson.parseToJsonElement(invoke())).describedAs(name) - .isEqualTo(case["expected"]!!) - } - } - } - - // ── end to end ────────────────────────────────────────────────────── - - /** `city` is required; the canned response sends `town` instead. */ - private val weatherTool: Tool = Tool.Function( - name = "weather", - inputSchema = AimuxJson.parseToJsonElement( - """{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false}""" - ), - ) - - private fun openAiToolCall(arguments: String, name: String = "weather"): String = - """ - {"id":"chatcmpl-1","model":"gpt-4o","choices":[{"message":{"role":"assistant","content":null, - "tool_calls":[{"id":"call-1","type":"function","function":{"name":"$name","arguments":${ - JsonPrimitive(arguments) - }}}]},"finish_reason":"tool_calls"}], - "usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}} - """.trimIndent() - - private fun model(): TypedModel = TypedModel.openai("sk-test-fake-key", "gpt-4o", server.baseUrl) - - private fun options(repair: RepairToolCall?): GenerateTextOptions = - GenerateTextOptions(tools = listOf(weatherTool), repairToolCall = repair) - - @Test - fun `a repaired call becomes valid in both tool_calls and response_messages`() { - server.responseBody = openAiToolCall("""{"town":"Singapore"}""") - - model().use { model -> - val result = model.generateText( - "weather in Singapore?", - options { context -> - // The repair function sees the RAW argument text, not a parsed object. - assertThat(context.toolCall.input).isEqualTo("""{"town":"Singapore"}""") - assertThat(context.inputSchema.jsonObject["required"].toString()).contains("city") - assertThat(context.messages).hasSize(1) - context.toolCall.copy(input = """{"city":"Singapore"}""") - }, - ) - - val call = result.toolCalls.single() - assertThat(call.invalid).isNotEqualTo(true) - assertThat(call.error).isNull() - assertThat(call.input.jsonObject["city"]?.jsonPrimitive?.content).isEqualTo("Singapore") - - // The transcript must agree, or the next turn replays bad arguments. - val part = result.responseMessages - .flatMap { it.contentParts ?: emptyList() } - .filterIsInstance() - .single() - assertThat(part.toolCallId).isEqualTo("call-1") - assertThat(part.input.jsonObject["city"]?.jsonPrimitive?.content).isEqualTo("Singapore") - } - } - - @Test - fun `generateTextAsOpenAI carries the repaired arguments`() { - server.responseBody = openAiToolCall("""{"town":"Singapore"}""") - - model().use { model -> - // A ChatCompletion has no invalid marker: the native result is - // repaired, then converted. - val completion = model.generateTextAsOpenAI( - "weather in Singapore?", - options { context -> context.toolCall.copy(input = """{"city":"Singapore"}""") }, - ) - - val call = completion.choices.single().message.toolCalls!!.single() - assertThat(call.id).isEqualTo("call-1") - assertThat(call.function.arguments).isEqualTo("""{"city":"Singapore"}""") - } - } - - /** - * The host loop's non-happy branches: what a hook's two failure modes do - * to the call, and the two cases where the hook must not run at all. - */ - @Test - fun `a throwing hook wraps, a null hook preserves, and valid or untooled calls skip repair`() { - val invalidCall = openAiToolCall("""{"town":"Singapore"}""") - var hookCalls = 0 - - model().use { model -> - // Throwing means "repair failed": the call stays invalid and the - // original failure is kept under the ToolCallRepair wrapper. - server.responseBody = invalidCall - var call = model.generateText( - "weather in Singapore?", - options { hookCalls++; throw RuntimeException("repair model unavailable") }, - ).toolCalls.single() - assertThat(hookCalls).isEqualTo(1) - assertThat(call.invalid).isEqualTo(true) - val repairError = call.error!!.jsonObject["ToolCallRepair"]!!.jsonObject - assertThat(repairError["cause"]!!.jsonObject["Other"]!!.jsonPrimitive.content) - .isEqualTo("repair model unavailable") - assertThat(repairError["original_error"]!!.jsonObject.keys) - .containsExactly("InvalidToolInput") - - // Returning null declines the repair: the ORIGINAL error survives - // unwrapped, so the caller cannot tell a decline from no hook. - server.responseBody = invalidCall - call = model.generateText( - "weather in Singapore?", - options { hookCalls++; null }, - ).toolCalls.single() - assertThat(hookCalls).isEqualTo(2) - assertThat(call.invalid).isEqualTo(true) - assertThat(call.error!!.jsonObject.keys).containsExactly("InvalidToolInput") - - // A valid call never reaches the hook. - server.responseBody = openAiToolCall("""{"city":"Singapore"}""") - call = model.generateText( - "weather in Singapore?", - options { hookCalls++; null }, - ).toolCalls.single() - assertThat(hookCalls).isEqualTo(2) - assertThat(call.invalid).isNotEqualTo(true) - - // Without a tool set the native context is null (AI SDK never - // repairs those), so the invalid call is returned as-is. - server.responseBody = invalidCall - call = model.generateText( - "weather in Singapore?", - GenerateTextOptions(repairToolCall = { hookCalls++; null }), - ).toolCalls.single() - assertThat(hookCalls).isEqualTo(2) - assertThat(call.invalid).isEqualTo(true) - } - } - - /** The nested generation a repair hook performs: valid arguments as text. */ - private val repairCompletion: String = - """{"id":"chatcmpl-2","model":"gpt-4o","choices":[{"message":{"role":"assistant", - "content":"{\"city\":\"Singapore\"}"},"finish_reason":"stop"}], - "usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}""" - - /** SSE: tool-call start, one arguments delta violating the schema, finish. */ - private val weatherSse: String = buildString { - append("""data: {"id":"1","model":"gpt-4o","choices":[{"delta":{"role":"assistant","tool_calls":""") - append("""[{"index":0,"id":"call-1","type":"function","function":{"name":"weather","arguments":""}}]}}]}""") - append("\n\n") - append("""data: {"id":"1","model":"gpt-4o","choices":[{"delta":{"tool_calls":""") - append("""[{"index":0,"function":{"arguments":"{\"town\":\"Singapore\"}"}}]}}]}""") - append("\n\n") - append("""data: {"id":"1","model":"gpt-4o","choices":[{"delta":{},"finish_reason":"tool_calls"}]}""") - append("\n\ndata: [DONE]\n\n") - } - - @Test - fun `streaming replaces the tool-call part and leaves the deltas alone`() { - server.contentType = "text/event-stream" - server.responseBody = weatherSse - - model().use { model -> - val parts = model.streamTextSequence( - "weather in Singapore?", - options { it.toolCall.copy(input = """{"city":"Singapore"}""") }, - ).toList() - - val call = parts.filterIsInstance().single() - assertThat(call.invalid).isNotEqualTo(true) - assertThat(call.input.jsonObject["city"]?.jsonPrimitive?.content).isEqualTo("Singapore") - - // Deltas are the provider's text, forwarded untouched. - val delta = parts.filterIsInstance().single() - assertThat(delta.delta).isEqualTo("""{"town":"Singapore"}""") - } - } - - @Test - fun `a streaming hook may call the SAME model while another thread closes it`() { - // The lock is fair, so a reader that arrives after close() has queued - // for the write lock waits for it. Without the lent read hold this is a - // three-way deadlock: stream thread (holds read, waits for the hook) ← - // closer (waits for the write lock) ← hook (waits for a read lock). - server.contentType = "text/event-stream" - server.setResponses(weatherSse, repairCompletion) - - val model = model() - val hookStarted = CountDownLatch(1) - val closer = Thread { - hookStarted.await() - model.close() - } - closer.isDaemon = true - // assertTimeoutPreemptively, not @Timeout: a regression deadlocks, and - // the default @Timeout mode only checks after the body returns. - assertTimeoutPreemptively(Duration.ofSeconds(30)) { - closer.start() - val parts = model.streamTextSequence( - "weather in Singapore?", - options { context -> - // The stream response is already fully written; the nested - // completion is plain JSON off the same one-server queue. - server.contentType = "application/json" - hookStarted.countDown() - // Let close() reach the write lock and queue ahead of this - // thread's read acquisition — that ordering is the bug, and - // without the sleep the hook wins the race and proves - // nothing. - Thread.sleep(250) - val fixed = model.generateText("Fix ${context.toolCall.input}") - context.toolCall.copy(input = fixed.text) - }, - ).toList() - - val call = parts.filterIsInstance().single() - assertThat(call.error).isNull() - assertThat(call.invalid).isNotEqualTo(true) - assertThat(call.input.jsonObject["city"]?.jsonPrimitive?.content).isEqualTo("Singapore") - - closer.join(TimeUnit.SECONDS.toMillis(10)) - assertThat(closer.isAlive).describedAs("close() never completed").isFalse() - } - } - -} diff --git a/bindings/node/__test__/thought_signature_replay.test.ts b/bindings/node/__test__/thought_signature_replay.test.ts deleted file mode 100644 index 590a3910c..000000000 --- a/bindings/node/__test__/thought_signature_replay.test.ts +++ /dev/null @@ -1,211 +0,0 @@ -// thought_signature_replay.test.ts — Regression tests for issue arcships/aimux#9. -// -// Google Gemini **thinking** models (gemini-2.5-pro, gemini-3.x-flash-thinking) -// attach a `thoughtSignature` to every `functionCall` part in responses, and -// the follow-up turn MUST echo it back verbatim as a sibling of the -// `functionCall` part — otherwise the API rejects the request with HTTP 400 -// ("Function call is missing a thought_signature"). -// -// aimux previously dropped the signature at parse time (no field in any -// ToolCall type) and did not emit it when rebuilding assistant tool-call -// parts. These tests use a mock HTTP server (no real API) and assert: -// 1. parse direction: `thoughtSignature` from the response reaches the -// typed `GenerateTextResult.tool_calls[].thought_signature`; -// 2. replay direction: the outbound request carries the signature as a -// part-level sibling of `functionCall` (not nested inside it). -// -// This mirrors the reasoning_replay.test.ts approach for DeepSeek thinking -// models. - -import test from 'ava' -import { createServer, type Server } from 'node:http' -import { google } from '../src/native.ts' - -function startMockServer(handler: (req: any, res: any) => void): Promise<{ server: Server; url: string }> { - return new Promise((resolve) => { - const server = createServer(handler) - server.listen(0, '127.0.0.1', () => { - const addr = server.address() as any - resolve({ server, url: `http://127.0.0.1:${addr.port}` }) - }) - }) -} - -function closeServer(server: Server): Promise { - return new Promise((resolve) => server.close(() => resolve())) -} - -/// Read the full request body as a parsed JSON object. -function readBody(req: any): Promise { - return new Promise((resolve) => { - let body = '' - req.on('data', (chunk: string) => { body += chunk }) - req.on('end', () => resolve(JSON.parse(body))) - }) -} - -const THOUGHT_SIGNATURE = - 'EuIDCt8DARFNMg/aRDRK3THWhBjzltCEy5/VM6ImWLJU8oHmnC75abdcZBMH' - -// A minimal Gemini non-streaming response with a functionCall + thoughtSignature. -function geminiToolCallResponse(signature?: string) { - return JSON.stringify({ - candidates: [{ - content: { - parts: [{ - functionCall: { - id: 'call-1', - name: 'weather', - args: { location: 'San Francisco' }, - }, - ...(signature ? { thoughtSignature: signature } : {}), - }], - role: 'model', - }, - finishReason: 'STOP', - index: 0, - }], - usageMetadata: { promptTokenCount: 5, candidatesTokenCount: 5, totalTokenCount: 10 }, - }) -} - -const TEXT_RESPONSE = JSON.stringify({ - candidates: [{ - content: { parts: [{ text: 'Done.' }], role: 'model' }, - finishReason: 'STOP', - index: 0, - }], - usageMetadata: { promptTokenCount: 5, candidatesTokenCount: 5, totalTokenCount: 10 }, -}) - -// ── parse direction: thoughtSignature reaches the typed tool_calls ────────── - -test('parse: thoughtSignature from a functionCall part lands on tool_calls[].thought_signature', async (t) => { - const { server, url } = await startMockServer(async (_req, res) => { - res.writeHead(200, { 'content-type': 'application/json' }) - res.end(geminiToolCallResponse(THOUGHT_SIGNATURE)) - }) - - try { - const model = await google('test-key', 'gemini-2.5-pro', url) - const resultJson = await model.generateText( - JSON.stringify([{ role: 'user', content: 'weather in SF?' }]), - JSON.stringify({ - tools: [{ - type: 'function', - name: 'weather', - description: 'Get the weather', - input_schema: { - type: 'object', - properties: { location: { type: 'string' } }, - required: ['location'], - additionalProperties: false, - }, - }], - }), - ) - const r = JSON.parse(resultJson) - t.is(r.tool_calls.length, 1) - t.is(r.tool_calls[0].tool_call_id, 'call-1') - t.is(r.tool_calls[0].tool_name, 'weather') - t.is(r.tool_calls[0].thought_signature, THOUGHT_SIGNATURE) - } finally { - await closeServer(server) - } -}) - -// ── replay direction: signature echoed back as a part-level sibling ───────── - -test('replay: assistant tool_call with thought_signature echoes it back verbatim as a part sibling', async (t) => { - let requestBody: any = null - const { server, url } = await startMockServer(async (req, res) => { - requestBody = await readBody(req) - res.writeHead(200, { 'content-type': 'application/json' }) - res.end(TEXT_RESPONSE) - }) - - try { - const model = await google('test-key', 'gemini-2.5-pro', url) - - // Second turn: assistant tool_call (with the captured signature) + the - // tool result. This is the exact shape that used to fail with HTTP 400. - const messages = [ - { role: 'user', content: 'weather in SF?' }, - { - role: 'assistant', - content: [{ - type: 'tool_call', - tool_call_id: 'call-1', - tool_name: 'weather', - input: { location: 'San Francisco' }, - thought_signature: THOUGHT_SIGNATURE, - }], - }, - { - role: 'tool', - content: [{ - type: 'tool_result', - tool_call_id: 'call-1', - result: { temp: 70 }, - }], - }, - ] - - const resultJson = await model.generateText(JSON.stringify(messages)) - const r = JSON.parse(resultJson) - t.is(r.text, 'Done.') - - // The signature must be a SIBLING of `functionCall` on the part. - const part = requestBody.contents[1].parts[0] - t.is(part.thoughtSignature, THOUGHT_SIGNATURE) - t.is(part.functionCall.thoughtSignature, undefined) - // And the tool result must be present as a functionResponse part. - t.is(requestBody.contents[2].role, 'user') - t.is(requestBody.contents[2].parts[0].functionResponse.id, 'call-1') - } finally { - await closeServer(server) - } -}) - -// ── negative: no signature on the input → no thoughtSignature in the body ─── - -test('replay: tool_call without signature emits no thoughtSignature field', async (t) => { - let requestBody: any = null - const { server, url } = await startMockServer(async (req, res) => { - requestBody = await readBody(req) - res.writeHead(200, { 'content-type': 'application/json' }) - res.end(TEXT_RESPONSE) - }) - - try { - const model = await google('test-key', 'gemini-2.5-pro', url) - - const messages = [ - { role: 'user', content: 'weather in SF?' }, - { - role: 'assistant', - content: [{ - type: 'tool_call', - tool_call_id: 'call-1', - tool_name: 'weather', - input: { location: 'San Francisco' }, - }], - }, - { - role: 'tool', - content: [{ - type: 'tool_result', - tool_call_id: 'call-1', - result: { temp: 70 }, - }], - }, - ] - - await model.generateText(JSON.stringify(messages)) - - const part = requestBody.contents[1].parts[0] - t.is(part.thoughtSignature, undefined) - } finally { - await closeServer(server) - } -}) diff --git a/bindings/node/__test__/tool_call_repair.test.ts b/bindings/node/__test__/tool_call_repair.test.ts deleted file mode 100644 index 0011f45af..000000000 --- a/bindings/node/__test__/tool_call_repair.test.ts +++ /dev/null @@ -1,207 +0,0 @@ -import test from 'ava' -import { readFileSync } from 'node:fs' -import { createServer, type Server } from 'node:http' - -import { generateText, generateTextAsOpenai, openai, streamText } from '../src/index.ts' -import type { RawToolCall, RepairToolCall, StreamPart, Tool, ToolCallRepairContext } from '../src/index.ts' -import { - applyToolCallRepair, - applyToolCallRepairToResult, - toolCallRepairContext, -} from '../src/native.ts' - -function startServer(body: string, contentType = 'application/json') { - return new Promise<{ server: Server; url: string }>((resolve) => { - const server = createServer((_req, res) => { - res.writeHead(200, { 'content-type': contentType }) - res.end(body) - }) - server.listen(0, '127.0.0.1', () => { - const address = server.address() - if (!address || typeof address === 'string') throw new Error('missing server address') - resolve({ server, url: `http://127.0.0.1:${address.port}` }) - }) - }) -} - -const weatherTool: Tool = { - type: 'function', - name: 'weather', - input_schema: { - type: 'object', - properties: { city: { type: 'string' } }, - required: ['city'], - additionalProperties: false, - }, -} - -const INVALID_TOOL_CALL = JSON.stringify({ - id: 'chatcmpl-repair', - model: 'gpt-4o', - choices: [{ - message: { - role: 'assistant', - content: null, - tool_calls: [{ - id: 'call-1', - type: 'function', - function: { name: 'weather', arguments: '{"town":"Singapore"}' }, - }], - }, - finish_reason: 'tool_calls', - }], - usage: { prompt_tokens: 5, completion_tokens: 2, total_tokens: 7 }, -}) - -const INVALID_TOOL_CALL_STREAM = [ - 'data: {"id":"1","model":"gpt-4o","choices":[{"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"call-1","type":"function","function":{"name":"weather","arguments":""}}]}}]}\n\n', - 'data: {"id":"1","model":"gpt-4o","choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\\"town\\":\\"Singapore\\"}"}}]}}]}\n\n', - 'data: {"id":"1","model":"gpt-4o","choices":[{"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":5,"completion_tokens":2,"total_tokens":7}}\n\n', - 'data: [DONE]\n\n', -].join('') - -const fixCity = (ctx: ToolCallRepairContext): RawToolCall => ({ - ...ctx.tool_call, - input: ctx.tool_call.input.replace('"town"', '"city"'), -}) - -test('repair: generateText repairs the call and transcript', async (t) => { - const { server, url } = await startServer(INVALID_TOOL_CALL) - try { - const model = await openai('test-key', 'gpt-4o', url) - let rawInput: string | undefined - const result = await generateText(model, 'weather in Singapore?', { - tools: [weatherTool], - repairToolCall: (ctx) => { - rawInput = ctx.tool_call.input - return fixCity(ctx) - }, - }) - - t.is(rawInput, '{"town":"Singapore"}') - t.deepEqual(result.tool_calls[0].input, { city: 'Singapore' }) - const message = result.response_messages[0] as any - const part = message.content.find((item: any) => item.type === 'tool_call') - t.deepEqual(part.input, result.tool_calls[0].input) - } finally { - await new Promise((resolve) => server.close(() => resolve())) - } -}) - -test('repair: generateTextAsOpenai carries the repaired arguments', async (t) => { - const { server, url } = await startServer(INVALID_TOOL_CALL) - try { - const model = await openai('test-key', 'gpt-4o', url) - const completion = await generateTextAsOpenai(model, 'weather in Singapore?', { - tools: [weatherTool], - repairToolCall: fixCity, - }) - const call = completion.choices[0].message.tool_calls?.[0] - t.is(call?.id, 'call-1') - t.deepEqual(JSON.parse(call?.function.arguments ?? ''), { city: 'Singapore' }) - t.is(completion.model, 'gpt-4o') - } finally { - await new Promise((resolve) => server.close(() => resolve())) - } -}) - -test('repair: streamText repairs ToolCall and preserves deltas', async (t) => { - const { server, url } = await startServer(INVALID_TOOL_CALL_STREAM, 'text/event-stream') - try { - const model = await openai('test-key', 'gpt-4o', url) - const parts: StreamPart[] = [] - for await (const part of streamText(model, 'weather in Singapore?', { - tools: [weatherTool], - repairToolCall: fixCity, - })) parts.push(part) - - const call = parts.find((p): p is Extract => 'ToolCall' in p) - t.deepEqual(call?.ToolCall.input, { city: 'Singapore' }) - const deltas = parts - .filter((p): p is Extract => 'ToolInputDelta' in p) - .map((p) => p.ToolInputDelta.delta) - t.is(deltas.join(''), '{"town":"Singapore"}') - } finally { - await new Promise((resolve) => server.close(() => resolve())) - } -}) - -test('repair: shared contract fixture', (t) => { - const fixture = JSON.parse(readFileSync( - new URL('../../../contract-tests/fixtures/tool-call-repair.json', import.meta.url), - 'utf8', - )) as { cases: any[] } - t.true(fixture.cases.length > 0) - - const run = (c: any): string => { - const opts = c.input.opts === null ? undefined : JSON.stringify(c.input.opts) - switch (c.function) { - case 'tool_call_repair_context': - return toolCallRepairContext(JSON.stringify(c.input.tool_call), JSON.stringify(c.input.prompt), opts) - case 'apply_tool_call_repair': - return applyToolCallRepair(JSON.stringify(c.input.tool_call), opts, JSON.stringify(c.input.reply)) - case 'apply_tool_call_repair_to_result': - return applyToolCallRepairToResult( - JSON.stringify(c.input.result), opts, c.input.tool_call_id, JSON.stringify(c.input.reply), - ) - default: - throw new Error(`unknown fixture function ${c.function}`) - } - } - - for (const c of fixture.cases) { - if (c.expected_error) { - const error = t.throws(() => run(c), undefined, c.name) - t.is(error?.constructor.name, `${c.expected_error}Error`, c.name) - } else { - t.deepEqual(JSON.parse(run(c)), c.expected, c.name) - } - } -}) - -test('repair: throwing, null, valid, and no-tool-set branches', async (t) => { - let calls = 0 - const generate = async (body: string, repair: RepairToolCall, tools?: Tool[]) => { - const { server, url } = await startServer(body) - try { - const model = await openai('test-key', 'gpt-4o', url) - return await generateText(model, 'weather in Singapore?', { - tools, - repairToolCall: (ctx) => { - calls += 1 - return repair(ctx) - }, - }) - } finally { - await new Promise((resolve) => server.close(() => resolve())) - } - } - - // Throwing hook: invalid, wrapped in ToolCallRepair over the original error. - const thrown = await generate(INVALID_TOOL_CALL, () => { - throw new Error('repair model unavailable') - }, [weatherTool]) - t.is(calls, 1) - t.is(thrown.tool_calls[0].invalid, true) - const failure = (thrown.tool_calls[0].error as any).ToolCallRepair - t.deepEqual(failure.cause, { Other: 'Error: repair model unavailable' }) - t.true('InvalidToolInput' in failure.original_error) - - // Hook returns null: invalid with the ORIGINAL error, no repair wrapper. - const unchanged = await generate(INVALID_TOOL_CALL, () => null, [weatherTool]) - t.is(calls, 2) - t.is(unchanged.tool_calls[0].invalid, true) - t.deepEqual(Object.keys(unchanged.tool_calls[0].error as any), ['InvalidToolInput']) - - // A valid call never reaches the hook. - const valid = await generate(INVALID_TOOL_CALL.replace('town', 'city'), fixCity, [weatherTool]) - t.is(calls, 2) - t.falsy(valid.tool_calls[0].invalid) - t.deepEqual(valid.tool_calls[0].input, { city: 'Singapore' }) - - // No tool set: not repairable, the hook is skipped, the call stays invalid. - const untooled = await generate(INVALID_TOOL_CALL, fixCity) - t.is(calls, 2) - t.is(untooled.tool_calls[0].invalid, true) - t.deepEqual(Object.keys(untooled.tool_calls[0].error as any), ['NoSuchTool']) -}) diff --git a/bindings/node/src/lib.rs b/bindings/node/src/lib.rs index edd9628b4..47d0ac56d 100644 --- a/bindings/node/src/lib.rs +++ b/bindings/node/src/lib.rs @@ -20,7 +20,7 @@ use std::future::Future; use std::sync::Arc; use crate::error::{ - AimuxResult, BindingError, AiMuxBindingError, MResult, StreamItem, parse_wire_json, + AiMuxBindingError, AimuxResult, BindingError, MResult, StreamItem, parse_wire_json, serialize_result, }; use aimux_core::AiMuxError; @@ -327,8 +327,9 @@ impl Model { // move into the spawned task. let abort_signal = bridge.map(|b| b.core_signal()); - let (tx, rx) = - tokio::sync::mpsc::channel::>(64); + let (tx, rx) = tokio::sync::mpsc::channel::< + std::result::Result, + >(64); // Spawn the stream-driving task immediately on napi's tokio runtime. napi::tokio::spawn(async move { @@ -379,7 +380,9 @@ impl Model { // frame; deliver it as a StreamPart::Error data item // and keep pumping. match serde_json::to_string( - &aimux_core::stream_part::StreamPart::Error { error: e }, + &aimux_core::stream_part::TextStreamPart::Error { + error: e, + }, ) { Ok(json) => { if tx.send(Ok(json)).await.is_err() { @@ -471,8 +474,9 @@ impl Model { let model = self.inner.clone(); let abort_signal = bridge.map(|b| b.core_signal()); - let (tx, rx) = - tokio::sync::mpsc::channel::>(64); + let (tx, rx) = tokio::sync::mpsc::channel::< + std::result::Result, + >(64); napi::tokio::spawn(async move { let prompt = match parse_prompt(&prompt) { @@ -565,6 +569,8 @@ impl Model { } } +type StreamReceiver = tokio::sync::mpsc::Receiver>; + /// AsyncGenerator that yields StreamPart JSON strings. /// /// The stream is started eagerly in `stream_text()` — a tokio task drives @@ -572,11 +578,7 @@ impl Model { /// receives from the channel. #[napi(async_iterator)] pub struct StreamTextGenerator { - rx: std::sync::Arc< - tokio::sync::Mutex< - Option>>, - >, - >, + rx: std::sync::Arc>>, } #[napi] @@ -945,19 +947,19 @@ struct RouterFfiConfig { /// or no store is registered. #[napi] pub fn session_calls(session_id: String) -> AimuxResult { - AimuxResult((|| -> crate::error::MResult { + AimuxResult({ let calls = aimux_core::session::session_calls(&session_id); serialize_result(&calls) - })()) + }) } /// Query: all known sessions (RFC-0024), as a JSON-serialized `SessionView[]`. #[napi] pub fn list_sessions() -> AimuxResult { - AimuxResult((|| -> crate::error::MResult { + AimuxResult({ let views = aimux_core::session::list_sessions(); serialize_result(&views) - })()) + }) } /// Create an OpenAI model instance. @@ -1377,10 +1379,10 @@ pub async fn azure( } None => {} } - if let Some(version) = api_version { - if !version.is_empty() { - cfg = cfg.with_api_version(version); - } + if let Some(version) = api_version + && !version.is_empty() + { + cfg = cfg.with_api_version(version); } if !resource_name.is_empty() { cfg = cfg.with_resource_name(resource_name); diff --git a/bindings/node/src/multimodal.rs b/bindings/node/src/multimodal.rs index 1d6095704..00666cc09 100644 --- a/bindings/node/src/multimodal.rs +++ b/bindings/node/src/multimodal.rs @@ -3,7 +3,9 @@ //! Each modality is a napi class wrapping the Rust trait object. //! All cross-boundary data uses JSON strings (base64 for binary). -use crate::error::{AimuxResult, BindingError, AiMuxBindingError, parse_wire_json, serialize_result}; +use crate::error::{ + AiMuxBindingError, AimuxResult, BindingError, parse_wire_json, serialize_result, +}; use std::sync::Arc; use aimux_core::AiMuxError; @@ -55,8 +57,7 @@ fn parse_opts_json( /// Shape-only stand-in for `TranscriptionCallOptions::audio`, built from the /// real enum so a variant rename is a compile error rather than a runtime one. fn audio_placeholder() -> serde_json::Value { - serde_json::to_value(AudioInput::Base64(String::new())) - .expect("AudioInput always serializes") + serde_json::to_value(AudioInput::Base64(String::new())).expect("AudioInput always serializes") } /// Shape-only stand-in for `RerankingCallOptions::documents`. @@ -208,27 +209,27 @@ impl TranscriptionModel { let __r: crate::error::MResult = async { let mut opts = TranscriptionCallOptions::new(AudioInput::Base64(audio_base64), media_type); - if let Some(s) = opts_json.as_deref() { - if !s.trim().is_empty() && s.trim() != "null" { - let mut parsed: TranscriptionCallOptions = parse_opts_json( - "opts_json", - s, - &[ - ("audio", audio_placeholder()), - ("media_type", serde_json::Value::from("")), - ], - )?; - // Required data comes from the explicit arguments; all - // operation policy and optional fields come from JSON. - parsed.audio = opts.audio; - parsed.media_type = opts.media_type; - opts = parsed; - } + if let Some(s) = opts_json.as_deref() + && !s.trim().is_empty() + && s.trim() != "null" + { + let mut parsed: TranscriptionCallOptions = parse_opts_json( + "opts_json", + s, + &[ + ("audio", audio_placeholder()), + ("media_type", serde_json::Value::from("")), + ], + )?; + // Required data comes from the explicit arguments; all + // operation policy and optional fields come from JSON. + parsed.audio = opts.audio; + parsed.media_type = opts.media_type; + opts = parsed; } opts.abort_signal = bridge.map(|b| b.core_signal()); - let result = - aimux_core::transcription_model::transcribe(self.inner.as_ref(), opts) + let result = aimux_core::transcription_model::transcribe(self.inner.as_ref(), opts) .await .map_err(|e| AiMuxBindingError::from(&e))?; serialize_result(&result) @@ -268,22 +269,23 @@ impl RerankingModel { let docs: RerankingDocuments = parse_wire_json("docs_json", &docs_json)?; let mut opts = RerankingCallOptions::new(query, docs); - if let Some(s) = opts_json.as_deref() { - if !s.trim().is_empty() && s.trim() != "null" { - let mut parsed: RerankingCallOptions = parse_opts_json( - "opts_json", - s, - &[ - ("query", serde_json::Value::from("")), - ("documents", documents_placeholder()), - ], - )?; - // Required data comes from the explicit arguments; all - // operation policy and optional fields come from JSON. - parsed.query = opts.query; - parsed.documents = opts.documents; - opts = parsed; - } + if let Some(s) = opts_json.as_deref() + && !s.trim().is_empty() + && s.trim() != "null" + { + let mut parsed: RerankingCallOptions = parse_opts_json( + "opts_json", + s, + &[ + ("query", serde_json::Value::from("")), + ("documents", documents_placeholder()), + ], + )?; + // Required data comes from the explicit arguments; all + // operation policy and optional fields come from JSON. + parsed.query = opts.query; + parsed.documents = opts.documents; + opts = parsed; } opts.abort_signal = bridge.map(|b| b.core_signal()); @@ -359,18 +361,16 @@ impl SearchModel { let __r: crate::error::MResult = async { use aimux_core::search_model::SearchCallOptions; let mut opts = SearchCallOptions::new(query); - if let Some(s) = opts_json.as_deref() { - if !s.trim().is_empty() && s.trim() != "null" { - // Required data comes from the explicit arguments; all - // operation policy and optional fields come from JSON. - let mut parsed: SearchCallOptions = parse_opts_json( - "opts_json", - s, - &[("query", serde_json::Value::from(""))], - )?; - parsed.query = opts.query; - opts = parsed; - } + if let Some(s) = opts_json.as_deref() + && !s.trim().is_empty() + && s.trim() != "null" + { + // Required data comes from the explicit arguments; all + // operation policy and optional fields come from JSON. + let mut parsed: SearchCallOptions = + parse_opts_json("opts_json", s, &[("query", serde_json::Value::from(""))])?; + parsed.query = opts.query; + opts = parsed; } opts.abort_signal = bridge.map(|b| b.core_signal()); @@ -416,12 +416,13 @@ impl Files { }, media_type, ); - if let Some(s) = opts_json.as_deref() { - if !s.trim().is_empty() && s.trim() != "null" { - let parsed: UploadFileCallOptions = parse_wire_json("opts_json", s)?; - opts.filename = parsed.filename; - opts.provider_options = parsed.provider_options; - } + if let Some(s) = opts_json.as_deref() + && !s.trim().is_empty() + && s.trim() != "null" + { + let parsed: UploadFileCallOptions = parse_wire_json("opts_json", s)?; + opts.filename = parsed.filename; + opts.provider_options = parsed.provider_options; } let result = self @@ -719,8 +720,9 @@ pub async fn tavily_search(api_key: String, base_url: Option) -> AimuxRe #[napi] pub struct TranscriptionSession { audio_tx: std::sync::Mutex>>, - parts_rx: - tokio::sync::Mutex>>, + parts_rx: tokio::sync::Mutex< + tokio::sync::mpsc::Receiver>, + >, token: aimux_core::AbortSignal, } @@ -827,46 +829,42 @@ pub async fn start_transcription_session( return; } }; - loop { - match tx.try_send(Ok(json.clone())) { - Ok(()) => break, - Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => { - // Full: block on send (waits for - // capacity) unless aborted. - tokio::select! { - _ = effective.cancelled() => return, - res = tx.send(Ok(json.clone())) => { - if res.is_err() { return; } - break; - } + match tx.try_send(Ok(json.clone())) { + Ok(()) => {}, + Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => { + // Full: block on send (waits for + // capacity) unless aborted. + tokio::select! { + _ = effective.cancelled() => return, + res = tx.send(Ok(json.clone())) => { + if res.is_err() { return; } } } - Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => { - return; - } + } + Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => { + return; } } + } } Err(e) => { // Connect failure: deliver as the first channel item. // try_send + abort-select (a full channel must not // stall; abort covers the session-drop path). - loop { - match tx.try_send(Err(AiMuxBindingError::from(&e))) { - Ok(()) => break, - Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => { - tokio::select! { - _ = effective.cancelled() => return, - res = tx.send(Err(AiMuxBindingError::from(&e))) => { - if res.is_err() { return; } - break; - } + match tx.try_send(Err(AiMuxBindingError::from(&e))) { + Ok(()) => {}, + Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => { + tokio::select! { + _ = effective.cancelled() => return, + res = tx.send(Err(AiMuxBindingError::from(&e))) => { + if res.is_err() { return; } } } - Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => return, } + Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => return, } + } } }); diff --git a/bindings/node/src/types.ts b/bindings/node/src/types.ts index 489ae22d5..bfc723811 100644 --- a/bindings/node/src/types.ts +++ b/bindings/node/src/types.ts @@ -46,6 +46,7 @@ export type * from './types/Fingerprint' export type * from './types/FinishReason' export type * from './types/FinishReasonUnified' export type * from './types/FunctionTool' +export type * from './types/FunctionToolInputExample' export type * from './types/GenerateContent' export type * from './types/GenerateObjectResult' export type * from './types/GenerateResult' diff --git a/bindings/node/src/types/CallOptions.ts b/bindings/node/src/types/CallOptions.ts index 53c072370..40f9151ad 100644 --- a/bindings/node/src/types/CallOptions.ts +++ b/bindings/node/src/types/CallOptions.ts @@ -62,7 +62,7 @@ tools: Array | null, /** * How the model should choose tools. */ -tool_choice: ToolChoice, +tool_choice: ToolChoice | null, /** * Extra HTTP headers. */ diff --git a/bindings/node/src/types/ContentPart.ts b/bindings/node/src/types/ContentPart.ts index a7538a154..16ca615db 100644 --- a/bindings/node/src/types/ContentPart.ts +++ b/bindings/node/src/types/ContentPart.ts @@ -27,12 +27,6 @@ input: JsonValue, * need it to replay server tool calls on a later turn. */ provider_executed?: boolean | null, -/** - * Provider-assigned thought signature (e.g. Google Gemini - * `thoughtSignature`). Must be echoed back verbatim on the follow-up - * turn when the tool result is sent. - */ -thought_signature?: string | null, /** * Provider-specific options for this part (e.g. * `anthropic.cacheControl`). diff --git a/bindings/node/src/types/FunctionTool.ts b/bindings/node/src/types/FunctionTool.ts index a79887e0c..efdcfaccf 100644 --- a/bindings/node/src/types/FunctionTool.ts +++ b/bindings/node/src/types/FunctionTool.ts @@ -1,4 +1,5 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { FunctionToolInputExample } from "./FunctionToolInputExample"; import type { JsonValue } from "./serde_json/JsonValue"; /** @@ -31,4 +32,4 @@ provider_options?: { [key in string]: JsonValue } | null, * Example inputs for the tool (V4 `inputExamples`), used by some providers * to emit `input_examples` in the request body. */ -input_examples?: Array | null, }; +input_examples?: Array | null, }; diff --git a/bindings/node/src/types/FunctionToolInputExample.ts b/bindings/node/src/types/FunctionToolInputExample.ts new file mode 100644 index 000000000..588fc7ad4 --- /dev/null +++ b/bindings/node/src/types/FunctionToolInputExample.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"; + +/** + * An example input for a function tool. + */ +export type FunctionToolInputExample = { input: { [key in string]: JsonValue }, }; diff --git a/bindings/node/src/types/GenerateContent.ts b/bindings/node/src/types/GenerateContent.ts index f6da76302..aa72530dc 100644 --- a/bindings/node/src/types/GenerateContent.ts +++ b/bindings/node/src/types/GenerateContent.ts @@ -1,55 +1,10 @@ // 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 { RawToolCall } from "./RawToolCall"; +import type { ToolResult } from "./ToolResult"; import type { JsonValue } from "./serde_json/JsonValue"; /** * A content item in the generation result. */ -export type GenerateContent = { "Text": { text: string, provider_metadata?: JsonValue | null, } } | { "ToolCall": { tool_call_id: string, tool_name: string, -/** - * The model's raw argument text, exactly as the provider delivered - * it (possibly malformed). Providers never parse it — `generate_text` - * owns parsing, schema validation, and repair. - * - * Always serializes as a JSON string. Deserializes a JSON string - * (the current wire shape) unchanged, and also re-serializes the - * pre-refactor shape — an already-parsed JSON value — to its compact - * JSON text, so a `GenerateResult` persisted before this field - * became a `String` keeps loading. See docs/api/gaps.md §9. - */ -input: string, -/** - * Whether the tool call will be executed by the provider. - * If false/unset, the tool call is executed by the client. - */ -provider_executed?: boolean | null, -/** - * Whether the tool is dynamic (defined at runtime, e.g. MCP tools). - */ -dynamic?: boolean | null, -/** - * Provider-assigned thought signature (e.g. Google Gemini - * `thoughtSignature`). Must be echoed back verbatim on the follow-up - * turn when the tool result is sent. - */ -thought_signature?: string | null, -/** - * Additional provider-specific metadata for the tool call. - */ -provider_metadata?: JsonValue | null, } } | { "Source": { id: string, source_type: string, url: string | null, title: string | null, provider_metadata?: JsonValue | null, } } | { "Reasoning": { text: string, provider_metadata: JsonValue | null, } } | { "File": { data: FileData, media_type: string, provider_metadata?: JsonValue | null, } } | { "ToolResult": { tool_call_id: string, tool_name: string, result: JsonValue, -/** - * Whether the result is an error or error message. - */ -is_error?: boolean | null, -/** - * Whether the result is preliminary (replaces prior, e.g. image previews). - */ -preliminary?: boolean | null, -/** - * Whether the tool is dynamic (defined at runtime, e.g. MCP tools). - */ -dynamic?: boolean | null, -/** - * Additional provider-specific metadata for the tool result. - */ -provider_metadata?: JsonValue | null, } }; +export type GenerateContent = { "Text": { text: string, provider_metadata?: JsonValue | null, } } | { "ToolCall": RawToolCall } | { "Source": { id: string, source_type: string, url: string | null, title: string | null, provider_metadata?: JsonValue | null, } } | { "Reasoning": { text: string, provider_metadata: JsonValue | null, } } | { "File": { data: FileData, media_type: string, provider_metadata?: JsonValue | null, } } | { "ToolResult": ToolResult }; diff --git a/bindings/node/src/types/ProviderTool.ts b/bindings/node/src/types/ProviderTool.ts index c7770fef8..425e83930 100644 --- a/bindings/node/src/types/ProviderTool.ts +++ b/bindings/node/src/types/ProviderTool.ts @@ -22,4 +22,4 @@ name: string, * The arguments for configuring the tool. Must match the expected * arguments defined by the provider for this tool. */ -args: JsonValue, }; +args: { [key in string]: JsonValue }, }; diff --git a/bindings/node/src/types/RawToolCall.ts b/bindings/node/src/types/RawToolCall.ts index bab390b0e..64adeade3 100644 --- a/bindings/node/src/types/RawToolCall.ts +++ b/bindings/node/src/types/RawToolCall.ts @@ -7,4 +7,4 @@ import type { JsonValue } from "./serde_json/JsonValue"; * The wire shape matches [`ToolCall`] field for field, except that `input` * is the provider's raw argument *text* rather than a parsed value. */ -export type RawToolCall = { tool_call_id: string, tool_name: string, input: string, provider_executed?: boolean | null, dynamic?: boolean | null, thought_signature?: string | null, provider_metadata?: JsonValue | null, }; +export type RawToolCall = { tool_call_id: string, tool_name: string, input: string, provider_executed?: boolean | null, dynamic?: boolean | null, provider_metadata?: JsonValue | null, }; diff --git a/bindings/node/src/types/StreamPart.ts b/bindings/node/src/types/StreamPart.ts index a04fe8385..153fddc19 100644 --- a/bindings/node/src/types/StreamPart.ts +++ b/bindings/node/src/types/StreamPart.ts @@ -2,12 +2,18 @@ import type { AiMuxError } from "./AiMuxError"; import type { FileData } from "./FileData"; import type { FinishReason } from "./FinishReason"; +import type { ToolCall } from "./ToolCall"; +import type { ToolResult } from "./ToolResult"; import type { Usage } from "./Usage"; import type { Warning } from "./Warning"; import type { JsonValue } from "./serde_json/JsonValue"; /** * A single chunk in the stream returned by `do_stream`. + * + * Generic over the tool-call payload: providers emit the default + * [`StreamPart`] (raw argument text); `stream_text` emits [`TextStreamPart`] + * (parsed and validated [`ToolCall`]). */ export type StreamPart = { "TextStart": { id: string, provider_metadata?: JsonValue | null, } } | { "TextDelta": { id: string, delta: string, provider_metadata?: JsonValue | null, } } | { "TextEnd": { id: string, provider_metadata?: JsonValue | null, } } | { "StreamStart": { warnings: Array, } } | { "Finish": { finish_reason: FinishReason, usage: Usage, provider_metadata: JsonValue | null, } } | { "Error": { error: AiMuxError, } } | { "ToolInputStart": { id: string, tool_name: string, /** @@ -21,50 +27,7 @@ dynamic?: boolean | null, /** * Optional title for the tool call. */ -title?: string | null, provider_metadata?: JsonValue | null, } } | { "ToolInputDelta": { id: string, delta: string, provider_metadata?: JsonValue | null, } } | { "ToolInputEnd": { id: string, provider_metadata?: JsonValue | null, } } | { "ToolCall": { tool_call_id: string, tool_name: string, -/** - * Serialized argument text in a `Value::String` from `do_stream`; - * parsed input after `stream_text`. - */ -input: JsonValue, -/** - * Whether the tool call will be executed by the provider. - */ -provider_executed?: boolean | null, -/** - * Whether the tool is dynamic (defined at runtime, e.g. MCP tools). - */ -dynamic?: boolean | null, -/** - * Provider-assigned thought signature (e.g. Google Gemini - * `thoughtSignature`). Must be echoed back verbatim on the follow-up - * turn when the tool result is sent. - */ -thought_signature?: string | null, -/** - * Set by Core when the call remains invalid after optional repair. - */ -invalid?: boolean | null, -/** - * Typed lookup, parsing, schema, or repair failure. - */ -error?: AiMuxError | null, provider_metadata?: JsonValue | null, } } | { "ToolResult": { tool_call_id: string, tool_name: string, result: JsonValue, -/** - * Whether the result is an error or error message. - */ -is_error?: boolean | null, -/** - * Whether the result is preliminary (replaces prior, e.g. image previews). - */ -preliminary?: boolean | null, -/** - * Whether the tool is dynamic (defined at runtime, e.g. MCP tools). - */ -dynamic?: boolean | null, -/** - * Additional provider-specific metadata for the tool result. - */ -provider_metadata?: JsonValue | null, } } | { "File": { data: FileData, media_type: string, provider_metadata?: JsonValue | null, } } | { "ReasoningStart": { id: string, +title?: string | null, provider_metadata?: JsonValue | null, } } | { "ToolInputDelta": { id: string, delta: string, provider_metadata?: JsonValue | null, } } | { "ToolInputEnd": { id: string, provider_metadata?: JsonValue | null, } } | { "ToolCall": ToolCall } | { "ToolResult": ToolResult } | { "File": { data: FileData, media_type: string, provider_metadata?: JsonValue | null, } } | { "ReasoningStart": { id: string, /** * Provider-specific metadata (e.g. xAI `itemId`). */ diff --git a/bindings/node/src/types/ToolCall.ts b/bindings/node/src/types/ToolCall.ts index c6b72530d..605cb733d 100644 --- a/bindings/node/src/types/ToolCall.ts +++ b/bindings/node/src/types/ToolCall.ts @@ -27,13 +27,6 @@ provider_executed?: boolean | null, * Whether the tool is dynamic (defined at runtime, e.g. MCP tools). */ dynamic?: boolean | null, -/** - * Provider-assigned thought signature (e.g. Google Gemini - * `thoughtSignature`). Must be echoed back verbatim on the follow-up turn - * when the tool result is sent; thinking models reject the request - * otherwise. - */ -thought_signature?: string | null, /** * Additional provider-specific metadata associated with this call. */ diff --git a/bindings/node/src/types/ToolResult.ts b/bindings/node/src/types/ToolResult.ts index 1398d1a46..9c5039b52 100644 --- a/bindings/node/src/types/ToolResult.ts +++ b/bindings/node/src/types/ToolResult.ts @@ -2,13 +2,14 @@ import type { JsonValue } from "./serde_json/JsonValue"; /** - * The result of executing a tool call, to be sent back to the model. + * A tool result produced by a provider-executed tool (e.g. xAI `file_search`, + * web search), carried next to the [`RawToolCall`] it answers. */ export type ToolResult = { /** - * Must match the corresponding `ToolCall::tool_call_id`. + * Must match the corresponding call's `tool_call_id`. */ -tool_call_id: string, +tool_call_id: string, tool_name: string, /** * The tool's output (usually a JSON-serializable value or plain text). */ @@ -20,4 +21,12 @@ 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, +/** + * Additional provider-specific metadata for the tool result. + */ +provider_metadata?: JsonValue | null, }; diff --git a/bindings/python/python/aimux/wrapper.py b/bindings/python/python/aimux/wrapper.py index 86e683262..a6e4e0fc2 100644 --- a/bindings/python/python/aimux/wrapper.py +++ b/bindings/python/python/aimux/wrapper.py @@ -54,6 +54,7 @@ "RepairToolCall", "ModelMessage", "FunctionTool", + "FunctionToolInputExample", "ProviderTool", "TextContentPart", "GenerateTextOptions", @@ -154,7 +155,6 @@ class ToolCall(BaseModel): input: Any provider_executed: Optional[bool] = None dynamic: Optional[bool] = None - thought_signature: Optional[str] = None provider_metadata: Optional[Any] = None invalid: Optional[bool] = None error: Optional[AiMuxErrorValue] = None @@ -173,7 +173,6 @@ class RawToolCall(BaseModel): input: str provider_executed: Optional[bool] = None dynamic: Optional[bool] = None - thought_signature: Optional[str] = None provider_metadata: Optional[Any] = None @@ -378,7 +377,6 @@ class _ContentToolCall(BaseModel): input: Any provider_executed: Optional[bool] = None dynamic: Optional[bool] = None - thought_signature: Optional[str] = None provider_metadata: Optional[Any] = None @@ -511,7 +509,6 @@ class _SPToolCall(BaseModel): input: Any provider_executed: Optional[bool] = None dynamic: Optional[bool] = None - thought_signature: Optional[str] = None provider_metadata: Optional[Dict[str, Any]] = None invalid: Optional[bool] = None error: Optional[AiMuxErrorValue] = None @@ -665,6 +662,10 @@ def _ser(self, handler): # type: ignore[no-untyped-def] # union with a ``type`` discriminator works directly. # ───────────────────────────────────────────────────────────────────────────── +class FunctionToolInputExample(BaseModel): + input: Dict[str, Any] + + class FunctionTool(BaseModel): """A user-defined function tool (Rust ``FunctionTool``).""" @@ -674,7 +675,7 @@ class FunctionTool(BaseModel): input_schema: Any strict: Optional[bool] = None provider_options: Optional[Dict[str, Any]] = None - input_examples: Optional[List[Any]] = None + input_examples: Optional[List[FunctionToolInputExample]] = None class ProviderTool(BaseModel): @@ -683,7 +684,7 @@ class ProviderTool(BaseModel): type: Literal["provider"] = "provider" id: str name: str - args: Any + args: Dict[str, Any] Tool = Annotated[ @@ -753,7 +754,6 @@ class _ToolCallContentPart(BaseModel): tool_name: str input: Any provider_executed: Optional[bool] = None - thought_signature: Optional[str] = None provider_options: Optional[Any] = None diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs index fdffb6aaf..b06f2a3ce 100644 --- a/bindings/python/src/lib.rs +++ b/bindings/python/src/lib.rs @@ -6,6 +6,8 @@ // pyo3 0.22 macros generate unsafe-op-in-unsafe-fn calls that trigger // edition-2024 lint. Suppress until pyo3 0.23+ lands. #![allow(unsafe_op_in_unsafe_fn)] +// PyO3 0.22 wraps PyResult returns in an identity conversion. +#![allow(clippy::useless_conversion)] mod decision; mod error; @@ -243,7 +245,7 @@ impl Model { // deliver it as a StreamPart::Error data item and keep // pumping. match serde_json::to_string( - &aimux_core::stream_part::StreamPart::Error { error: e }, + &aimux_core::stream_part::TextStreamPart::Error { error: e }, ) { Ok(json) => { if tx.send(Ok(json)).await.is_err() { @@ -410,7 +412,7 @@ impl StreamIterator { let item = py.allow_threads(|| runtime().block_on(self.rx.recv())); match item { - Some(Ok(json)) => Ok(Some(json.to_object(py).into())), + Some(Ok(json)) => Ok(Some(json.to_object(py))), Some(Err(f)) => Err(f.to_py_err()), None => Ok(None), // stream finished } @@ -663,10 +665,10 @@ fn azure( if let Some(url) = base_url { config = config.with_base_url(url); } - if let Some(version) = api_version { - if !version.is_empty() { - config = config.with_api_version(version); - } + if let Some(version) = api_version + && !version.is_empty() + { + config = config.with_api_version(version); } if !resource_name.is_empty() { config = config.with_resource_name(resource_name); @@ -775,6 +777,7 @@ fn create_provider( /// string (serialized `Catalogue`). `source_url` defaults to the anya2a /// `dist/all.json`. #[pyfunction] +#[pyo3(signature = (source_url=None))] fn get_model_specs(source_url: Option<&str>) -> PyResult { let catalogue = runtime() .block_on(async { aimux_providers::get_model_specs(source_url).await }) diff --git a/bindings/python/src/multimodal.rs b/bindings/python/src/multimodal.rs index 3c7289617..0bef00f90 100644 --- a/bindings/python/src/multimodal.rs +++ b/bindings/python/src/multimodal.rs @@ -14,7 +14,7 @@ use std::sync::Arc; use crate::error::{ - BindingError, AiMuxBindingError, binding_py_err, serialize_result, to_py_err, wire_error, + AiMuxBindingError, BindingError, binding_py_err, serialize_result, to_py_err, wire_error, wire_json, }; use aimux_core::AiMuxError; @@ -66,8 +66,7 @@ fn parse_opts_json( /// Shape-only stand-in for `TranscriptionCallOptions::audio`, built from the /// real enum so a variant rename is a compile error rather than a runtime one. fn audio_placeholder() -> serde_json::Value { - serde_json::to_value(AudioInput::Base64(String::new())) - .expect("AudioInput always serializes") + serde_json::to_value(AudioInput::Base64(String::new())).expect("AudioInput always serializes") } /// Shape-only stand-in for `RerankingCallOptions::documents`. @@ -192,22 +191,23 @@ impl TranscriptionModel { AudioInput::Base64(audio_base64.to_string()), media_type.to_string(), ); - if let Some(s) = opts_json { - if !s.trim().is_empty() && s.trim() != "null" { - // Take every caller option; audio and media_type always come - // from the explicit args. - let mut parsed: TranscriptionCallOptions = parse_opts_json( - "opts_json", - s, - &[ - ("audio", audio_placeholder()), - ("media_type", serde_json::Value::from("")), - ], - )?; - parsed.audio = opts.audio; - parsed.media_type = opts.media_type; - opts = parsed; - } + if let Some(s) = opts_json + && !s.trim().is_empty() + && s.trim() != "null" + { + // Take every caller option; audio and media_type always come + // from the explicit args. + let mut parsed: TranscriptionCallOptions = parse_opts_json( + "opts_json", + s, + &[ + ("audio", audio_placeholder()), + ("media_type", serde_json::Value::from("")), + ], + )?; + parsed.audio = opts.audio; + parsed.media_type = opts.media_type; + opts = parsed; } let result = crate::runtime().block_on(async move { @@ -246,22 +246,23 @@ impl RerankingModel { let docs: RerankingDocuments = wire_json("docs_json", docs_json)?; let mut opts = RerankingCallOptions::new(query.to_string(), docs); - if let Some(s) = opts_json { - if !s.trim().is_empty() && s.trim() != "null" { - // Take every caller option; query and documents always come - // from the explicit args. - let mut parsed: RerankingCallOptions = parse_opts_json( - "opts_json", - s, - &[ - ("query", serde_json::Value::from("")), - ("documents", documents_placeholder()), - ], - )?; - parsed.query = opts.query; - parsed.documents = opts.documents; - opts = parsed; - } + if let Some(s) = opts_json + && !s.trim().is_empty() + && s.trim() != "null" + { + // Take every caller option; query and documents always come + // from the explicit args. + let mut parsed: RerankingCallOptions = parse_opts_json( + "opts_json", + s, + &[ + ("query", serde_json::Value::from("")), + ("documents", documents_placeholder()), + ], + )?; + parsed.query = opts.query; + parsed.documents = opts.documents; + opts = parsed; } let result = crate::runtime().block_on(async move { @@ -318,18 +319,16 @@ impl SearchModel { #[pyo3(signature = (query, opts_json=None))] pub fn search(&self, query: &str, opts_json: Option<&str>) -> PyResult { let mut opts = SearchCallOptions::new(query.to_string()); - if let Some(s) = opts_json { - if !s.trim().is_empty() && s.trim() != "null" { - // Take every caller option; the query always comes from the - // explicit arg. - let mut parsed: SearchCallOptions = parse_opts_json( - "opts_json", - s, - &[("query", serde_json::Value::from(""))], - )?; - parsed.query = opts.query; - opts = parsed; - } + if let Some(s) = opts_json + && !s.trim().is_empty() + && s.trim() != "null" + { + // Take every caller option; the query always comes from the + // explicit arg. + let mut parsed: SearchCallOptions = + parse_opts_json("opts_json", s, &[("query", serde_json::Value::from(""))])?; + parsed.query = opts.query; + opts = parsed; } let result = crate::runtime().block_on(async move { @@ -372,12 +371,13 @@ impl Files { }, media_type.to_string(), ); - if let Some(s) = opts_json { - if !s.trim().is_empty() && s.trim() != "null" { - let parsed: UploadFileCallOptions = wire_json("opts_json", s)?; - opts.filename = parsed.filename; - opts.provider_options = parsed.provider_options; - } + if let Some(s) = opts_json + && !s.trim().is_empty() + && s.trim() != "null" + { + let parsed: UploadFileCallOptions = wire_json("opts_json", s)?; + opts.filename = parsed.filename; + opts.provider_options = parsed.provider_options; } let result = crate::runtime().block_on(async move { self.inner.upload_file(&opts).await }); @@ -672,8 +672,8 @@ pub fn start_transcription_session( include_raw_chunks: opts.include_raw_chunks.unwrap_or(false), timeout: opts.timeout, }; - let result = aimux_core::transcription_model::stream_transcribe(model.as_ref(), options) - .await; + let result = + aimux_core::transcription_model::stream_transcribe(model.as_ref(), options).await; match result { Ok(stream_result) => { use futures::StreamExt; @@ -712,21 +712,18 @@ pub fn start_transcription_session( return; } }; - loop { - match tx.try_send(Ok(json.clone())) { - Ok(()) => break, - Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => { - tokio::select! { - _ = effective.cancelled() => return, - res = tx.send(Ok(json.clone())) => { - if res.is_err() { return; } - break; - } + match tx.try_send(Ok(json.clone())) { + Ok(()) => {} + Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => { + tokio::select! { + _ = effective.cancelled() => return, + res = tx.send(Ok(json.clone())) => { + if res.is_err() { return; } } } - Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => { - return; - } + } + Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => { + return; } } } @@ -734,20 +731,17 @@ pub fn start_transcription_session( Err(e) => { // Connect failure: deliver as the first channel item // (try_send + abort-select; mirrors the FFI driver). - loop { - match tx.try_send(Err(e.clone().into())) { - Ok(()) => break, - Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => { - tokio::select! { - _ = effective.cancelled() => return, - res = tx.send(Err(e.clone().into())) => { - if res.is_err() { return; } - break; - } + match tx.try_send(Err(e.clone().into())) { + Ok(()) => {} + Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => { + tokio::select! { + _ = effective.cancelled() => return, + res = tx.send(Err(e.clone().into())) => { + if res.is_err() { return; } } } - Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => return, } + Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => return, } } } diff --git a/bindings/python/tests/test_contract.py b/bindings/python/tests/test_contract.py deleted file mode 100644 index 66df3eff7..000000000 --- a/bindings/python/tests/test_contract.py +++ /dev/null @@ -1,123 +0,0 @@ -"""Cross-language contract tests for the Python binding. - -Consumes the shared `contract-tests/fixtures/wire-format.json` — the same -fixtures asserted by Rust `contract_test.rs`, Node `run-node.ts`, Go -`wire_format_test.go` and Java/Kotlin `ContractTest`. - -Pure model-layer tests: no native library needed. -""" - -import json -from pathlib import Path - -from aimux.wrapper import GenerateContent, GenerateTextOptions, ModelMessage, parse_stream_part - -_REPO_ROOT = Path(__file__).resolve().parents[3] - - -def _fixtures(): - path = _REPO_ROOT / "contract-tests" / "fixtures" / "wire-format.json" - return json.loads(path.read_text(encoding="utf-8")) - - -def _fixture_json(name): - for f in _fixtures(): - if f["name"] == name: - return f["json"] - raise AssertionError(f"no fixture named {name!r}") - - -def test_stream_part_raw_fixture_decodes_to_raw(): - """RFC-0016 M2: the shared stream_part_raw fixture parses into the typed - Raw variant with the payload intact.""" - sp = parse_stream_part(json.loads(_fixture_json("stream_part_raw"))) - assert sp.root.type == "Raw" - assert sp.root.raw_value["id"] == "c1" - assert sp.root.raw_value["choices"] == [] - - -def test_include_raw_chunks_true_fixture_roundtrips(): - """RFC-0016 M2 true-case: include_raw_chunks:true survives typed parse - and re-serialization.""" - opts = GenerateTextOptions.model_validate_json( - _fixture_json("generate_text_options_include_raw_chunks_true") - ) - assert opts.include_raw_chunks is True - assert '"include_raw_chunks":true' in opts.model_dump_json(exclude_none=True) - - -def test_generate_text_options_default_fixture_all_null(): - """Default options: include_raw_chunks must be null like every other - field (RFC-0016 M2 default is off).""" - opts = GenerateTextOptions.model_validate_json( - _fixture_json("generate_text_options_default") - ) - assert opts.include_raw_chunks is None - - -def test_numeric_options_fixture_keeps_precision(): - """The regression lock for ``top_k``. - - The value is asserted rather than merely decoded: Python would happily - accept 40.5 into a float either way, but asserting it means a fixture - narrowed back to an integer is caught here too. - """ - opts = GenerateTextOptions.model_validate_json( - _fixture_json("generate_text_options_numeric_types") - ) - assert opts.top_k == 40.5 - assert opts.frequency_penalty == -0.5 - assert opts.temperature == 0.7 - assert opts.top_p == 0.95 - assert opts.max_output_tokens == 256 - assert opts.seed == 42 - assert opts.max_retries == 3 - - -def test_generate_content_fixtures_decode_into_typed_variants(): - """Every GenerateContent fixture decodes into the typed union. - - Decoding alone is the check that matters here: the union is discriminated, - so a variant whose shape drifted stops matching and validation fails. The - count is asserted so a newly added fixture cannot slip past this test - unnoticed. - """ - fixtures = [f for f in _fixtures() if f["type"] == "GenerateContent"] - assert len(fixtures) == 8, f"expected 8 GenerateContent fixtures, saw {len(fixtures)}" - - by_name = {} - for f in fixtures: - content = GenerateContent.model_validate(json.loads(f["json"])) - by_name[f["name"]] = content.root - - # Spot-check the shapes the maintainer flagged as invisible to a - # parse-only check: the tool-call input, the nested file union, and - # Source's optionals. - tool_call = by_name["generate_content_tool_call"] - assert tool_call.tool_call_id == "call_1" - assert tool_call.input == '{"city":"Paris"}' - assert tool_call.provider_executed is True - - file_part = by_name["generate_content_file"] - assert file_part.media_type == "image/png" - - source = by_name["generate_content_source_unset_optionals"] - assert source.url is None - assert source.title is None - - -def test_provider_executed_tool_transcript_message_fixture_roundtrips(): - message = ModelMessage.model_validate_json( - _fixture_json("model_message_provider_executed_tool_transcript") - ) - call, result = message.content - assert call.provider_executed is True - assert call.tool_name == "search" - assert result.tool_name == "search" - assert result.is_error is False - assert result.preliminary is True - assert result.dynamic is True - - reencoded = message.model_dump_json(exclude_none=True) - decoded = ModelMessage.model_validate_json(reencoded) - assert decoded == message diff --git a/bindings/python/tests/test_tool_call_repair.py b/bindings/python/tests/test_tool_call_repair.py deleted file mode 100644 index e108c2b90..000000000 --- a/bindings/python/tests/test_tool_call_repair.py +++ /dev/null @@ -1,194 +0,0 @@ -import json -from pathlib import Path - -import pytest - -from aimux import ( - InvalidArgumentError, - apply_tool_call_repair, - apply_tool_call_repair_to_result, - generate_text, - generate_text_as_openai, - openai, - stream_text, - tool_call_repair_context, -) -from aimux import wrapper as typed -from test_e2e import MockServer, OPENAI_TOOL_CALL - -WEATHER_SCHEMA = { - "type": "object", - "properties": {"city": {"type": "string"}}, - "required": ["city"], - "additionalProperties": False, -} -TOOLS = [{"type": "function", "name": "get_weather", "input_schema": WEATHER_SCHEMA}] -TYPED_TOOLS = [typed.FunctionTool(name="get_weather", input_schema=WEATHER_SCHEMA)] -# The same canned response, with arguments the schema accepts. -VALID_TOOL_CALL = OPENAI_TOOL_CALL.replace("location", "city") -TOOL_CALL_STREAM = ( - 'data: {"id":"1","model":"gpt-4o","choices":[{"delta":{"role":"assistant",' - '"tool_calls":[{"index":0,"id":"call_s1","type":"function",' - '"function":{"name":"get_weather","arguments":""}}]}}]}\n\n' - 'data: {"id":"1","model":"gpt-4o","choices":[{"delta":{"tool_calls":[{"index":0,' - '"function":{"arguments":"{\\"location\\":\\"Tokyo\\"}"}}]}}]}\n\n' - 'data: {"id":"1","model":"gpt-4o","choices":[{"delta":{},"finish_reason":"tool_calls"}],' - '"usage":{"prompt_tokens":5,"completion_tokens":2,"total_tokens":7}}\n\n' - 'data: [DONE]\n\n' -) - - -def _repair(context): - return dict(context["tool_call"], input='{"city":"Tokyo"}') - - -def _cases(): - path = Path(__file__).resolve().parents[3] / "contract-tests/fixtures/tool-call-repair.json" - return json.loads(path.read_text(encoding="utf-8"))["cases"] - - -def _call(case): - args = case["input"] - opts = None if args["opts"] is None else json.dumps(args["opts"]) - if case["function"] == "tool_call_repair_context": - return tool_call_repair_context(json.dumps(args["tool_call"]), json.dumps(args["prompt"]), opts) - if case["function"] == "apply_tool_call_repair": - return apply_tool_call_repair(json.dumps(args["tool_call"]), opts, json.dumps(args["reply"])) - if case["function"] == "apply_tool_call_repair_to_result": - return apply_tool_call_repair_to_result( - json.dumps(args["result"]), opts, args["tool_call_id"], json.dumps(args["reply"]) - ) - raise AssertionError(f"unknown fixture function {case['function']!r}") - - -@pytest.mark.parametrize("case", _cases(), ids=lambda case: case["name"]) -def test_shared_fixture(case): - if case.get("expected_error"): - with pytest.raises(InvalidArgumentError): - _call(case) - else: - assert json.loads(_call(case)) == case["expected"] - - -def test_generate_repairs_call_and_transcript(): - captured = {} - - def repair(context): - captured.update(context) - return _repair(context) - - with MockServer(OPENAI_TOOL_CALL) as mock: - model = openai("test-key", "gpt-4o", mock.url) - result = generate_text(model, "What's the weather in Tokyo?", { - "tools": TOOLS, - "repair_tool_call": repair, - }) - - assert captured["tool_call"]["input"] == '{"location":"Tokyo"}' - assert result["tool_calls"][0]["input"] == {"city": "Tokyo"} - transcript_calls = [ - part - for message in result["response_messages"] - for part in message["content"] - if isinstance(part, dict) and part.get("type") == "tool_call" - ] - assert [part["input"] for part in transcript_calls] == [result["tool_calls"][0]["input"]] - - -def test_stream_repairs_tool_call_and_preserves_deltas(): - with MockServer(TOOL_CALL_STREAM, content_type="text/event-stream") as mock: - model = openai("test-key", "gpt-4o", mock.url) - parts = list(stream_text(model, "What's the weather in Tokyo?", { - "tools": TOOLS, - "repair_tool_call": _repair, - })) - - calls = [part["ToolCall"] for part in parts if "ToolCall" in part] - assert len(calls) == 1 - assert calls[0]["input"] == {"city": "Tokyo"} - deltas = "".join(part["ToolInputDelta"]["delta"] for part in parts if "ToolInputDelta" in part) - assert deltas == '{"location":"Tokyo"}' - - -def test_generate_as_openai_carries_the_repaired_arguments(): - # A ChatCompletion has no `invalid` marker: the binding repairs the native - # result and converts it, on both API layers. - with MockServer(OPENAI_TOOL_CALL) as mock: - model = openai("test-key", "gpt-4o", mock.url) - plain = generate_text_as_openai(model, "What's the weather in Tokyo?", { - "tools": TOOLS, - "repair_tool_call": _repair, - }) - completion = typed.generate_text_as_openai( - model, - "What's the weather in Tokyo?", - typed.GenerateTextOptions( - tools=TYPED_TOOLS, - repair_tool_call=_typed_repair, - ), - ) - - arguments = plain["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"] - assert json.loads(arguments) == {"city": "Tokyo"} - typed_call = completion.choices[0].message.tool_calls[0] - assert json.loads(typed_call.function.arguments) == {"city": "Tokyo"} - - -def test_a_wrong_return_type_is_an_error_not_a_failed_repair(): - # Only the hook's own exception means "failed"; the typed layer's encoding - # of what it returned is not the hook's failure and must surface as itself. - options = typed.GenerateTextOptions( - tools=TYPED_TOOLS, - repair_tool_call=lambda ctx: {"tool_call_id": "x", "tool_name": "y", "input": "{}"}, - ) - with MockServer(OPENAI_TOOL_CALL) as mock: - model = openai("test-key", "gpt-4o", mock.url) - with pytest.raises(AttributeError): - typed.generate_text(model, "What's the weather in Tokyo?", options) - - -def _raise(_context): - raise RuntimeError("repair model unavailable") - - -def _typed_repair(context): - return context.tool_call.model_copy(update={"input": '{"city":"Tokyo"}'}) - - -def test_repair_loop_branches(): - """Host-loop branches the shared fixture cannot reach, on the typed layer.""" - cases = [ - # name, response, with_tools, hook, expected hook invocations, error check - ("raises", OPENAI_TOOL_CALL, True, _raise, 1, lambda e: ( - e["ToolCallRepair"]["cause"] == {"Other": "repair model unavailable"} - and "InvalidToolInput" in e["ToolCallRepair"]["original_error"] - )), - ("returns None", OPENAI_TOOL_CALL, True, lambda ctx: None, 1, - lambda e: set(e) == {"InvalidToolInput"}), - ("valid call", VALID_TOOL_CALL, True, _typed_repair, 0, None), - ("no tools", OPENAI_TOOL_CALL, False, _typed_repair, 0, - lambda e: set(e) == {"NoSuchTool"}), - ] - - for name, response, with_tools, hook, invocations, check_error in cases: - seen = [] - - def spy(context, hook=hook, seen=seen): - seen.append(context) - return hook(context) - - options = typed.GenerateTextOptions( - tools=TYPED_TOOLS if with_tools else None, - repair_tool_call=spy, - ) - with MockServer(response) as mock: - model = openai("test-key", "gpt-4o", mock.url) - result = typed.generate_text(model, "What's the weather in Tokyo?", options) - - assert len(seen) == invocations, name - call = result.tool_calls[0] - if check_error is None: - assert not call.invalid and call.input == {"city": "Tokyo"}, name - else: - assert call.invalid is True, name - assert check_error(call.error), (name, call.error) diff --git a/bindings/swift/Sources/Aimux/ToolCallRepair.swift b/bindings/swift/Sources/Aimux/ToolCallRepair.swift index 7be8e8141..af5bd09a7 100644 --- a/bindings/swift/Sources/Aimux/ToolCallRepair.swift +++ b/bindings/swift/Sources/Aimux/ToolCallRepair.swift @@ -33,7 +33,6 @@ public struct RawToolCall: Codable, Equatable { public var input: String public var providerExecuted: Bool? public var dynamic: Bool? - public var thoughtSignature: String? public var providerMetadata: JSONValue? enum CodingKeys: String, CodingKey { @@ -42,16 +41,15 @@ public struct RawToolCall: Codable, Equatable { case input case providerExecuted = "provider_executed" case dynamic - case thoughtSignature = "thought_signature" case providerMetadata = "provider_metadata" } public init(toolCallId: String, toolName: String, input: String, providerExecuted: Bool? = nil, dynamic: Bool? = nil, - thoughtSignature: String? = nil, providerMetadata: JSONValue? = nil) { + providerMetadata: JSONValue? = nil) { self.toolCallId = toolCallId; self.toolName = toolName; self.input = input self.providerExecuted = providerExecuted; self.dynamic = dynamic - self.thoughtSignature = thoughtSignature; self.providerMetadata = providerMetadata + self.providerMetadata = providerMetadata } } diff --git a/bindings/swift/Sources/Aimux/Types.swift b/bindings/swift/Sources/Aimux/Types.swift index 13b0f41d4..529bd71ac 100644 --- a/bindings/swift/Sources/Aimux/Types.swift +++ b/bindings/swift/Sources/Aimux/Types.swift @@ -251,6 +251,12 @@ public enum Warning: Codable, Equatable { // MARK: - Tool / ToolChoice +public struct FunctionToolInputExample: Codable, Equatable { + public var input: [String: JSONValue] + + public init(input: [String: JSONValue]) { self.input = input } +} + /// A user-defined function tool definition. public struct FunctionTool: Codable, Equatable { public var name: String @@ -259,7 +265,7 @@ public struct FunctionTool: Codable, Equatable { public var inputSchema: JSONValue public var strict: Bool? public var providerOptions: JSONValue? - public var inputExamples: [JSONValue]? + public var inputExamples: [FunctionToolInputExample]? enum CodingKeys: String, CodingKey { case name, description @@ -271,7 +277,7 @@ public struct FunctionTool: Codable, Equatable { public init(name: String, inputSchema: JSONValue, description: String? = nil, strict: Bool? = nil, providerOptions: JSONValue? = nil, - inputExamples: [JSONValue]? = nil) { + inputExamples: [FunctionToolInputExample]? = nil) { self.name = name; self.inputSchema = inputSchema; self.description = description self.strict = strict; self.providerOptions = providerOptions; self.inputExamples = inputExamples } @@ -281,9 +287,9 @@ public struct FunctionTool: Codable, Equatable { public struct ProviderTool: Codable, Equatable { public var id: String public var name: String - public var args: JSONValue + public var args: [String: JSONValue] - public init(id: String, name: String, args: JSONValue) { + public init(id: String, name: String, args: [String: JSONValue]) { self.id = id; self.name = name; self.args = args } } @@ -307,12 +313,12 @@ public enum Tool: Codable, Equatable { description: try c.decodeIfPresent(String.self, forKey: AnyCodingKey("description")), strict: try c.decodeIfPresent(Bool.self, forKey: AnyCodingKey("strict")), providerOptions: try c.decodeIfPresent(JSONValue.self, forKey: AnyCodingKey("provider_options")), - inputExamples: try c.decodeIfPresent([JSONValue].self, forKey: AnyCodingKey("input_examples")))) + inputExamples: try c.decodeIfPresent([FunctionToolInputExample].self, forKey: AnyCodingKey("input_examples")))) case "provider": self = .provider(ProviderTool( id: try c.decode(String.self, forKey: AnyCodingKey("id")), name: try c.decode(String.self, forKey: AnyCodingKey("name")), - args: try c.decode(JSONValue.self, forKey: AnyCodingKey("args")))) + args: try c.decode([String: JSONValue].self, forKey: AnyCodingKey("args")))) default: throw aimuxDecodingError(c.codingPath, "unknown tool type \(type)") } @@ -436,7 +442,7 @@ public enum ContentPart: Codable, Equatable { case fileReference(mediaType: String, reference: JSONValue, filename: String?, providerOptions: JSONValue?) case reasoning(text: String, signature: String?, providerOptions: JSONValue?) case toolCall(toolCallId: String, toolName: String, input: JSONValue, - providerExecuted: Bool? = nil, thoughtSignature: String? = nil, + providerExecuted: Bool? = nil, providerOptions: JSONValue?) case toolResult(toolCallId: String, result: JSONValue, toolName: String?, isError: Bool?, preliminary: Bool?, dynamic: Bool?, providerOptions: JSONValue?) @@ -444,7 +450,7 @@ public enum ContentPart: Codable, Equatable { private enum Field: String, CodingKey { case text, image, data, mediaType = "media_type", filename, url, reference case signature, toolCallId = "tool_call_id", toolName = "tool_name", input, result - case providerExecuted = "provider_executed", thoughtSignature = "thought_signature" + case providerExecuted = "provider_executed" case isError = "is_error", preliminary, dynamic case providerOptions = "provider_options" } @@ -489,7 +495,6 @@ public enum ContentPart: Codable, Equatable { toolName: try c.decode(String.self, forKey: AnyCodingKey("tool_name")), input: try c.decode(JSONValue.self, forKey: AnyCodingKey("input")), providerExecuted: try c.decodeIfPresent(Bool.self, forKey: AnyCodingKey("provider_executed")), - thoughtSignature: try c.decodeIfPresent(String.self, forKey: AnyCodingKey("thought_signature")), providerOptions: try po()) case "tool_result": self = .toolResult(toolCallId: try c.decode(String.self, forKey: AnyCodingKey("tool_call_id")), @@ -544,13 +549,12 @@ public enum ContentPart: Codable, Equatable { try c.encode(text, forKey: AnyCodingKey("text")) try c.encodeIfPresent(signature, forKey: AnyCodingKey("signature")) try c.encodeIfPresent(po, forKey: AnyCodingKey("provider_options")) - case .toolCall(let toolCallId, let toolName, let input, let providerExecuted, let thoughtSignature, let po): + case .toolCall(let toolCallId, let toolName, let input, let providerExecuted, let po): try c.encode("tool_call", forKey: AnyCodingKey("type")) try c.encode(toolCallId, forKey: AnyCodingKey("tool_call_id")) try c.encode(toolName, forKey: AnyCodingKey("tool_name")) try c.encode(input, forKey: AnyCodingKey("input")) try c.encodeIfPresent(providerExecuted, forKey: AnyCodingKey("provider_executed")) - try c.encodeIfPresent(thoughtSignature, forKey: AnyCodingKey("thought_signature")) try c.encodeIfPresent(po, forKey: AnyCodingKey("provider_options")) case .toolResult(let toolCallId, let result, let toolName, let isError, let preliminary, let dynamic, let po): try c.encode("tool_result", forKey: AnyCodingKey("type")) @@ -633,9 +637,6 @@ public struct ToolCall: Codable, Equatable { public var input: JSONValue public var providerExecuted: Bool? public var dynamic: Bool? - /// Provider-assigned thought signature (e.g. Google Gemini - /// `thoughtSignature`); must be echoed back verbatim on follow-up turns. - public var thoughtSignature: String? /// Additional provider-specific metadata associated with this call. public var providerMetadata: JSONValue? /// Set by Core when the tool call stays invalid after optional repair. @@ -649,7 +650,6 @@ public struct ToolCall: Codable, Equatable { case input case providerExecuted = "provider_executed" case dynamic - case thoughtSignature = "thought_signature" case providerMetadata = "provider_metadata" case invalid case error @@ -657,12 +657,11 @@ public struct ToolCall: Codable, Equatable { public init(toolCallId: String, toolName: String, input: JSONValue, providerExecuted: Bool? = nil, dynamic: Bool? = nil, - thoughtSignature: String? = nil, providerMetadata: JSONValue? = nil, + providerMetadata: JSONValue? = nil, invalid: Bool? = nil, error: JSONValue? = nil) { self.toolCallId = toolCallId; self.toolName = toolName; self.input = input self.providerExecuted = providerExecuted; self.dynamic = dynamic - self.thoughtSignature = thoughtSignature self.providerMetadata = providerMetadata self.invalid = invalid; self.error = error } @@ -763,7 +762,7 @@ public enum FileData: Codable, Equatable { public enum GenerateContent: Codable, Equatable { case text(text: String, providerMetadata: JSONValue?) case toolCall(toolCallId: String, toolName: String, input: JSONValue, - providerExecuted: Bool?, dynamic: Bool?, thoughtSignature: String? = nil, + providerExecuted: Bool?, dynamic: Bool?, providerMetadata: JSONValue?) case source(id: String, sourceType: String, url: String?, title: String?, providerMetadata: JSONValue?) @@ -776,7 +775,7 @@ public enum GenerateContent: Codable, Equatable { case text case toolCallId = "tool_call_id", toolName = "tool_name", input, result case providerExecuted = "provider_executed", dynamic - case thoughtSignature = "thought_signature", providerMetadata = "provider_metadata" + case providerMetadata = "provider_metadata" case id, sourceType = "source_type", url, title case isError = "is_error", preliminary case data, mediaType = "media_type" @@ -798,7 +797,6 @@ public enum GenerateContent: Codable, Equatable { input: try n.decode(JSONValue.self, forKey: .input), providerExecuted: try n.decodeIfPresent(Bool.self, forKey: .providerExecuted), dynamic: try n.decodeIfPresent(Bool.self, forKey: .dynamic), - thoughtSignature: try n.decodeIfPresent(String.self, forKey: .thoughtSignature), providerMetadata: try n.decodeIfPresent(JSONValue.self, forKey: .providerMetadata)) case "Source": self = .source(id: try n.decode(String.self, forKey: .id), @@ -833,14 +831,13 @@ public enum GenerateContent: Codable, Equatable { var n = c.nestedContainer(keyedBy: Field.self, forKey: AnyCodingKey("Text")) try n.encode(text, forKey: .text) try n.encodeIfPresent(pm, forKey: .providerMetadata) - case .toolCall(let toolCallId, let toolName, let input, let pe, let dyn, let signature, let pm): + case .toolCall(let toolCallId, let toolName, let input, let pe, let dyn, let pm): var n = c.nestedContainer(keyedBy: Field.self, forKey: AnyCodingKey("ToolCall")) try n.encode(toolCallId, forKey: .toolCallId) try n.encode(toolName, forKey: .toolName) try n.encode(input, forKey: .input) try n.encodeIfPresent(pe, forKey: .providerExecuted) try n.encodeIfPresent(dyn, forKey: .dynamic) - try n.encodeIfPresent(signature, forKey: .thoughtSignature) try n.encodeIfPresent(pm, forKey: .providerMetadata) case .source(let id, let sourceType, let url, let title, let pm): var n = c.nestedContainer(keyedBy: Field.self, forKey: AnyCodingKey("Source")) @@ -1246,7 +1243,7 @@ public enum StreamPart: Codable, Equatable { case toolInputDelta(id: String, delta: String, providerMetadata: JSONValue?) case toolInputEnd(id: String, providerMetadata: JSONValue?) case toolCall(toolCallId: String, toolName: String, input: JSONValue, - providerExecuted: Bool?, dynamic: Bool?, thoughtSignature: String? = nil, + providerExecuted: Bool?, dynamic: Bool?, providerMetadata: JSONValue?, invalid: Bool?, error: JSONValue?) case toolResult(toolCallId: String, toolName: String, result: JSONValue, @@ -1268,7 +1265,7 @@ public enum StreamPart: Codable, Equatable { case finishReason = "finish_reason", providerMetadata = "provider_metadata" case error case toolName = "tool_name", toolCallId = "tool_call_id", input, result - case providerExecuted = "provider_executed", dynamic, thoughtSignature = "thought_signature", invalid + case providerExecuted = "provider_executed", dynamic, invalid case isError = "is_error", preliminary case timestamp, modelId = "model_id" case sourceType = "source_type", url, title @@ -1323,7 +1320,6 @@ public enum StreamPart: Codable, Equatable { input: try n.decode(JSONValue.self, forKey: .input), providerExecuted: try n.decodeIfPresent(Bool.self, forKey: .providerExecuted), dynamic: try n.decodeIfPresent(Bool.self, forKey: .dynamic), - thoughtSignature: try n.decodeIfPresent(String.self, forKey: .thoughtSignature), providerMetadata: try n.decodeIfPresent(JSONValue.self, forKey: .providerMetadata), invalid: try n.decodeIfPresent(Bool.self, forKey: .invalid), error: try n.decodeIfPresent(JSONValue.self, forKey: .error)) @@ -1401,12 +1397,11 @@ public enum StreamPart: Codable, Equatable { case .toolInputEnd(let id, let pm): var n = c.nestedContainer(keyedBy: Field.self, forKey: AnyCodingKey("ToolInputEnd")) try n.encode(id, forKey: .id); try n.encodeIfPresent(pm, forKey: .providerMetadata) - case .toolCall(let toolCallId, let toolName, let input, let pe, let dyn, let signature, let pm, let inv, let err): + case .toolCall(let toolCallId, let toolName, let input, let pe, let dyn, let pm, let inv, let err): var n = c.nestedContainer(keyedBy: Field.self, forKey: AnyCodingKey("ToolCall")) try n.encode(toolCallId, forKey: .toolCallId); try n.encode(toolName, forKey: .toolName) try n.encode(input, forKey: .input) try n.encodeIfPresent(pe, forKey: .providerExecuted); try n.encodeIfPresent(dyn, forKey: .dynamic) - try n.encodeIfPresent(signature, forKey: .thoughtSignature) try n.encodeIfPresent(pm, forKey: .providerMetadata) try n.encodeIfPresent(inv, forKey: .invalid); try n.encodeIfPresent(err, forKey: .error) case .toolResult(let toolCallId, let toolName, let result, let ie, let prel, let dyn, let pm): diff --git a/bindings/swift/Tests/AimuxTests/ContractTests.swift b/bindings/swift/Tests/AimuxTests/ContractTests.swift deleted file mode 100644 index 463e8d90a..000000000 --- a/bindings/swift/Tests/AimuxTests/ContractTests.swift +++ /dev/null @@ -1,183 +0,0 @@ -// ContractTests.swift — validates the Swift binding against the shared -// wire-format fixtures in `contract-tests/fixtures/wire-format.json`, the same -// file the Rust, Go, Java, Kotlin, Python and Node contract tests read. -// -// Rust is the wire authority: it produces the JSON, the bindings consume it. -// So the direction that matters here is DECODING — every fixture must decode -// into the Swift type it claims to describe. Encoding is checked as a Swift -// round-trip (decode → encode → decode) rather than byte-comparing against the -// fixture, because Swift's synthesized Codable omits nil optionals while Rust -// emits explicit nulls for most fields; a byte comparison would fail on that -// difference alone and say nothing about type correctness. -// -// These are pure serialization tests — no native library is loaded. - -import XCTest -@testable import Aimux - -private struct Fixture: Decodable { - let name: String - let type: String - let json: String -} - -final class ContractTests: XCTestCase { - - // MARK: - fixture loading - - /// Repo root derived from this file's location: - /// /bindings/swift/Tests/AimuxTests/ContractTests.swift - private static var fixtureURL: URL { - URL(fileURLWithPath: #filePath) - .deletingLastPathComponent() // AimuxTests - .deletingLastPathComponent() // Tests - .deletingLastPathComponent() // swift - .deletingLastPathComponent() // bindings - .deletingLastPathComponent() // - .appendingPathComponent("contract-tests/fixtures/wire-format.json") - } - - private func loadFixtures() throws -> [Fixture] { - let url = Self.fixtureURL - guard FileManager.default.fileExists(atPath: url.path) else { - XCTFail("shared fixtures not found at \(url.path)") - return [] - } - return try JSONDecoder().decode([Fixture].self, from: Data(contentsOf: url)) - } - - /// Decode a fixture, re-encode it, and decode again — the encoded form must - /// survive Swift's own round-trip. - private func assertRoundTrips( - _ type: T.Type, _ fixture: Fixture - ) throws { - let data = Data(fixture.json.utf8) - let decoded: T - do { - decoded = try JSONDecoder().decode(T.self, from: data) - } catch { - XCTFail("fixture '\(fixture.name)' (\(fixture.type)) does not decode into \(T.self): \(error)\n json: \(fixture.json)") - return - } - let reencoded = try JSONEncoder().encode(decoded) - let again = try JSONDecoder().decode(T.self, from: reencoded) - XCTAssertEqual(decoded, again, "fixture '\(fixture.name)' does not survive a Swift encode/decode round-trip") - } - - func testTopLevelToolCallProviderMetadataRoundTrips() throws { - let json = #"{"tool_call_id":"call_1","tool_name":"get_weather","input":{"city":"Paris"},"provider_metadata":{"openai":{"itemId":"item_1"}}}"# - let call = try JSONDecoder().decode(ToolCall.self, from: Data(json.utf8)) - XCTAssertEqual(call.providerMetadata?["openai"]?["itemId"]?.stringValue, "item_1") - - let encoded = try JSONEncoder().encode(call) - let decoded = try JSONDecoder().decode(ToolCall.self, from: encoded) - XCTAssertEqual(decoded, call) - } - - func testProviderExecutedToolTranscriptMessageRoundTrips() throws { - let fixtures = try loadFixtures() - guard let fixture = fixtures.first(where: { - $0.name == "model_message_provider_executed_tool_transcript" - }) else { - return XCTFail("provider-executed transcript fixture is missing") - } - let message = try JSONDecoder().decode( - ModelMessage.self, - from: Data(fixture.json.utf8) - ) - guard case .parts(let parts) = message.content else { - return XCTFail("expected multipart assistant message") - } - guard case .toolCall(_, let name, _, let providerExecuted, - let thoughtSignature, _) = parts[0] else { - return XCTFail("expected tool call") - } - XCTAssertEqual(name, "search") - XCTAssertEqual(providerExecuted, true) - XCTAssertEqual(thoughtSignature, "sig_provider") - guard case .toolResult(_, _, let resultName, let isError, - let preliminary, let dynamic, _) = parts[1] else { - return XCTFail("expected tool result") - } - XCTAssertEqual(resultName, "search") - XCTAssertEqual(isError, false) - XCTAssertEqual(preliminary, true) - XCTAssertEqual(dynamic, true) - - let reencoded = try JSONEncoder().encode(message) - XCTAssertEqual(try JSONDecoder().decode(ModelMessage.self, from: reencoded), message) - } - - func testResultToolCallThoughtSignaturesRoundTrip() throws { - let fixtures = try loadFixtures() - guard let fixture = fixtures.first(where: { $0.name == "generate_content_tool_call" }) else { - return XCTFail("generate-content tool-call fixture is missing") - } - let content = try JSONDecoder().decode( - GenerateContent.self, - from: Data(fixture.json.utf8) - ) - guard case .toolCall(_, _, _, _, _, let generateSignature, _) = content else { - return XCTFail("expected generate-content tool call") - } - XCTAssertEqual(generateSignature, "sig_abc") - let contentData = try JSONEncoder().encode(content) - XCTAssertEqual(try JSONDecoder().decode(GenerateContent.self, from: contentData), content) - - let streamJSON = #"{"ToolCall":{"tool_call_id":"stream_1","tool_name":"search","input":{"query":"Rust"},"thought_signature":"sig_stream"}}"# - let part = try JSONDecoder().decode(StreamPart.self, from: Data(streamJSON.utf8)) - guard case .toolCall(_, _, _, _, _, let streamSignature, _, _, _) = part else { - return XCTFail("expected stream tool call") - } - XCTAssertEqual(streamSignature, "sig_stream") - let partData = try JSONEncoder().encode(part) - XCTAssertEqual(try JSONDecoder().decode(StreamPart.self, from: partData), part) - } - - // MARK: - every fixture decodes - - /// A fixture type with no case here fails rather than being skipped: - /// silent skipping is how a net grows holes. - func testEveryFixtureDecodesIntoItsSwiftType() throws { - let fixtures = try loadFixtures() - XCTAssertFalse(fixtures.isEmpty, "no fixtures loaded") - - for fixture in fixtures { - switch fixture.type { - case "ToolChoice": try assertRoundTrips(ToolChoice.self, fixture) - case "StreamPart": try assertRoundTrips(StreamPart.self, fixture) - case "GenerateContent": try assertRoundTrips(GenerateContent.self, fixture) - case "GenerateTextOptions": try assertRoundTrips(GenerateTextOptions.self, fixture) - case "TimeoutConfiguration": try assertRoundTrips(TimeoutConfiguration.self, fixture) - case "Role": try assertRoundTrips(Role.self, fixture) - case "ModelMessage": try assertRoundTrips(ModelMessage.self, fixture) - case "FinishReasonUnified": try assertRoundTrips(FinishReasonUnified.self, fixture) - case "ReasoningEffort": try assertRoundTrips(ReasoningEffort.self, fixture) - default: - XCTFail("fixture '\(fixture.name)' declares type '\(fixture.type)', which has no case in ContractTests — wire it up so the fixture is actually checked against Swift") - } - } - } - - // MARK: - the numeric type probe - - /// `top_k` is fractional in the fixture on purpose: Rust is `Option` - /// (matching the upstream AI SDK's `topK?: number`), so a binding that - /// declares it as an integer cannot hold this value. Kotlin and Java both - /// declared it `Long` until this fixture was added. - func testNumericTypesSurviveWithFullPrecision() throws { - let fixtures = try loadFixtures() - guard let fixture = fixtures.first(where: { $0.name == "generate_text_options_numeric_types" }) else { - XCTFail("fixture 'generate_text_options_numeric_types' is missing") - return - } - let opts = try JSONDecoder().decode(GenerateTextOptions.self, from: Data(fixture.json.utf8)) - XCTAssertEqual(opts.topK, 40.5, "top_k must keep its fraction — an integer type would truncate it to 40") - XCTAssertEqual(opts.frequencyPenalty, -0.5, "penalties must stay signed") - XCTAssertEqual(opts.temperature, 0.7) - XCTAssertEqual(opts.topP, 0.95) - XCTAssertEqual(opts.maxOutputTokens, 256) - XCTAssertEqual(opts.seed, 42) - XCTAssertEqual(opts.maxRetries, 3) - } -} diff --git a/bindings/swift/Tests/AimuxTests/ToolCallRepairTests.swift b/bindings/swift/Tests/AimuxTests/ToolCallRepairTests.swift deleted file mode 100644 index 39f976539..000000000 --- a/bindings/swift/Tests/AimuxTests/ToolCallRepairTests.swift +++ /dev/null @@ -1,206 +0,0 @@ -import Foundation -import XCTest -@testable import Aimux - -final class ToolCallRepairTests: XCTestCase { - func testSharedFixtureCases() throws { - let url = URL(fileURLWithPath: #filePath).deletingLastPathComponent() - .deletingLastPathComponent().deletingLastPathComponent() - .deletingLastPathComponent().deletingLastPathComponent() - .appendingPathComponent("contract-tests/fixtures/tool-call-repair.json") - let document = try JSONSerialization.jsonObject(with: Data(contentsOf: url)) as! [String: Any] - let cases = document["cases"] as! [[String: Any]] - XCTAssertFalse(cases.isEmpty) - - for testCase in cases { - let name = testCase["name"] as! String - let input = testCase["input"] as! [String: Any] - let options = input["opts"] is NSNull ? nil : json(input["opts"]!) - let run: () throws -> String - switch testCase["function"] as? String { - case "tool_call_repair_context": - run = { try toolCallRepairContext(toolCall: json(input["tool_call"]!), - prompt: json(input["prompt"]!), options: options) } - case "apply_tool_call_repair": - run = { try applyToolCallRepair(toolCall: json(input["tool_call"]!), - options: options, reply: json(input["reply"]!)) } - case "apply_tool_call_repair_to_result": - run = { try applyToolCallRepairToResult(result: json(input["result"]!), - options: options, toolCallId: input["tool_call_id"] as! String, - reply: json(input["reply"]!)) } - default: - XCTFail("unknown fixture function in \(name)") - continue - } - if testCase["expected_error"] != nil { - XCTAssertThrowsError(try run(), name) { - guard case .invalidArgument = $0 as? AimuxError else { - return XCTFail("\(name): expected invalidArgument, got \($0)") - } - } - } else { - // `XCTAssertEqual` rethrows, so the throwing call cannot sit in its - // argument. `.fragmentsAllowed`: the no-tools context case answers - // with the bare JSON literal `null`. - let actual = try JSONSerialization.jsonObject(with: Data(run().utf8), options: [.fragmentsAllowed]) - XCTAssertEqual(actual as! NSObject, testCase["expected"] as! NSObject, name) - } - } - } - - func testGenerateRepairsRawInputAndTranscript() throws { - let server = MockHTTPServer(response: .json(invalidResponse)) - try server.start(); defer { server.stop() } - var rawInput: String? - let result = try Model.openai(apiKey: "test", modelId: "gpt-4o", baseUrl: server.baseURL) - .generateText(prompt: .text("weather?"), options: options { context in - rawInput = context.toolCall.input - return repaired(context) - }) - XCTAssertEqual(rawInput, #"{"town":"Singapore"}"#) - XCTAssertEqual(result.toolCalls[0].input["city"]?.stringValue, "Singapore") - let transcript = result.responseMessages.compactMap { message -> JSONValue? in - guard case .parts(let parts) = message.content else { return nil } - return parts.compactMap { if case .toolCall(_, _, let input, _, _, _) = $0 { input } else { nil } }.first - }.first - XCTAssertEqual(transcript?["city"]?.stringValue, "Singapore") - } - - func testGenerateAsOpenAICarriesRepairedArguments() throws { - // A ChatCompletion has no invalid marker: the native result is - // repaired, then converted. - let server = MockHTTPServer(response: .json(invalidResponse)) - try server.start(); defer { server.stop() } - let completion = try Model.openai(apiKey: "test", modelId: "gpt-4o", baseUrl: server.baseURL) - .generateTextAsOpenAI(prompt: .text("weather?"), options: options(repaired)) - let call = completion.choices[0].message.toolCalls?.first - XCTAssertEqual(call?.id, "call-1") - XCTAssertEqual(call?.function.arguments, #"{"city":"Singapore"}"#) - } - - func testStreamRepairsToolCallAndPreservesDeltas() throws { - let parts = try streamParts(responses: [.sse(streamBody)]) { _ in repaired } - XCTAssertEqual(parts.compactMap { if case .toolInputDelta(_, let value, _) = $0 { value } else { nil } }.joined(), - #"{"town":"Singapore"}"#) - let call = parts.compactMap { part -> JSONValue? in - if case .toolCall(_, _, let input, _, _, _, _, let invalid, _) = part, invalid != true { input } else { nil } - }.first - XCTAssertEqual(call?["city"]?.stringValue, "Singapore") - } - - func testStreamRepairBridgesNestedAimuxGenerationOffRawThread() throws { - var nestedError: Error? - let parts = try streamParts(responses: [.sse(streamBody), .json(repairResponse)]) { server in - let model = try! Model.openai(apiKey: "test", modelId: "gpt-4o", baseUrl: server.baseURL) - return { context in - do { - let fixed = try model.generateText(prompt: .text("fix")) - return RawToolCall(toolCallId: context.toolCall.toolCallId, - toolName: context.toolCall.toolName, input: fixed.text) - } catch { nestedError = error; throw error } - } - } - XCTAssertNil(nestedError) - XCTAssertTrue(parts.contains { if case .toolCall(_, _, let input, _, _, _, _, let invalid, _) = $0 { - return invalid != true && input["city"]?.stringValue == "Singapore" - } else { return false } }) - } - - /// The four host-loop branches around the repair closure, which the shared - /// fixture (pure native functions only) never reaches. - func testHostLoopBranchesAroundRepairClosure() throws { - struct RepairUnavailable: LocalizedError { - let errorDescription: String? = "repair model unavailable" - } - var calls = 0 - /// One `generateText` against a fresh server; the counter is re-armed - /// per sub-case, as is the mock (it has no setter for its response). - func run(_ response: [String: Any], tools: Bool = true, - _ repair: @escaping RepairToolCall) throws -> ToolCall { - calls = 0 - let hooked: RepairToolCall = { calls += 1; return try repair($0) } - let server = MockHTTPServer(response: .json(response)) - try server.start(); defer { server.stop() } - return try Model.openai(apiKey: "test", modelId: "gpt-4o", baseUrl: server.baseURL) - .generateText(prompt: .text("weather?"), - options: tools ? options(hooked) - : GenerateTextOptions(repairToolCall: hooked)) - .toolCalls[0] - } - - let thrown = try run(invalidResponse) { _ in throw RepairUnavailable() } - XCTAssertEqual(calls, 1) - XCTAssertEqual(thrown.invalid, true) - XCTAssertEqual(thrown.error?["ToolCallRepair"]?["cause"]?["Other"]?.stringValue, - "repair model unavailable") - XCTAssertNotNil(thrown.error?["ToolCallRepair"]?["original_error"]?["InvalidToolInput"]) - - let unchanged = try run(invalidResponse) { _ in nil } - XCTAssertEqual(calls, 1) - XCTAssertEqual(unchanged.invalid, true) - XCTAssertNil(unchanged.error?["ToolCallRepair"]) - XCTAssertNotNil(unchanged.error?["InvalidToolInput"]) - - let valid = try run(validResponse) { _ in nil } - XCTAssertEqual(calls, 0) - XCTAssertNotEqual(valid.invalid, true) - XCTAssertEqual(valid.input["city"]?.stringValue, "Singapore") - - let untooled = try run(invalidResponse, tools: false) { _ in nil } - XCTAssertEqual(calls, 0) - XCTAssertEqual(untooled.invalid, true) - } -} - -private func streamParts(responses: [MockResponse], - repair: (MockHTTPServer) -> RepairToolCall) throws -> [StreamPart] { - let server = MockHTTPServer(responses: responses) - try server.start(); defer { server.stop() } - var parts: [StreamPart] = []; var failure: Error? - try Model.openai(apiKey: "test", modelId: "gpt-4o", baseUrl: server.baseURL) - .streamText(prompt: .text("weather?"), options: options(repair(server)), - onPart: { parts.append($0) }, onDone: {}, onError: { failure = $0 }) - if let failure { throw failure } - return parts -} - -private func options(_ repair: @escaping RepairToolCall) -> GenerateTextOptions { - let schema = try! JSONDecoder().decode(JSONValue.self, from: Data( - #"{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false}"#.utf8)) - return GenerateTextOptions(tools: [.function(FunctionTool(name: "weather", inputSchema: schema))], - repairToolCall: repair) -} - -private func repaired(_ context: ToolCallRepairContext) -> RawToolCall? { - RawToolCall(toolCallId: context.toolCall.toolCallId, toolName: context.toolCall.toolName, - input: #"{"city":"Singapore"}"#) -} - -private func json(_ value: Any) -> String { - String(data: try! JSONSerialization.data(withJSONObject: value, options: [.fragmentsAllowed]), encoding: .utf8)! -} - -private let invalidResponse: [String: Any] = ["id": "1", "model": "gpt-4o", "choices": [[ - "message": ["role": "assistant", "content": NSNull(), "tool_calls": [["id": "call-1", "type": "function", - "function": ["name": "weather", "arguments": #"{"town":"Singapore"}"#]]]], "finish_reason": "tool_calls"]], - "usage": ["prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2]] - -private let validResponse: [String: Any] = ["id": "1", "model": "gpt-4o", "choices": [[ - "message": ["role": "assistant", "content": NSNull(), "tool_calls": [["id": "call-1", "type": "function", - "function": ["name": "weather", "arguments": #"{"city":"Singapore"}"#]]]], "finish_reason": "tool_calls"]], - "usage": ["prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2]] - -private let repairResponse: [String: Any] = ["id": "2", "model": "gpt-4o", "choices": [[ - "message": ["role": "assistant", "content": #"{"city":"Singapore"}"#], "finish_reason": "stop"]], - "usage": ["prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2]] - -private let streamBody = [ - ["id": "1", "model": "gpt-4o", "choices": [["delta": ["role": "assistant", "tool_calls": [["index": 0, - "id": "call-1", "type": "function", "function": ["name": "weather", "arguments": ""]]]]]]], - ["id": "1", "model": "gpt-4o", "choices": [["delta": ["tool_calls": [["index": 0, - "function": ["arguments": "{\"town\":"]]]]]]], - ["id": "1", "model": "gpt-4o", "choices": [["delta": ["tool_calls": [["index": 0, - "function": ["arguments": "\"Singapore\"}"]]]]]]], - ["id": "1", "model": "gpt-4o", "choices": [["delta": [String: Any](), "finish_reason": "tool_calls"]], - "usage": ["prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2]], -].map { "data: " + json($0) }.joined(separator: "\n\n") + "\n\ndata: [DONE]\n\n" diff --git a/bindings/swift/Tests/AimuxTests/WrapperTests.swift b/bindings/swift/Tests/AimuxTests/WrapperTests.swift index 3241b0309..428579bbf 100644 --- a/bindings/swift/Tests/AimuxTests/WrapperTests.swift +++ b/bindings/swift/Tests/AimuxTests/WrapperTests.swift @@ -89,7 +89,7 @@ final class WrapperTests: XCTestCase { // Structured raw.content contains a ToolCall variant mirroring the call. let toolContents = result.raw.content.compactMap { part -> ToolCall? in - if case .toolCall(let id, let name, let input, _, _, _, _) = part { + if case .toolCall(let id, let name, let input, _, _, _) = part { return ToolCall(toolCallId: id, toolName: name, input: input) } return nil @@ -254,7 +254,7 @@ final class WrapperTests: XCTestCase { // The complete ToolCall part carries the tool name and structured input. let toolCall = parts.compactMap { part -> (String, JSONValue)? in - if case .toolCall(_, let name, let input, _, _, _, _, _, _) = part { return (name, input) } + if case .toolCall(_, let name, let input, _, _, _, _, _) = part { return (name, input) } return nil }.first XCTAssertEqual(toolCall?.0, "get_weather") diff --git a/contract-tests/fixtures/tool-call-repair.json b/contract-tests/fixtures/tool-call-repair.json deleted file mode 100644 index de4711d00..000000000 --- a/contract-tests/fixtures/tool-call-repair.json +++ /dev/null @@ -1,1295 +0,0 @@ -{ - "description": "Host-side tool-call repair (RFC-0035). Every case is pure: feed `input`, expect `expected` (deep-equal) or the named `expected_error` variant. `prompt` and `opts` are the SAME values the call was generated with; a host serializes them to JSON strings before passing them. A `null` `opts` means defaults. An `expected` of null is the literal JSON null the context function writes for a call made without a tool set.", - "cases": [ - { - "name": "context_string_prompt", - "function": "tool_call_repair_context", - "description": "A bare string prompt becomes the single user message the model saw; `instructions` comes from the options, not from the prompt.", - "input": { - "tool_call": { - "tool_call_id": "call-1", - "tool_name": "weather", - "input": { - "town": "Singapore" - }, - "dynamic": true, - "invalid": true, - "error": { - "InvalidToolInput": { - "tool_name": "weather", - "tool_input": "{\"town\":\"Singapore\"}", - "cause": "Type validation failed: Value: {\"town\":\"Singapore\"}.\nError message: Additional properties are not allowed ('town' was unexpected)" - } - } - }, - "prompt": "weather in Singapore?", - "opts": { - "tools": [ - { - "type": "function", - "name": "weather", - "input_schema": { - "type": "object", - "properties": { - "city": { - "type": "string" - } - }, - "required": [ - "city" - ], - "additionalProperties": false - } - } - ], - "instructions": "be terse" - } - }, - "expected": { - "tool_call": { - "tool_call_id": "call-1", - "tool_name": "weather", - "input": "{\"town\":\"Singapore\"}", - "dynamic": true - }, - "error": { - "InvalidToolInput": { - "tool_name": "weather", - "tool_input": "{\"town\":\"Singapore\"}", - "cause": "Type validation failed: Value: {\"town\":\"Singapore\"}.\nError message: Additional properties are not allowed ('town' was unexpected)" - } - }, - "input_schema": { - "type": "object", - "properties": { - "city": { - "type": "string" - } - }, - "required": [ - "city" - ], - "additionalProperties": false - }, - "tools": [ - { - "type": "function", - "name": "weather", - "input_schema": { - "type": "object", - "properties": { - "city": { - "type": "string" - } - }, - "required": [ - "city" - ], - "additionalProperties": false - } - } - ], - "messages": [ - { - "role": "user", - "content": "weather in Singapore?" - } - ], - "instructions": "be terse" - } - }, - { - "name": "context_messages_prompt", - "function": "tool_call_repair_context", - "description": "A messages prompt is passed through as-is.", - "input": { - "tool_call": { - "tool_call_id": "call-1", - "tool_name": "weather", - "input": { - "town": "Singapore" - }, - "dynamic": true, - "invalid": true, - "error": { - "InvalidToolInput": { - "tool_name": "weather", - "tool_input": "{\"town\":\"Singapore\"}", - "cause": "Type validation failed: Value: {\"town\":\"Singapore\"}.\nError message: Additional properties are not allowed ('town' was unexpected)" - } - } - }, - "prompt": [ - { - "role": "user", - "content": "weather in Singapore?" - } - ], - "opts": { - "tools": [ - { - "type": "function", - "name": "weather", - "input_schema": { - "type": "object", - "properties": { - "city": { - "type": "string" - } - }, - "required": [ - "city" - ], - "additionalProperties": false - } - } - ] - } - }, - "expected": { - "tool_call": { - "tool_call_id": "call-1", - "tool_name": "weather", - "input": "{\"town\":\"Singapore\"}", - "dynamic": true - }, - "error": { - "InvalidToolInput": { - "tool_name": "weather", - "tool_input": "{\"town\":\"Singapore\"}", - "cause": "Type validation failed: Value: {\"town\":\"Singapore\"}.\nError message: Additional properties are not allowed ('town' was unexpected)" - } - }, - "input_schema": { - "type": "object", - "properties": { - "city": { - "type": "string" - } - }, - "required": [ - "city" - ], - "additionalProperties": false - }, - "tools": [ - { - "type": "function", - "name": "weather", - "input_schema": { - "type": "object", - "properties": { - "city": { - "type": "string" - } - }, - "required": [ - "city" - ], - "additionalProperties": false - } - } - ], - "messages": [ - { - "role": "user", - "content": "weather in Singapore?" - } - ], - "instructions": null - } - }, - { - "name": "context_prompt_wrapper", - "function": "tool_call_repair_context", - "description": "The single-key {\"prompt\": …} wrapper is accepted wherever a prompt is, by the C ABI and by node and python alike — all three share one parse_prompt.", - "input": { - "tool_call": { - "tool_call_id": "call-1", - "tool_name": "weather", - "input": { - "town": "Singapore" - }, - "dynamic": true, - "invalid": true, - "error": { - "InvalidToolInput": { - "tool_name": "weather", - "tool_input": "{\"town\":\"Singapore\"}", - "cause": "Type validation failed: Value: {\"town\":\"Singapore\"}.\nError message: Additional properties are not allowed ('town' was unexpected)" - } - } - }, - "prompt": { - "prompt": "weather in Singapore?" - }, - "opts": { - "tools": [ - { - "type": "function", - "name": "weather", - "input_schema": { - "type": "object", - "properties": { - "city": { - "type": "string" - } - }, - "required": [ - "city" - ], - "additionalProperties": false - } - } - ], - "instructions": "be terse" - } - }, - "expected": { - "tool_call": { - "tool_call_id": "call-1", - "tool_name": "weather", - "input": "{\"town\":\"Singapore\"}", - "dynamic": true - }, - "error": { - "InvalidToolInput": { - "tool_name": "weather", - "tool_input": "{\"town\":\"Singapore\"}", - "cause": "Type validation failed: Value: {\"town\":\"Singapore\"}.\nError message: Additional properties are not allowed ('town' was unexpected)" - } - }, - "input_schema": { - "type": "object", - "properties": { - "city": { - "type": "string" - } - }, - "required": [ - "city" - ], - "additionalProperties": false - }, - "tools": [ - { - "type": "function", - "name": "weather", - "input_schema": { - "type": "object", - "properties": { - "city": { - "type": "string" - } - }, - "required": [ - "city" - ], - "additionalProperties": false - } - } - ], - "messages": [ - { - "role": "user", - "content": "weather in Singapore?" - } - ], - "instructions": "be terse" - } - }, - { - "name": "context_without_tools", - "function": "tool_call_repair_context", - "description": "Options with no tool set yield JSON null, NOT an error: the AI SDK never repairs such a call, so the host skips it.", - "input": { - "tool_call": { - "tool_call_id": "call-1", - "tool_name": "weather", - "input": { - "town": "Singapore" - }, - "dynamic": true, - "invalid": true, - "error": { - "InvalidToolInput": { - "tool_name": "weather", - "tool_input": "{\"town\":\"Singapore\"}", - "cause": "Type validation failed: Value: {\"town\":\"Singapore\"}.\nError message: Additional properties are not allowed ('town' was unexpected)" - } - } - }, - "prompt": "weather in Singapore?", - "opts": { - "instructions": "be terse" - } - }, - "expected": null - }, - { - "name": "context_null_opts", - "function": "tool_call_repair_context", - "description": "Absent options (null / empty string / omitted) are defaults, which carry no tools — so also null.", - "input": { - "tool_call": { - "tool_call_id": "call-1", - "tool_name": "weather", - "input": { - "town": "Singapore" - }, - "dynamic": true, - "invalid": true, - "error": { - "InvalidToolInput": { - "tool_name": "weather", - "tool_input": "{\"town\":\"Singapore\"}", - "cause": "Type validation failed: Value: {\"town\":\"Singapore\"}.\nError message: Additional properties are not allowed ('town' was unexpected)" - } - } - }, - "prompt": "weather in Singapore?", - "opts": null - }, - "expected": null - }, - { - "name": "reply_repaired_validates", - "function": "apply_tool_call_repair", - "description": "A replacement that satisfies the schema yields a valid tool call: no `invalid`, no `error`, no `dynamic`.", - "input": { - "tool_call": { - "tool_call_id": "call-1", - "tool_name": "weather", - "input": { - "town": "Singapore" - }, - "dynamic": true, - "invalid": true, - "error": { - "InvalidToolInput": { - "tool_name": "weather", - "tool_input": "{\"town\":\"Singapore\"}", - "cause": "Type validation failed: Value: {\"town\":\"Singapore\"}.\nError message: Additional properties are not allowed ('town' was unexpected)" - } - } - }, - "opts": { - "tools": [ - { - "type": "function", - "name": "weather", - "input_schema": { - "type": "object", - "properties": { - "city": { - "type": "string" - } - }, - "required": [ - "city" - ], - "additionalProperties": false - } - } - ], - "instructions": "be terse" - }, - "reply": { - "type": "repaired", - "tool_call": { - "tool_call_id": "call-1", - "tool_name": "weather", - "input": "{\"city\":\"Singapore\"}" - } - } - }, - "expected": { - "tool_call_id": "call-1", - "tool_name": "weather", - "input": { - "city": "Singapore" - } - } - }, - { - "name": "reply_repaired_still_invalid", - "function": "apply_tool_call_repair", - "description": "A replacement that still fails validation keeps the ORIGINAL call and reports ToolCallRepair{original_error, cause}.", - "input": { - "tool_call": { - "tool_call_id": "call-1", - "tool_name": "weather", - "input": { - "town": "Singapore" - }, - "dynamic": true, - "invalid": true, - "error": { - "InvalidToolInput": { - "tool_name": "weather", - "tool_input": "{\"town\":\"Singapore\"}", - "cause": "Type validation failed: Value: {\"town\":\"Singapore\"}.\nError message: Additional properties are not allowed ('town' was unexpected)" - } - } - }, - "opts": { - "tools": [ - { - "type": "function", - "name": "weather", - "input_schema": { - "type": "object", - "properties": { - "city": { - "type": "string" - } - }, - "required": [ - "city" - ], - "additionalProperties": false - } - } - ], - "instructions": "be terse" - }, - "reply": { - "type": "repaired", - "tool_call": { - "tool_call_id": "call-1", - "tool_name": "weather", - "input": "{\"city\":42}" - } - } - }, - "expected": { - "tool_call_id": "call-1", - "tool_name": "weather", - "input": { - "town": "Singapore" - }, - "dynamic": true, - "invalid": true, - "error": { - "ToolCallRepair": { - "original_error": { - "InvalidToolInput": { - "tool_name": "weather", - "tool_input": "{\"town\":\"Singapore\"}", - "cause": "Type validation failed: Value: {\"town\":\"Singapore\"}.\nError message: Additional properties are not allowed ('town' was unexpected)" - } - }, - "cause": { - "InvalidToolInput": { - "tool_name": "weather", - "tool_input": "{\"city\":42}", - "cause": "Type validation failed: Value: {\"city\":42}.\nError message: 42 is not of type \"string\"" - } - } - } - } - } - }, - { - "name": "reply_unchanged", - "function": "apply_tool_call_repair", - "description": "`unchanged` reproduces the input call byte for byte, original error included.", - "input": { - "tool_call": { - "tool_call_id": "call-1", - "tool_name": "weather", - "input": { - "town": "Singapore" - }, - "dynamic": true, - "invalid": true, - "error": { - "InvalidToolInput": { - "tool_name": "weather", - "tool_input": "{\"town\":\"Singapore\"}", - "cause": "Type validation failed: Value: {\"town\":\"Singapore\"}.\nError message: Additional properties are not allowed ('town' was unexpected)" - } - } - }, - "opts": { - "tools": [ - { - "type": "function", - "name": "weather", - "input_schema": { - "type": "object", - "properties": { - "city": { - "type": "string" - } - }, - "required": [ - "city" - ], - "additionalProperties": false - } - } - ], - "instructions": "be terse" - }, - "reply": { - "type": "unchanged" - } - }, - "expected": { - "tool_call_id": "call-1", - "tool_name": "weather", - "input": { - "town": "Singapore" - }, - "dynamic": true, - "invalid": true, - "error": { - "InvalidToolInput": { - "tool_name": "weather", - "tool_input": "{\"town\":\"Singapore\"}", - "cause": "Type validation failed: Value: {\"town\":\"Singapore\"}.\nError message: Additional properties are not allowed ('town' was unexpected)" - } - } - } - }, - { - "name": "reply_failed", - "function": "apply_tool_call_repair", - "description": "A host-side failure becomes ToolCallRepair{cause: Other(message)} — a host error has no typed counterpart in core.", - "input": { - "tool_call": { - "tool_call_id": "call-1", - "tool_name": "weather", - "input": { - "town": "Singapore" - }, - "dynamic": true, - "invalid": true, - "error": { - "InvalidToolInput": { - "tool_name": "weather", - "tool_input": "{\"town\":\"Singapore\"}", - "cause": "Type validation failed: Value: {\"town\":\"Singapore\"}.\nError message: Additional properties are not allowed ('town' was unexpected)" - } - } - }, - "opts": { - "tools": [ - { - "type": "function", - "name": "weather", - "input_schema": { - "type": "object", - "properties": { - "city": { - "type": "string" - } - }, - "required": [ - "city" - ], - "additionalProperties": false - } - } - ], - "instructions": "be terse" - }, - "reply": { - "type": "failed", - "message": "repair model unavailable" - } - }, - "expected": { - "tool_call_id": "call-1", - "tool_name": "weather", - "input": { - "town": "Singapore" - }, - "dynamic": true, - "invalid": true, - "error": { - "ToolCallRepair": { - "original_error": { - "InvalidToolInput": { - "tool_name": "weather", - "tool_input": "{\"town\":\"Singapore\"}", - "cause": "Type validation failed: Value: {\"town\":\"Singapore\"}.\nError message: Additional properties are not allowed ('town' was unexpected)" - } - }, - "cause": { - "Other": "repair model unavailable" - } - } - } - } - }, - { - "name": "apply_without_tools", - "function": "apply_tool_call_repair", - "description": "Applying a reply to a call made without a tool set is a caller error — the context call already answered null for it.", - "input": { - "tool_call": { - "tool_call_id": "call-1", - "tool_name": "weather", - "input": { - "town": "Singapore" - }, - "dynamic": true, - "invalid": true, - "error": { - "InvalidToolInput": { - "tool_name": "weather", - "tool_input": "{\"town\":\"Singapore\"}", - "cause": "Type validation failed: Value: {\"town\":\"Singapore\"}.\nError message: Additional properties are not allowed ('town' was unexpected)" - } - } - }, - "opts": { - "instructions": "be terse" - }, - "reply": { - "type": "unchanged" - } - }, - "expected_error": "InvalidArgument" - }, - { - "name": "reject_valid_tool_call", - "function": "apply_tool_call_repair", - "description": "Repairing a call that is not invalid is a caller error, not a no-op.", - "input": { - "tool_call": { - "tool_call_id": "call-1", - "tool_name": "weather", - "input": { - "city": "Singapore" - } - }, - "opts": { - "tools": [ - { - "type": "function", - "name": "weather", - "input_schema": { - "type": "object", - "properties": { - "city": { - "type": "string" - } - }, - "required": [ - "city" - ], - "additionalProperties": false - } - } - ], - "instructions": "be terse" - }, - "reply": { - "type": "unchanged" - } - }, - "expected_error": "InvalidArgument" - }, - { - "name": "patch_result", - "function": "apply_tool_call_repair_to_result", - "description": "Both `tool_calls` and the matching `response_messages` tool-call part are rewritten; everything else is untouched.", - "input": { - "result": { - "text": "", - "tool_calls": [ - { - "tool_call_id": "call-1", - "tool_name": "weather", - "input": { - "town": "Singapore" - }, - "dynamic": true, - "invalid": true, - "error": { - "InvalidToolInput": { - "tool_name": "weather", - "tool_input": "{\"town\":\"Singapore\"}", - "cause": "Type validation failed: Value: {\"town\":\"Singapore\"}.\nError message: Additional properties are not allowed ('town' was unexpected)" - } - } - } - ], - "response_messages": [ - { - "role": "assistant", - "content": [ - { - "type": "text", - "text": "checking" - }, - { - "type": "tool_call", - "tool_call_id": "call-1", - "tool_name": "weather", - "input": { - "town": "Singapore" - } - } - ] - } - ] - }, - "opts": { - "tools": [ - { - "type": "function", - "name": "weather", - "input_schema": { - "type": "object", - "properties": { - "city": { - "type": "string" - } - }, - "required": [ - "city" - ], - "additionalProperties": false - } - } - ], - "instructions": "be terse" - }, - "tool_call_id": "call-1", - "reply": { - "type": "repaired", - "tool_call": { - "tool_call_id": "call-1", - "tool_name": "weather", - "input": "{\"city\":\"Singapore\"}" - } - } - }, - "expected": { - "text": "", - "tool_calls": [ - { - "tool_call_id": "call-1", - "tool_name": "weather", - "input": { - "city": "Singapore" - } - } - ], - "response_messages": [ - { - "role": "assistant", - "content": [ - { - "type": "text", - "text": "checking" - }, - { - "type": "tool_call", - "tool_call_id": "call-1", - "tool_name": "weather", - "input": { - "city": "Singapore" - } - } - ] - } - ] - } - }, - { - "name": "patch_result_renamed_call", - "function": "apply_tool_call_repair_to_result", - "description": "A repair may hand back a different tool_call_id; the transcript part must follow it, or the replayed message points at a call that is no longer there.", - "input": { - "result": { - "text": "", - "tool_calls": [ - { - "tool_call_id": "call-1", - "tool_name": "weather", - "input": { - "town": "Singapore" - }, - "dynamic": true, - "invalid": true, - "error": { - "InvalidToolInput": { - "tool_name": "weather", - "tool_input": "{\"town\":\"Singapore\"}", - "cause": "Type validation failed: Value: {\"town\":\"Singapore\"}.\nError message: Additional properties are not allowed ('town' was unexpected)" - } - } - } - ], - "response_messages": [ - { - "role": "assistant", - "content": [ - { - "type": "text", - "text": "checking" - }, - { - "type": "tool_call", - "tool_call_id": "call-1", - "tool_name": "weather", - "input": { - "town": "Singapore" - } - } - ] - } - ] - }, - "opts": { - "tools": [ - { - "type": "function", - "name": "weather", - "input_schema": { - "type": "object", - "properties": { - "city": { - "type": "string" - } - }, - "required": [ - "city" - ], - "additionalProperties": false - } - } - ], - "instructions": "be terse" - }, - "tool_call_id": "call-1", - "reply": { - "type": "repaired", - "tool_call": { - "tool_call_id": "call-2", - "tool_name": "weather", - "input": "{\"city\":\"Singapore\"}" - } - } - }, - "expected": { - "text": "", - "tool_calls": [ - { - "tool_call_id": "call-2", - "tool_name": "weather", - "input": { - "city": "Singapore" - } - } - ], - "response_messages": [ - { - "role": "assistant", - "content": [ - { - "type": "text", - "text": "checking" - }, - { - "type": "tool_call", - "tool_call_id": "call-2", - "tool_name": "weather", - "input": { - "city": "Singapore" - } - } - ] - } - ] - } - }, - { - "name": "patch_result_unknown_id", - "function": "apply_tool_call_repair_to_result", - "description": "A tool_call_id the document does not carry is an error, never a silent no-op.", - "input": { - "result": { - "text": "", - "tool_calls": [ - { - "tool_call_id": "call-1", - "tool_name": "weather", - "input": { - "town": "Singapore" - }, - "dynamic": true, - "invalid": true, - "error": { - "InvalidToolInput": { - "tool_name": "weather", - "tool_input": "{\"town\":\"Singapore\"}", - "cause": "Type validation failed: Value: {\"town\":\"Singapore\"}.\nError message: Additional properties are not allowed ('town' was unexpected)" - } - } - } - ], - "response_messages": [ - { - "role": "assistant", - "content": [ - { - "type": "text", - "text": "checking" - }, - { - "type": "tool_call", - "tool_call_id": "call-1", - "tool_name": "weather", - "input": { - "town": "Singapore" - } - } - ] - } - ] - }, - "opts": { - "tools": [ - { - "type": "function", - "name": "weather", - "input_schema": { - "type": "object", - "properties": { - "city": { - "type": "string" - } - }, - "required": [ - "city" - ], - "additionalProperties": false - } - } - ], - "instructions": "be terse" - }, - "tool_call_id": "call-9", - "reply": { - "type": "unchanged" - } - }, - "expected_error": "InvalidArgument" - }, - { - "name": "patch_result_generate_object", - "function": "apply_tool_call_repair_to_result", - "description": "A GenerateObjectResult carries the text result under `raw`; the patch reaches into it and leaves `object` alone.", - "input": { - "result": { - "object": { - "ok": true - }, - "finish_reason": { - "unified": "tool-calls", - "raw": null - }, - "raw": { - "text": "", - "tool_calls": [ - { - "tool_call_id": "call-1", - "tool_name": "weather", - "input": { - "town": "Singapore" - }, - "dynamic": true, - "invalid": true, - "error": { - "InvalidToolInput": { - "tool_name": "weather", - "tool_input": "{\"town\":\"Singapore\"}", - "cause": "Type validation failed: Value: {\"town\":\"Singapore\"}.\nError message: Additional properties are not allowed ('town' was unexpected)" - } - } - } - ], - "response_messages": [ - { - "role": "assistant", - "content": [ - { - "type": "text", - "text": "checking" - }, - { - "type": "tool_call", - "tool_call_id": "call-1", - "tool_name": "weather", - "input": { - "town": "Singapore" - } - } - ] - } - ] - } - }, - "opts": { - "tools": [ - { - "type": "function", - "name": "weather", - "input_schema": { - "type": "object", - "properties": { - "city": { - "type": "string" - } - }, - "required": [ - "city" - ], - "additionalProperties": false - } - } - ], - "instructions": "be terse" - }, - "tool_call_id": "call-1", - "reply": { - "type": "repaired", - "tool_call": { - "tool_call_id": "call-1", - "tool_name": "weather", - "input": "{\"city\":\"Singapore\"}" - } - } - }, - "expected": { - "object": { - "ok": true - }, - "finish_reason": { - "unified": "tool-calls", - "raw": null - }, - "raw": { - "text": "", - "tool_calls": [ - { - "tool_call_id": "call-1", - "tool_name": "weather", - "input": { - "city": "Singapore" - } - } - ], - "response_messages": [ - { - "role": "assistant", - "content": [ - { - "type": "text", - "text": "checking" - }, - { - "type": "tool_call", - "tool_call_id": "call-1", - "tool_name": "weather", - "input": { - "city": "Singapore" - } - } - ] - } - ] - } - } - }, - { - "name": "patch_result_duplicated_id", - "function": "apply_tool_call_repair_to_result", - "description": "Two tool calls sharing the target id make the patch ambiguous: an error, never a guess.", - "input": { - "result": { - "text": "", - "tool_calls": [ - { - "tool_call_id": "call-1", - "tool_name": "weather", - "input": { - "town": "Singapore" - }, - "dynamic": true, - "invalid": true, - "error": { - "InvalidToolInput": { - "tool_name": "weather", - "tool_input": "{\"town\":\"Singapore\"}", - "cause": "Type validation failed: Value: {\"town\":\"Singapore\"}.\nError message: Additional properties are not allowed ('town' was unexpected)" - } - } - }, - { - "tool_call_id": "call-1", - "tool_name": "weather", - "input": { - "town": "Singapore" - }, - "dynamic": true, - "invalid": true, - "error": { - "InvalidToolInput": { - "tool_name": "weather", - "tool_input": "{\"town\":\"Singapore\"}", - "cause": "Type validation failed: Value: {\"town\":\"Singapore\"}.\nError message: Additional properties are not allowed ('town' was unexpected)" - } - } - } - ], - "response_messages": [ - { - "role": "assistant", - "content": [ - { - "type": "text", - "text": "checking" - }, - { - "type": "tool_call", - "tool_call_id": "call-1", - "tool_name": "weather", - "input": { - "town": "Singapore" - } - } - ] - } - ] - }, - "opts": { - "tools": [ - { - "type": "function", - "name": "weather", - "input_schema": { - "type": "object", - "properties": { - "city": { - "type": "string" - } - }, - "required": [ - "city" - ], - "additionalProperties": false - } - } - ], - "instructions": "be terse" - }, - "tool_call_id": "call-1", - "reply": { - "type": "unchanged" - } - }, - "expected_error": "InvalidArgument" - }, - { - "name": "patch_result_renamed_onto_existing_id", - "function": "apply_tool_call_repair_to_result", - "description": "A repair may rename the call, but not onto an id another call in the document already has.", - "input": { - "result": { - "text": "", - "tool_calls": [ - { - "tool_call_id": "call-1", - "tool_name": "weather", - "input": { - "town": "Singapore" - }, - "dynamic": true, - "invalid": true, - "error": { - "InvalidToolInput": { - "tool_name": "weather", - "tool_input": "{\"town\":\"Singapore\"}", - "cause": "Type validation failed: Value: {\"town\":\"Singapore\"}.\nError message: Additional properties are not allowed ('town' was unexpected)" - } - } - }, - { - "tool_call_id": "call-2", - "tool_name": "weather", - "input": { - "town": "Singapore" - }, - "dynamic": true, - "invalid": true, - "error": { - "InvalidToolInput": { - "tool_name": "weather", - "tool_input": "{\"town\":\"Singapore\"}", - "cause": "Type validation failed: Value: {\"town\":\"Singapore\"}.\nError message: Additional properties are not allowed ('town' was unexpected)" - } - } - } - ], - "response_messages": [ - { - "role": "assistant", - "content": [ - { - "type": "text", - "text": "checking" - }, - { - "type": "tool_call", - "tool_call_id": "call-1", - "tool_name": "weather", - "input": { - "town": "Singapore" - } - } - ] - } - ] - }, - "opts": { - "tools": [ - { - "type": "function", - "name": "weather", - "input_schema": { - "type": "object", - "properties": { - "city": { - "type": "string" - } - }, - "required": [ - "city" - ], - "additionalProperties": false - } - } - ], - "instructions": "be terse" - }, - "tool_call_id": "call-1", - "reply": { - "type": "repaired", - "tool_call": { - "tool_call_id": "call-2", - "tool_name": "weather", - "input": "{\"city\":\"Singapore\"}" - } - } - }, - "expected_error": "InvalidArgument" - } - ] -} diff --git a/contract-tests/fixtures/wire-format.json b/contract-tests/fixtures/wire-format.json deleted file mode 100644 index b6f414055..000000000 --- a/contract-tests/fixtures/wire-format.json +++ /dev/null @@ -1,170 +0,0 @@ -[ - { - "name": "tool_choice_auto", - "type": "ToolChoice", - "rust_value": "Auto", - "json": "\"auto\"", - "description": "Auto tool choice serializes as bare string" - }, - { - "name": "tool_choice_none", - "type": "ToolChoice", - "rust_value": "None", - "json": "\"none\"", - "description": "None tool choice serializes as bare string" - }, - { - "name": "tool_choice_required", - "type": "ToolChoice", - "rust_value": "Required", - "json": "\"required\"", - "description": "Required tool choice serializes as bare string" - }, - { - "name": "tool_choice_tool", - "type": "ToolChoice", - "rust_value": "Tool { tool_name: \"get_weather\" }", - "json": "{\"type\":\"tool\",\"toolName\":\"get_weather\"}", - "description": "Tool variant serializes as tagged object with camelCase field" - }, - { - "name": "stream_part_text_delta", - "type": "StreamPart", - "json": "{\"TextDelta\":{\"id\":\"tx1\",\"delta\":\"Hello\"}}", - "description": "TextDelta stream part" - }, - { - "name": "stream_part_finish", - "type": "StreamPart", - "json": "{\"Finish\":{\"finish_reason\":{\"unified\":\"stop\",\"raw\":null},\"usage\":{\"input_tokens\":{\"total\":10},\"output_tokens\":{\"total\":20}},\"provider_metadata\":null}}", - "description": "Finish stream part with usage. `unified` is the bare string \"stop\" — the same shape the finish_reason_unified_stop fixture pins — not an externally-tagged object; Rust never emits the object form." - }, - { - "name": "stream_part_stream_start", - "type": "StreamPart", - "json": "{\"StreamStart\":{\"warnings\":[]}}", - "description": "StreamStart with empty warnings" - }, - { - "name": "stream_part_raw", - "type": "StreamPart", - "rust_value": "", - "json": "{\"Raw\":{\"raw_value\":{\"id\":\"c1\",\"choices\":[]}}}", - "description": "Raw stream part carrying the parsed JSON payload of a provider SSE event (RFC-0016 M2; emitted before parsed parts, excludes [DONE] sentinel)" - }, - { - "name": "generate_text_options_default", - "type": "GenerateTextOptions", - "json": "{\"max_output_tokens\":null,\"temperature\":null,\"stop_sequences\":null,\"top_p\":null,\"top_k\":null,\"presence_penalty\":null,\"frequency_penalty\":null,\"response_format\":null,\"seed\":null,\"tools\":null,\"tool_choice\":null,\"headers\":null,\"provider_options\":null,\"reasoning\":null,\"instructions\":null,\"body_overrides\":null,\"max_retries\":null,\"timeout\":null,\"include_raw_chunks\":null}", - "description": "Default GenerateTextOptions with all fields null" - }, - { - "name": "generate_text_options_with_session_id", - "type": "GenerateTextOptions", - "json": "{\"max_output_tokens\":null,\"temperature\":null,\"stop_sequences\":null,\"top_p\":null,\"top_k\":null,\"presence_penalty\":null,\"frequency_penalty\":null,\"response_format\":null,\"seed\":null,\"tools\":null,\"tool_choice\":null,\"headers\":null,\"provider_options\":null,\"reasoning\":null,\"instructions\":null,\"body_overrides\":null,\"max_retries\":null,\"timeout\":null,\"session_id\":\"sess-1\",\"include_raw_chunks\":null}", - "description": "GenerateTextOptions with explicit session_id (RFC-0024)" - }, - { - "name": "generate_text_options_include_raw_chunks_true", - "type": "GenerateTextOptions", - "rust_value": "", - "json": "{\"max_output_tokens\":null,\"temperature\":null,\"stop_sequences\":null,\"top_p\":null,\"top_k\":null,\"presence_penalty\":null,\"frequency_penalty\":null,\"response_format\":null,\"seed\":null,\"tools\":null,\"tool_choice\":null,\"headers\":null,\"provider_options\":null,\"reasoning\":null,\"instructions\":null,\"body_overrides\":null,\"max_retries\":null,\"timeout\":null,\"include_raw_chunks\":true}", - "description": "GenerateTextOptions with include_raw_chunks=true (RFC-0016 M2 true-case)" - }, - { - "name": "generate_text_options_numeric_types", - "type": "GenerateTextOptions", - "json": "{\"max_output_tokens\":256,\"temperature\":0.7,\"stop_sequences\":null,\"top_p\":0.95,\"top_k\":40.5,\"presence_penalty\":0.5,\"frequency_penalty\":-0.5,\"response_format\":null,\"seed\":42,\"tools\":null,\"tool_choice\":null,\"headers\":null,\"provider_options\":null,\"reasoning\":null,\"instructions\":null,\"body_overrides\":null,\"max_retries\":3,\"timeout\":null,\"include_raw_chunks\":null}", - "description": "Numeric fields carry non-null, type-discriminating values so every binding's declared types are exercised. top_k is deliberately FRACTIONAL: Rust is Option (matching the upstream AI SDK's `topK?: number`), so a binding that declares it as an integer cannot hold this value. Integer fields (max_output_tokens u32, seed u64, max_retries u32) and a negative penalty pin the signedness. This case exists to lock TYPES, not to model a realistic call." - }, - { - "name": "generate_content_text", - "type": "GenerateContent", - "json": "{\"Text\":{\"text\":\"Hello\",\"provider_metadata\":{\"openai\":{\"itemId\":\"msg_1\"}}}}", - "description": "Text content with provider_metadata present. GenerateContent is externally tagged, so every binding sees a single-key object whose key is the variant name." - }, - { - "name": "generate_content_text_no_metadata", - "type": "GenerateContent", - "json": "{\"Text\":{\"text\":\"Hello\"}}", - "description": "Text without provider_metadata: the key is ABSENT, not null (the field is skip_serializing_if = Option::is_none). Pins the omission so a binding that expects a required field is caught." - }, - { - "name": "generate_content_reasoning_no_metadata", - "type": "GenerateContent", - "json": "{\"Reasoning\":{\"text\":\"Thinking\",\"provider_metadata\":null}}", - "description": "KNOWN ASYMMETRY, pinned deliberately: Reasoning.provider_metadata has NO skip_serializing_if, so unlike Text (see generate_content_text_no_metadata) it emits an explicit null. The two neighbouring fixtures therefore disagree on purpose — that is the drift, recorded rather than hidden. The ticket that makes the attributes symmetric must update this fixture in the same commit; if you are here because this case went red, that is the sentinel working, not a broken test." - }, - { - "name": "generate_content_tool_call", - "type": "GenerateContent", - "json": "{\"ToolCall\":{\"tool_call_id\":\"call_1\",\"tool_name\":\"get_weather\",\"input\":\"{\\\"city\\\":\\\"Paris\\\"}\",\"provider_executed\":true,\"thought_signature\":\"sig_abc\"}}", - "description": "Raw provider tool call with provider_executed and thought_signature set; dynamic and provider_metadata unset and therefore absent. `input` is the provider's serialized JSON argument text; Core parses it before exposing the top-level ToolCall." - }, - { - "name": "generate_content_source", - "type": "GenerateContent", - "json": "{\"Source\":{\"id\":\"src_1\",\"source_type\":\"url\",\"url\":\"https://example.com/a\",\"title\":\"Example\"}}", - "description": "Source/citation. Note url and title carry no skip_serializing_if, so they are always emitted (null when unset) — unlike provider_metadata, which is omitted when unset." - }, - { - "name": "generate_content_source_unset_optionals", - "type": "GenerateContent", - "json": "{\"Source\":{\"id\":\"src_2\",\"source_type\":\"document\",\"url\":null,\"title\":null}}", - "description": "Source with url and title unset. They carry no skip_serializing_if, so they must appear as explicit nulls — while provider_metadata, which does carry it, stays absent in the same object. Pinning the unset form is what makes that difference load-bearing: without this case, adding skip_serializing_if to url/title would silently turn null into an omitted key and no fixture would notice." - }, - { - "name": "generate_content_file", - "type": "GenerateContent", - "json": "{\"File\":{\"data\":{\"Data\":{\"data\":{\"Base64\":\"aGk=\"}}},\"media_type\":\"image/png\"}}", - "description": "Generated file. `data` is a doubly-nested externally-tagged union (FileData::Data wrapping FileBytes::Base64) — the shape every binding must reproduce, and the one the four-file-variant merge will collapse." - }, - { - "name": "generate_content_tool_result", - "type": "GenerateContent", - "json": "{\"ToolResult\":{\"tool_call_id\":\"call_1\",\"tool_name\":\"web_search\",\"result\":{\"answer\":42},\"is_error\":false}}", - "description": "Provider-executed tool result. is_error is false (present); preliminary, dynamic and provider_metadata are unset and absent." - }, - { - "name": "timeout_configuration_values", - "type": "TimeoutConfiguration", - "json": "{\"total_ms\":5000,\"step_ms\":null,\"first_chunk_ms\":1000,\"chunk_ms\":500}", - "description": "TimeoutConfiguration with all three limits set (wire shape lock, RFC-0016 H3)" - }, - { - "name": "role_user", - "type": "Role", - "json": "\"user\"", - "description": "User role serializes as lowercase string" - }, - { - "name": "role_system", - "type": "Role", - "json": "\"system\"", - "description": "System role serializes as lowercase string" - }, - { - "name": "model_message_text", - "type": "ModelMessage", - "json": "{\"role\":\"user\",\"content\":\"Hello\"}", - "description": "ModelMessage with text content" - }, - { - "name": "model_message_provider_executed_tool_transcript", - "type": "ModelMessage", - "json": "{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_call\",\"tool_call_id\":\"srv_1\",\"tool_name\":\"search\",\"input\":{\"query\":\"Rust\"},\"provider_executed\":true,\"thought_signature\":\"sig_provider\",\"provider_options\":{\"anthropic\":{\"type\":\"mcp-tool-use\",\"serverName\":\"docs\"}}},{\"type\":\"tool_result\",\"tool_call_id\":\"srv_1\",\"result\":{\"answer\":42},\"tool_name\":\"search\",\"is_error\":false,\"preliminary\":true,\"dynamic\":true,\"provider_options\":{\"anthropic\":{\"resultId\":\"result_1\"}}}]}", - "description": "Provider-executed tool call and result stay together in an assistant message for replay. ToolCall carries provider_executed (but no dynamic field in the prompt contract); ToolResult carries replay metadata." - }, - { - "name": "finish_reason_unified_stop", - "type": "FinishReasonUnified", - "json": "\"stop\"", - "description": "FinishReasonUnified stop variant" - }, - { - "name": "reasoning_effort_high", - "type": "ReasoningEffort", - "json": "\"high\"", - "description": "ReasoningEffort high variant" - } -] diff --git a/contract-tests/run-node.ts b/contract-tests/run-node.ts deleted file mode 100644 index 19ed553ca..000000000 --- a/contract-tests/run-node.ts +++ /dev/null @@ -1,192 +0,0 @@ -// Node.js contract test runner — validates wire format consistency. -// -// Uses the same JSON fixtures as the Rust contract tests. -// Run: node --experimental-strip-types run-node.ts - -import { readFileSync } from 'node:fs' -import { join, dirname } from 'node:path' -import { fileURLToPath } from 'node:url' - -const __dirname = dirname(fileURLToPath(import.meta.url)) -const fixturesPath = join(__dirname, 'fixtures', 'wire-format.json') -const fixtures = JSON.parse(readFileSync(fixturesPath, 'utf-8')) - -let passed = 0 -let failed = 0 - -function assert(condition: boolean, name: string, message?: string) { - if (condition) { - passed++ - // console.log(` ✔ ${name}`) - } else { - failed++ - console.error(` ✗ ${name}${message ? ': ' + message : ''}`) - } -} - -// ── ToolChoice wire format ───────────────────────────────────────────────── - -function testToolChoice() { - console.log('ToolChoice:') - - // The native module returns JSON strings; we verify the wire format - // by checking that the expected JSON parses correctly. - for (const f of fixtures.filter((f: any) => f.type === 'ToolChoice')) { - const parsed = JSON.parse(f.json) - if (f.name === 'tool_choice_auto') { - assert(parsed === 'auto', f.name, `expected "auto", got ${JSON.stringify(parsed)}`) - } else if (f.name === 'tool_choice_none') { - assert(parsed === 'none', f.name, `expected "none", got ${JSON.stringify(parsed)}`) - } else if (f.name === 'tool_choice_required') { - assert(parsed === 'required', f.name, `expected "required", got ${JSON.stringify(parsed)}`) - } else if (f.name === 'tool_choice_tool') { - assert(parsed.type === 'tool' && parsed.toolName === 'get_weather', f.name, - `expected {type:"tool",toolName:"get_weather"}, got ${JSON.stringify(parsed)}`) - } - } -} - -// ── StreamPart wire format ───────────────────────────────────────────────── - -function testStreamPart() { - console.log('StreamPart:') - - for (const f of fixtures.filter((f: any) => f.type === 'StreamPart')) { - const parsed = JSON.parse(f.json) - const key = Object.keys(parsed)[0] - - if (f.name === 'stream_part_text_delta') { - assert(key === 'TextDelta' && parsed.TextDelta.delta === 'Hello', f.name, - `expected TextDelta with delta "Hello", got ${JSON.stringify(parsed)}`) - } else if (f.name === 'stream_part_finish') { - assert(key === 'Finish' && parsed.Finish.usage !== undefined, f.name, - `expected Finish with usage, got ${JSON.stringify(parsed)}`) - } else if (f.name === 'stream_part_stream_start') { - assert(key === 'StreamStart' && Array.isArray(parsed.StreamStart.warnings), f.name, - `expected StreamStart with warnings array, got ${JSON.stringify(parsed)}`) - } else if (f.name === 'stream_part_raw') { - assert(key === 'Raw' && parsed.Raw.raw_value?.id === 'c1', f.name, - `expected Raw with raw_value, got ${JSON.stringify(parsed)}`) - } - } -} - -// ── Role wire format ─────────────────────────────────────────────────────── - -function testRole() { - console.log('Role:') - - for (const f of fixtures.filter((f: any) => f.type === 'Role')) { - const parsed = JSON.parse(f.json) - if (f.name === 'role_user') { - assert(parsed === 'user', f.name, `expected "user", got ${JSON.stringify(parsed)}`) - } else if (f.name === 'role_system') { - assert(parsed === 'system', f.name, `expected "system", got ${JSON.stringify(parsed)}`) - } - } -} - -// ── GenerateTextOptions wire format ──────────────────────────────────────── - -function testGenerateTextOptions() { - console.log('GenerateTextOptions:') - - for (const f of fixtures.filter((f: any) => f.type === 'GenerateTextOptions')) { - const parsed = JSON.parse(f.json) - assert(typeof parsed === 'object' && parsed !== null, f.name, 'expected object') - - if (f.name === 'generate_text_options_default') { - // All fields should be null - const allNull = Object.values(parsed).every((v: any) => v === null) - assert(allNull, f.name, 'all fields should be null for default') - } else if (f.name === 'generate_text_options_include_raw_chunks_true') { - // RFC-0016 M2 true-case: only include_raw_chunks set - assert(parsed.include_raw_chunks === true, f.name, - `expected include_raw_chunks:true, got ${JSON.stringify(parsed)}`) - const rest = { ...parsed } - delete rest.include_raw_chunks - assert(Object.values(rest).every((v: any) => v === null), f.name, - 'all other fields should stay null') - } - - if (f.name === 'generate_text_options_with_session_id') { - // RFC-0024: explicit session_id crosses the wire (snake_case). - assert( - parsed.session_id === 'sess-1', - f.name, - `expected session_id "sess-1", got ${JSON.stringify(parsed.session_id)}`, - ) - } - } -} - -// ── ModelMessage wire format ─────────────────────────────────────────────── - -function testModelMessage() { - console.log('ModelMessage:') - - for (const f of fixtures.filter((f: any) => f.type === 'ModelMessage')) { - const parsed = JSON.parse(f.json) - if (f.name === 'model_message_text') { - assert(parsed.role === 'user' && parsed.content === 'Hello', f.name, - `expected role:"user", content:"Hello", got ${JSON.stringify(parsed)}`) - } else if (f.name === 'model_message_provider_executed_tool_transcript') { - const [call, result] = parsed.content ?? [] - assert( - parsed.role === 'assistant' && call?.type === 'tool_call' && result?.type === 'tool_result', - `${f.name} order`, - `expected assistant [tool_call, tool_result], got ${JSON.stringify(parsed)}`, - ) - assert( - call?.provider_executed === true && call?.thought_signature === 'sig_provider', - `${f.name} provider execution`, - `provider_executed/thought_signature missing: ${JSON.stringify(call)}`, - ) - assert( - call?.provider_options?.anthropic?.serverName === 'docs' && - result?.provider_options?.anthropic?.resultId === 'result_1', - `${f.name} metadata`, - `provider metadata missing: ${JSON.stringify(parsed.content)}`, - ) - } - } -} - -// ── Native module smoke test ─────────────────────────────────────────────── - -async function testNativeModule() { - console.log('Native module:') - - try { - // The napi-generated index.js uses require() (CommonJS), so use createRequire. - const { createRequire } = await import('node:module') - const require = createRequire(import.meta.url) - const { openai } = require('../bindings/node/index.js') - assert(typeof openai === 'function', 'native module loads', 'openai is not a function') - - const model = await openai('sk-test-fake-key', 'gpt-4o-mini') - assert(model !== undefined, 'model creation', 'model is undefined') - assert(typeof model.generateText === 'function', 'generateText method', 'missing method') - assert(typeof model.streamText === 'function', 'streamText method', 'missing method') - } catch (e) { - assert(false, 'native module', String(e)) - } -} - -// ── Run all tests ────────────────────────────────────────────────────────── - -async function main() { - console.log(`\nContract tests (${fixtures.length} fixtures)\n`) - - testToolChoice() - testStreamPart() - testRole() - testGenerateTextOptions() - testModelMessage() - await testNativeModule() - - console.log(`\n${passed} passed, ${failed} failed\n`) - process.exit(failed > 0 ? 1 : 0) -} - -main() diff --git a/docs/api/gaps.md b/docs/api/gaps.md index 5bdcec9fb..07aebde20 100644 --- a/docs/api/gaps.md +++ b/docs/api/gaps.md @@ -197,12 +197,15 @@ The wire shape changed with it: |---|---|---| | Wire JSON | `"input": {"city":"Paris"}` | `"input": "{\"city\":\"Paris\"}"` | -Serialization always writes the JSON string; nothing writes the old shape any -more. Deserialization accepts both — a JSON string loads unchanged, and any -other JSON value (object, array, number, bool, null) is re-serialized to its -compact JSON text (`deserialize_tool_call_input` in -`aimux-core/src/result.rs`). A `GenerateResult` persisted or recorded before -the refactor therefore keeps loading, with no migration step. +Serialization always writes the JSON string, and deserialization accepts only +the JSON string. The pre-refactor shape (an object, array, number, bool or +null in `input`) no longer loads: the compatibility deserializer was removed +when the tool-call types were unified, and a `GenerateResult` persisted before +PR #165 must be re-recorded. + +The same rule holds for a provider's stream: `StreamPart::ToolCall` carries a +`RawToolCall` whose `input` is the raw text. After `stream_text` the part is a +`TextStreamPart`, whose `ToolCall` carries the parsed `tool::ToolCall`. The parsed arguments still reach callers as a `Value`, on `GenerateTextResult.tool_calls[].input` — that field is unchanged. diff --git a/scripts/local-ci.sh b/scripts/local-ci.sh index d6b3152e3..f6068d04f 100755 --- a/scripts/local-ci.sh +++ b/scripts/local-ci.sh @@ -4,7 +4,6 @@ # Mirrors .github/workflows/ci.yml (host jobs only; cross-platform matrix # targets and Flutter example builds stay in CI): # rust cargo fmt + clippy -D warnings + cargo test --workspace -# contract Rust contract tests + Node run-node.ts # python maturin develop --release + pytest (bindings/python venv) # node npm ci + napi build + npm test + tsc typecheck # go go vet + go test (needs local-ci-setup.sh) @@ -13,9 +12,9 @@ # # Usage: # scripts/local-ci.sh # everything installed -# scripts/local-ci.sh --only=rust,contract +# scripts/local-ci.sh --only=rust,python # scripts/local-ci.sh --skip=node,flutter -# scripts/local-ci.sh --quick # rust + contract only +# scripts/local-ci.sh --quick # rust only # # Exit code 0 = all gates green. Stops at the first failing gate. @@ -33,7 +32,7 @@ for arg in "$@"; do *) echo "unknown arg: $arg" >&2; exit 2 ;; esac done -[ "$QUICK" -eq 1 ] && { ONLY="${ONLY:-rust,contract}"; } +[ "$QUICK" -eq 1 ] && { ONLY="${ONLY:-rust}"; } want() { # want local stage="$1" @@ -70,17 +69,6 @@ if want rust; then fi fi -# ── contract ─────────────────────────────────────────────────────────────── -if want contract; then - section "contract: Rust/Node contract tests" - if cargo test --test contract_test -p aimux-core --quiet \ - && node --experimental-strip-types contract-tests/run-node.ts | grep -q '0 failed'; then - pass "contract" - else - fail "contract" - fi -fi - # ── python ───────────────────────────────────────────────────────────────── if want python; then section "python: maturin develop + pytest" diff --git a/tools/aimux-web/src/wire.rs b/tools/aimux-web/src/wire.rs index 0c97f2ad6..4222b79d9 100644 --- a/tools/aimux-web/src/wire.rs +++ b/tools/aimux-web/src/wire.rs @@ -179,7 +179,6 @@ fn to_content_part(p: &WireContentPart) -> ContentPart { tool_name: tool_name.clone(), input: input.clone(), provider_executed: *provider_executed, - thought_signature: None, provider_options: None, }, WireContentPart::ToolResult {